Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ follow [SemVer](https://semver.org/).

## [Unreleased]

### Added
- Drift guards batch (architecture review findings F1/F6/F9,
2026-08-20):
- `tests/test_grammar_completeness.py` — the F1 pin: every
`QuestionType` member must carry a row in the completeness tables
(widget collect mode + a wrong-shaped answer), and each of the
four surfaces must emit construct-specific output for it — a
construct wired into only three surfaces, or a new type added
without updating the tables, fails red instead of silently falling
through a default branch
- `tests/test_docs_drift.py` — the grammar's hand-maintained docs
tracked mechanically: README's spelled-out construct count and
per-construct coverage, SKILL.md's coverage of every question
type, and every MCP tool / `x_to_y` library function the skill
names must actually exist (the count had already rotted by hand
once, commit 543a7a0)
- `docs/adding-a-construct.md` — the ~19-touchpoint checklist for a
new construct, with the review's accept-and-pin ruling and the
rejected registry/base-class alternatives recorded
- Surface-decision authority stated where it was only implicit
(F9): `select_form_surface` docstring and README now say the
router is advisory in the shipped plugin — the agent's MCP tool
choice is the effective decision and the router runs after the
fact for telemetry agreement; binding only for library consumers
routing their own calls

### Changed
- 0.6.x cleanup batch (architecture review findings F4/F5/F7,
2026-08-20) — single-sourcing and schema hygiene, output
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ if select_form_surface(form) == "widget":
`problems_to_markdown` re-asks exactly the fields that failed.
- **Surface routing** — `select_form_surface` picks widget vs fallback;
a keyboard-mode opt-out is persisted per project. The form degrades —
it never breaks.
it never breaks. Authority note: in the shipped plugin the router is
*advisory* — the agent's choice of MCP tool IS the surface decision,
guided by the skill's prose ladder, and the router runs after the
fact so telemetry can record agreement. Library consumers routing
their own calls (as above) are the path where its answer is binding.
- **Validation** — `form_from_dict` refuses malformed definitions;
`collect_form_response` refuses malformed answers (required fields,
option membership) with field-level problems.
Expand Down
99 changes: 99 additions & 0 deletions docs/adding-a-construct.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Adding a construct: the touchpoint checklist

A new construct type costs roughly 19 files and ~1,000 lines, about
half of it tests. The 2026-08-20 architecture review ruled that cost
the honest price of four surfaces — a construct *means something
different* on each one, so the per-surface branches are four genuine
translations, not duplication a registry could collapse (rejected
alternatives, recorded so they stay rejected: a `Construct` base class
with per-surface render methods; entry-point plugin discovery; codegen
from a spec table). What the review added instead is this explicit
checklist and the drift catchers that turn a forgotten touchpoint into
a red test (`tests/test_grammar_completeness.py`).

Worked examples: `ranking` (PR #24, 20 files, +865) and
`assumption_review` (PR #25, 19 files, +1219).

## The model (1–2)

1. **`models.py` — `QuestionType`**: add the member, with the spec
prose as its comment block (the enum body is the grammar's
normative text). If the construct carries new extras, add them as
fields on the `FormQuestion` dataclass — additive columns, no
subclassing.
2. **`models.py` — `to_ask_user_format` / `to_ask_user_formats`**: how
the construct degrades on a plain question tool. An expanding
construct (one payload per item/slot) raises in the singular method
and expands in the plural one; iterate rows through
`expansion_items` / `suggested_pick` / `item_context` so the
surfaces cannot disagree.

## The parser and validator (3–7)

3. **`bridge.py` — `_parse_<x>_extras`**: a new parser returning
`(value(s), problems)`, called from `form_from_dict` and guarded
internally by `qtype is not QuestionType.X`. Reject what the
construct's rules forbid (e.g. `default` on a ranking, D2).
4. **`bridge.py` — `form_from_dict`**: call the parser, extend
`problems`, pass the new kwarg to the `FormQuestion(...)`
construction.
5. **`bridge.py` — the frozensets**: `_OPTIONS_REQUIRED_TYPES`,
`_WIDGET_ONLY_TYPES`, `_EXPANDING_TYPES`, `_NO_PORTABLE_CONTROL` —
add the type wherever its behavior matches.
6. **`bridge.py` — `_validate_<x>` + `_ANSWER_VALIDATORS`**: the
answer-shape validator, registered in the dict. Six constructs
whose answer is one selected option just register
`_validate_membership`.
7. **`bridge.py` — `_fold_expanded_answers`**: only if the construct
expands to dotted keys on flat surfaces — how they fold back.

## The four surfaces (8–14)

8. **`elicitation_schema.py` — `_property_for` /
`form_to_elicitation_schema`**: the native-elicitation projection
(flat primitives; expanding constructs become dotted properties).
9. **`widget.py` — `_control_<x>_html` + `_CONTROL_RENDERERS`**: the
rich HTML control, registered in the dict.
10. **`widget.py` — `_COLLECT_MODES`**: how the submit script reads
the answer out of the DOM. A construct that answers like an
existing one reuses its mode and needs NO script edit; a genuinely
new answer shape needs a new script case AND a new case in the
gate-parity port (`tests/test_widget_roundtrip.py`).
11. **`widget.py` — `_families_for`** and **`theme.py` — `CSS_<X>` +
`CSS_FAMILIES`**: the control's CSS family, so forms never ship
styles they don't use.
12. **`markdown_surface.py` — `_control_lines` + `_skeleton_value`**:
the portable-markdown rendering and the reply skeleton's
placeholder shape.
13. **`markdown_ingestion.py`**: how a typed shorthand line for the
construct parses back (`_known_keys`, `_coerce`,
`_resolve_line_key`, `markdown_to_answers`).
14. **`mcp_server.py` — `_field_schema`**: the type enum entry, a line
in the prose description, and any new extra-key property. The
schema drift test names what you forget.

## The exemplar and exports (15–16)

15. **`reference_form.py`**: one field for the new type in
`REFERENCE_FORM` plus a valid answer in `EXAMPLE_ANSWERS` — the
round-trip, CSS, and grammar-completeness suites all span it, and
`test_reference_form` fails until the field exists.
16. **`__init__.py`**: export any new public helper in `__all__`.

## Tests and docs (17–19)

17. **Tests**: a `tests/test_<x>_construct.py` file (definition rules,
answer validation, each surface's rendering), plus rows in the
completeness tables of `tests/test_grammar_completeness.py`
(collect mode + wrong-shaped answer) and gate-parity fixtures in
`tests/test_widget_roundtrip.py`.
18. **Docs**: README "The grammar" (bullet AND the spelled-out
construct count), `plugin/skills/forms/SKILL.md` (a `##` section:
extra keys, answer shape, flat-surface expansion), CHANGELOG.
`tests/test_docs_drift.py` enforces the count and the name
coverage.
19. **Sanity**: `python -m pytest` — the drift catchers
(`test_grammar_completeness`, `test_widget_roundtrip`,
`test_widget_css_families`, `test_docs_drift`,
`test_reference_form`, the `_field_schema` coverage test) are
designed to fail red on any touchpoint you missed above.
12 changes: 12 additions & 0 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,18 @@ def select_form_surface(
input — the axis is how much of the option space the user can see
at once, not how many tool calls it costs.

.. note::
Authority (architecture review F9, 2026-08-20): in the shipped
plugin this router is **advisory** — the agent's choice of MCP
tool is the effective surface decision, made from the skill's
prose ladder, and the MCP handlers call this only *after the
fact* (passing ``chosen``) so telemetry records agreement vs
disagreement. Its return value is binding only for library
consumers who route their own render calls through it. The
markdown surface is outside its range entirely (it can return
only ``"widget"`` / ``"ask"``) — revisit when the markdown
surface gains an MCP tool.

Precedence, highest first:

1. **Capability floor** — a client that cannot render widgets gets
Expand Down
85 changes: 85 additions & 0 deletions tests/test_docs_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Docs drift catchers (architecture review finding F6, 2026-08-20).

The grammar is documented by hand in three places — README's "The
grammar" section, the plugin skill, and the CHANGELOG — and the
construct COUNT has already rotted once (commit 543a7a0 hand-corrected
"six"). The code-level drift catchers (round-trip, CSS families,
version sync) had no docs-level counterpart, so the next construct's
documentation depended entirely on the author remembering. These tests
are that counterpart: they read the real files and fail red when the
grammar and its documentation disagree.
"""

from __future__ import annotations

import re
from pathlib import Path

from attune_forms import __all__ as _public_names
from attune_forms.mcp_server import tool_definitions
from attune_forms.models import QuestionType

_ROOT = Path(__file__).resolve().parent.parent
_README = (_ROOT / "README.md").read_text(encoding="utf-8")
_SKILL = (_ROOT / "plugin" / "skills" / "forms" / "SKILL.md").read_text(encoding="utf-8")

#: The plain controls; every other QuestionType member is a construct.
_CORE_TYPES = {
QuestionType.TEXT_INPUT,
QuestionType.SINGLE_SELECT,
QuestionType.MULTI_SELECT,
QuestionType.BOOLEAN,
QuestionType.NUMBER,
QuestionType.DATE,
QuestionType.TEXTAREA,
}
_CONSTRUCTS = [t for t in QuestionType if t not in _CORE_TYPES]

_COUNT_WORDS = {
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
10: "ten",
11: "eleven",
12: "twelve",
}


def test_readme_states_the_real_construct_count() -> None:
""" "eight constructs" must track the enum — the count is a
maintained invariant that has been hand-corrected before."""
word = _COUNT_WORDS[len(_CONSTRUCTS)]
assert f"{word} constructs" in _README


def test_readme_describes_every_construct() -> None:
lower = _README.lower()
for qtype in _CONSTRUCTS:
name = qtype.value.replace("_", " ")
assert name in lower, f"README's grammar section is missing {qtype.value!r}"


def test_skill_describes_every_question_type() -> None:
lower = _SKILL.lower()
for qtype in QuestionType:
assert qtype.value in lower, f"SKILL.md is missing {qtype.value!r}"


def test_skill_names_only_real_mcp_tools() -> None:
real = {tool.name for tool in tool_definitions()}
named = set(re.findall(r"`(elicitation_[a-z_]+)`", _SKILL))
ghosts = named - real
assert not ghosts, f"SKILL.md names MCP tool(s) that do not exist: {sorted(ghosts)}"


def test_skill_names_only_real_library_functions() -> None:
"""A backticked transform name (`x_to_y` shape) in the skill must be
a real public export or a real MCP tool — the skill is the agent's
instruction sheet, and a renamed function leaves it instructing the
impossible."""
real = set(_public_names) | {tool.name for tool in tool_definitions()}
named = {name for name in re.findall(r"`([a-z][a-z0-9_]*)\(?", _SKILL) if "_to_" in name}
ghosts = named - real
assert not ghosts, f"SKILL.md names library function(s) that do not exist: {sorted(ghosts)}"
Loading
Loading