diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fd9ff..9bb29c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,13 @@ follow [SemVer](https://semver.org/). (`{"keep": true}`) beside a typed text lane is no longer silently laundered into a valid `{"edit": …}` that this surface alone accepted while the collect path names it + - A display-only PROGRESS field (no blocked options) no longer + projects to `{"type": "string", "enum": []}` in the elicitation + schema — an empty enum is a property no value can satisfy (an + unanswerable field, or a whole-schema rejection on a strict + client). Such a report is narrated, not answered, so it is now + skipped from `properties`/`required` entirely; a PROGRESS that + carries blocked options is a real single-pick and still projects - `form_from_template` now validates the `slots` argument type *before* the `values = slots or {}` coalesce (confirmation-pass-2 needs-a-look, 2026-08-20). The pass-1 `isinstance(values, dict)` guard ran after the @@ -143,6 +150,17 @@ follow [SemVer](https://semver.org/). non-`dict` `slots` is now named directly; `None` still coalesces to an empty mapping. `dict` stays strict so the "mapping" wording matches what is accepted — a `Mapping` that is not a `dict` is rejected too +- The widget's `::` radio-group namespace is now collision-guarded at + definition time, symmetric with the existing dotted-key guard + (confirmation-pass-2 needs-a-look, 2026-08-20). A TRIAGE or + ASSUMPTION_REVIEW board with id `a` renders one radio group per item + named `a::`; a sibling field whose id was literally `a::1` emitted + a group sharing that `name`, so the browser fused the two into one + mutually-exclusive group and one field became unanswerable in the + widget. A field id colliding with a board's `a::` namespace is now + rejected at `form_from_dict` time, so the colliding HTML is never + rendered. Low realism (no author writes `::N` ids) but a genuine + unguarded namespace analogous to the guarded dotted one - Confirmation-pass-1 batch (library review, 2026-08-20 — all eight findings empirically confirmed before fixing): - An assumption-review text lane whose item has NO ruling (a diff --git a/src/attune_forms/bridge.py b/src/attune_forms/bridge.py index 1d87355..ae09134 100644 --- a/src/attune_forms/bridge.py +++ b/src/attune_forms/bridge.py @@ -970,6 +970,23 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema: f"{owner.id!r}'s dotted answer namespace ('{owner.id}.')" ) + # The widget's "::" radio-group namespace needs the SAME by-definition + # guard: a TRIAGE / ASSUMPTION_REVIEW board with id "a" renders one + # radio group per item named "a::", so a sibling field whose id is + # literally "a::1" emits a group sharing that name — the browser fuses + # them into one mutually-exclusive group and one field becomes + # unanswerable (confirmation-pass-2 finding, 2026-08-20). Reject at + # definition, symmetric with the dotted guard above. + for owner in questions: + if owner.type not in _WIDGET_RADIO_GROUP_TYPES: + continue + for question in questions: + if question.id != owner.id and question.id.startswith(f"{owner.id}::"): + problems.append( + f"field id {question.id!r} collides with {owner.type.value} " + f"{owner.id!r}'s widget radio-group namespace ('{owner.id}::')" + ) + if problems: raise FormValidationError(problems) @@ -1015,6 +1032,18 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema: {QuestionType.TRIAGE, QuestionType.RANKING, QuestionType.ASSUMPTION_REVIEW} ) +#: Types whose widget renders one radio *group per item*, named +#: ``"::"`` (see ``_control_triage_html`` / +#: ``_control_assumption_review_html``). That ``::`` group namespace is a +#: second reserved namespace — a sibling field whose literal id is +#: ``"::"`` would emit a radio group sharing the board row's +#: ``name``, and the browser would treat them as ONE mutually-exclusive +#: group, making one field unanswerable (confirmation-pass-2 finding, +#: 2026-08-20). RANKING is in :data:`_EXPANDING_TYPES` but NOT here: its +#: widget groups by ``data-opt``, never by a ``::`` radio name, so it owns +#: no such namespace. +_WIDGET_RADIO_GROUP_TYPES = frozenset({QuestionType.TRIAGE, QuestionType.ASSUMPTION_REVIEW}) + #: The strict subset with NO portable ``AskUserQuestion`` control at #: all. A form using any of these cannot be asked on ``AskUserQuestion`` #: in any form — unlike the v3–v5 constructs, which are expressible but diff --git a/src/attune_forms/elicitation_schema.py b/src/attune_forms/elicitation_schema.py index 05cfc44..2cae33a 100644 --- a/src/attune_forms/elicitation_schema.py +++ b/src/attune_forms/elicitation_schema.py @@ -193,6 +193,17 @@ def form_to_elicitation_schema(form: FormSchema) -> dict[str, Any]: if q.required: required.append(f"{q.id}.{key}") continue + if q.type is QuestionType.PROGRESS and not q.options: + # A display-only PROGRESS (no blocked options) has nothing to + # ask — the report is narrated, not answered. Projecting it + # would emit ``{"type": "string", "enum": []}``, a property no + # value can satisfy (an empty enum is unanswerable, and a + # strict client may reject the whole schema over it). Skip it + # entirely, mirroring the narrate-instead fallback in + # ``to_ask_user_format``. A PROGRESS that DOES carry blocked + # options is a real single-pick and still projects below + # (confirmation pass 2, 2026-08-20). + continue properties[q.id] = _property_for(q) if q.required: required.append(q.id) diff --git a/tests/test_elicitation_schema.py b/tests/test_elicitation_schema.py index 971879a..f390f21 100644 --- a/tests/test_elicitation_schema.py +++ b/tests/test_elicitation_schema.py @@ -174,3 +174,58 @@ def test_directly_built_ranking_default_does_not_clobber_suggested(self): # The visible proposal (suggested) survives; the illegal `default` # never reaches the schema. assert prop["default"] == ["C", "B", "A"] + + def test_display_only_progress_is_skipped(self): + # A display-only PROGRESS (no blocked options) projected to + # `{"type": "string", "enum": []}` — an unanswerable property a + # strict client could reject the whole schema over. It carries no + # answer, so it must not appear in properties/required at all + # (confirmation pass 2, 2026-08-20). + form = form_from_dict( + { + "title": "T", + "fields": [ + { + "id": "p", + "text": "Progress", + "type": "progress", + "required": False, + "progress_items": [ + {"label": "A", "status": "done"}, + {"label": "B", "status": "in_flight"}, + ], + } + ], + } + ) + schema = form_to_elicitation_schema(form) + assert "p" not in schema["properties"] + assert "p" not in schema["required"] + + def test_progress_with_blocked_options_still_projects(self): + # A PROGRESS that carries blocked options is a real single-pick and + # must keep projecting as a string enum. + form = form_from_dict( + { + "title": "T", + "fields": [ + { + "id": "p", + "text": "Progress", + "type": "progress", + "options": ["B"], + "progress_items": [ + {"label": "A", "status": "done"}, + {"label": "B", "status": "blocked"}, + ], + } + ], + } + ) + schema = form_to_elicitation_schema(form) + assert schema["properties"]["p"] == { + "title": "Progress", + "type": "string", + "enum": ["B"], + } + assert schema["required"] == ["p"] diff --git a/tests/test_triage_construct.py b/tests/test_triage_construct.py index 6aa536f..a10c33b 100644 --- a/tests/test_triage_construct.py +++ b/tests/test_triage_construct.py @@ -12,6 +12,8 @@ from __future__ import annotations +import re + import pytest from attune_forms import ( @@ -286,6 +288,32 @@ def test_sibling_id_in_dotted_namespace_rejected(self) -> None: with pytest.raises(FormValidationError, match="dotted answer namespace"): form_from_dict(data) + def test_colliding_double_colon_field_id_rejected(self) -> None: + # The widget renders board "findings" as radio groups named + # "findings::0", "findings::1", ... A sibling field whose id is + # literally "findings::1" would emit a group sharing that name, so + # the browser fuses them into one mutually-exclusive group and one + # field becomes unanswerable (confirmation-pass-2, 2026-08-20). + # Reject at definition so the colliding HTML is never rendered. + data = _triage() + data["fields"].append( + {"id": "findings::1", "type": "text_input", "text": "n", "required": False} + ) + with pytest.raises(FormValidationError, match="widget radio-group namespace"): + form_from_dict(data) + + # And pin the collision the guard prevents on the rendered HTML: + # the board's three rows render as three DISTINCT group names + # (findings::0/1/2), each carrying only its own disposition radios. + # A colliding "findings::1" field would inject a foreign group of + # the same name — exactly what the definition guard now forbids. + html = form_to_widget_html(form_from_dict(_triage())) + names = re.findall(r' None: from attune_forms import form_response_summary