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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ follow [SemVer](https://semver.org/).
## [Unreleased]

### Added
- Widget gate parity pin (architecture review finding F2, 2026-08-20):
the submit script's client-side required-field gate (incomplete
rulings boards, unfilled ranking slots, blank `edit` text, the
optional partial-ranking block) is now ported rule-for-rule into the
round-trip simulator and asserted equivalent to the server
validators — for every construct × fill state, the gate blocks the
post exactly when `collect_form_response` would reject the payload.
A structural anchor check also pins the DOM attributes the gate
queries (`data-required`, `[data-item]`, `data-rank-n`) to what the
renderer emits. Tests only — no behavior change; the previously
untested drift class (gate lets an invalid answer post, or blocks a
valid one, after the widget is dead) now fails red in CI
- Drift guards batch (architecture review findings F1/F6/F9,
2026-08-20):
- `tests/test_grammar_completeness.py` — the F1 pin: every
Expand Down
285 changes: 284 additions & 1 deletion tests/test_widget_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
"fid": a["data-fid"],
"ftype": a.get("data-ftype"),
"collect": a.get("data-collect"),
"required": "data-required" in a,
"controls": [],
}
)
Expand All @@ -98,8 +99,11 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
self._in_ranked = False
if "data-item" in a and self.fields:
# A triage row: subsequent controls belong to this item, the
# way the submit script scopes its per-row query.
# way the submit script scopes its per-row query. The row
# count is what the gate's completeness check divides by
# (`f.querySelectorAll('[data-item]').length`).
self._item = a["data-item"]
self.fields[-1]["rows"] = self.fields[-1].get("rows", 0) + 1
if "data-control" in a and self.fields:
controls = self.fields[-1]["controls"]
if self._in_ranked and tag == "input":
Expand Down Expand Up @@ -224,6 +228,58 @@ def _submit(dom: _WidgetDOM) -> dict[str, Any]:
return json.loads(json.dumps(payload))


def _gate(dom: _WidgetDOM, answers: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Port of the submit script's required-field gate, rule for rule.

Mirrors the two gate passes the script runs before posting: the
required-field check (empty answer, incomplete rulings board, an
unfilled ranking slot, an ``edit`` ruling with blank replacement
text) and the all-or-nothing check for OPTIONAL partially-filled
rankings. Returns ``(missing, partial)`` field-id lists — the
script blocks the post when either is non-empty.
"""
missing: list[str] = []
for field in dom.fields:
if not field.get("required"):
continue
v = answers.get(field["fid"])
rows = field.get("rows", 0)
slots = field.get("slots", 0)
blank_edit = False
if isinstance(v, dict):
for ruling in v.values():
if isinstance(ruling, dict) and not str(ruling.get("edit") or "").strip():
blank_edit = True
empty = (
v is None
or v == ""
or (isinstance(v, list) and not v)
or (rows > 0 and (not v or len(v) < rows))
or (slots > 0 and (not v or len(v) < slots))
or blank_edit
)
if empty:
missing.append(field["fid"])
partial: list[str] = []
for field in dom.fields:
if field.get("required"):
continue
slots = field.get("slots", 0)
if not slots:
continue
v = answers.get(field["fid"])
if not isinstance(v, list) or len(v) == 0 or len(v) >= slots:
continue
partial.append(field["fid"])
return missing, partial


def _gate_allows(dom: _WidgetDOM) -> bool:
"""True iff the gate would let the current DOM state post."""
missing, partial = _gate(dom, _submit(dom)["answers"])
return not missing and not partial


def _render_reference() -> tuple[Any, _WidgetDOM]:
form = form_from_dict(REFERENCE_FORM)
dom = _WidgetDOM()
Expand Down Expand Up @@ -372,3 +428,230 @@ def test_fill_caps_ranking_at_the_slot_budget(self) -> None:
_fill(dom, {"rollout_order": ["us-prod", "eu-prod", "canary", "staging"]})
field = next(f for f in dom.fields if f["fid"] == "rollout_order")
assert field["ranked"] == ["us-prod", "eu-prod", "canary"]


def _gate_form(field: dict[str, Any]) -> tuple[Any, _WidgetDOM]:
form = form_from_dict({"title": "Gate parity", "fields": [field]})
dom = _WidgetDOM()
dom.feed(form_to_widget_html(form, instance_id="gate"))
return form, dom


#: One fixture per construct type × fill state: ``(case id, field dict,
#: fill)`` — ``fill=None`` leaves the rendered DOM untouched. No case
#: hardcodes an expected verdict: the parity assertion is the property
#: itself (gate blocks ⇔ validator rejects), so a fixture whose two
#: sides disagree fails no matter which side is "right".
_TRIAGE_FIELD: dict[str, Any] = {
"id": "board",
"type": "triage",
"text": "Rule each finding.",
"triage_items": [{"id": "a", "label": "Finding A"}, {"id": "b", "label": "Finding B"}],
"dispositions": ["fix", "dismiss"],
}
_ASSUME_FIELD: dict[str, Any] = {
"id": "review",
"type": "assumption_review",
"text": "Rule each assumption.",
"assumptions": [{"id": "a1", "label": "Py 3.10 floor"}, {"id": "a2", "label": "CI on push"}],
}
_RANK_FIELD: dict[str, Any] = {
"id": "order",
"type": "ranking",
"text": "Ship order?",
"options": ["staging", "canary", "prod"],
"top_n": 2,
}
_GATE_CASES: list[tuple[str, dict[str, Any], Any]] = [
("text-empty", {"id": "f", "type": "text_input", "text": "Name?"}, None),
("text-filled", {"id": "f", "type": "text_input", "text": "Name?"}, "Dark mode"),
(
"select-empty",
{"id": "f", "type": "single_select", "text": "?", "options": ["a", "b"]},
None,
),
(
"select-filled",
{"id": "f", "type": "single_select", "text": "?", "options": ["a", "b"]},
"b",
),
("multi-empty", {"id": "f", "type": "multi_select", "text": "?", "options": ["a", "b"]}, None),
(
"multi-filled",
{"id": "f", "type": "multi_select", "text": "?", "options": ["a", "b"]},
["a"],
),
("boolean-empty", {"id": "f", "type": "boolean", "text": "?"}, None),
("boolean-filled", {"id": "f", "type": "boolean", "text": "?"}, "Yes"),
("number-empty", {"id": "f", "type": "number", "text": "?", "minimum": 0}, None),
("number-zero", {"id": "f", "type": "number", "text": "?", "minimum": 0}, 0),
("date-empty", {"id": "f", "type": "date", "text": "?"}, None),
("date-filled", {"id": "f", "type": "date", "text": "?"}, "2026-08-01"),
("textarea-empty", {"id": "f", "type": "textarea", "text": "?"}, None),
("textarea-filled", {"id": "f", "type": "textarea", "text": "?"}, "notes"),
(
"decision-empty",
{
"id": "f",
"type": "decision",
"text": "?",
"options": ["flag", "all at once"],
"recommended": "flag",
"rationale": "safer",
},
None,
),
(
"decision-filled",
{
"id": "f",
"type": "decision",
"text": "?",
"options": ["flag", "all at once"],
"recommended": "flag",
"rationale": "safer",
},
"flag",
),
(
"pushback-empty",
{
"id": "f",
"type": "pushback",
"text": "?",
"options": ["branch", "stacked PRs"],
"user_position": "branch",
"recommended": "stacked PRs",
"rationale": "drift",
},
None,
),
(
"deliberation-filled",
{
"id": "f",
"type": "deliberation",
"text": "?",
"options": ["lru", "redis"],
"endorsements": {"lru": ["claude"]},
"recommended": "lru",
"rationale": "one consumer",
},
"lru",
),
(
"progress-empty",
{
"id": "f",
"type": "progress",
"text": "?",
"options": ["Design sign-off"],
"progress_items": [
{"label": "Prototype", "status": "done"},
{"label": "Design sign-off", "status": "blocked"},
],
},
None,
),
(
"progress-filled",
{
"id": "f",
"type": "progress",
"text": "?",
"options": ["Design sign-off"],
"progress_items": [
{"label": "Prototype", "status": "done"},
{"label": "Design sign-off", "status": "blocked"},
],
},
"Design sign-off",
),
(
"confirm-empty",
{"id": "f", "type": "confirm", "text": "Flip the flag?", "consequences": [{"label": "x"}]},
None,
),
(
"confirm-approved",
{"id": "f", "type": "confirm", "text": "Flip the flag?", "consequences": [{"label": "x"}]},
"Approve",
),
("triage-empty", _TRIAGE_FIELD, None),
("triage-partial", _TRIAGE_FIELD, {"a": "fix"}),
("triage-full", _TRIAGE_FIELD, {"a": "fix", "b": "dismiss"}),
("triage-optional-partial", {**_TRIAGE_FIELD, "required": False}, {"a": "fix"}),
("ranking-empty", _RANK_FIELD, None),
("ranking-partial", _RANK_FIELD, ["staging"]),
("ranking-full", _RANK_FIELD, ["staging", "prod"]),
("ranking-optional-empty", {**_RANK_FIELD, "required": False}, None),
("ranking-optional-partial", {**_RANK_FIELD, "required": False}, ["staging"]),
("assume-empty", _ASSUME_FIELD, None),
("assume-partial", _ASSUME_FIELD, {"a1": "accept"}),
("assume-blank-edit", _ASSUME_FIELD, {"a1": {"edit": ""}, "a2": "accept"}),
("assume-edit-text", _ASSUME_FIELD, {"a1": {"edit": "3.11 floor"}, "a2": "accept"}),
("assume-full", _ASSUME_FIELD, {"a1": "accept", "a2": "reject"}),
]


class TestGateParity:
"""The submit script's client-side gate re-implements the server
validators' completeness rules (required boards fully ruled, ranking
all-or-nothing, blank ``edit`` text). Nothing in production keeps the
two in sync — this class is the sync test (architecture review
finding F2, 2026-08-20): for every construct × fill state, the gate
blocks the post exactly when ``collect_form_response`` would reject
the posted payload.
"""

@pytest.mark.parametrize(
("field", "fill"),
[pytest.param(f, v, id=cid) for cid, f, v in _GATE_CASES],
)
def test_gate_blocks_iff_validator_rejects(self, field: dict[str, Any], fill: Any) -> None:
form, dom = _gate_form(field)
if fill is not None:
_fill(dom, {field["id"]: fill})
payload = _submit(dom)
try:
collect_form_response(form, payload["answers"])
validator_clean = True
except FormValidationError:
validator_clean = False
assert _gate_allows(dom) == validator_clean

def test_untouched_reference_form_gate_matches_validator(self) -> None:
"""The all-construct form, untouched: the gate must block for
the same reason the validator names required fields."""
form, dom = _render_reference()
payload = _submit(dom)
with pytest.raises(FormValidationError):
collect_form_response(form, payload["answers"])
assert not _gate_allows(dom)

def test_filled_reference_form_gate_matches_validator(self) -> None:
"""The all-construct form, fully filled: both sides pass."""
form, dom = _render_reference()
_fill(dom, EXAMPLE_ANSWERS)
payload = _submit(dom)
collect_form_response(form, payload["answers"]) # no problems
assert _gate_allows(dom)

def test_gate_reads_the_anchors_the_renderer_emits(self) -> None:
"""Structural drift catcher, gate edition (extends the reader's
collect-mode pin at :meth:`TestSentinelContract`): every DOM
anchor the gate JS queries must appear in the emitted script,
and the ``data-required`` flags the renderer emits must match
the form's required questions — a renamed attribute on either
side fails here before it silently disables a gate rule."""
form, dom = _render_reference()
for anchor in (
"data-required",
"[data-item]",
"data-rank-n",
".ae-rank",
"ae-field-missing",
):
assert anchor in dom.script, f"gate anchor {anchor!r} missing from script"
required_fids = {f["fid"] for f in dom.fields if f.get("required")}
assert required_fids == {q.id for q in form.questions if q.required}
Loading