diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fd9ff..84c8d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,22 @@ follow [SemVer](https://semver.org/). fallback, matching what the code reads ### Fixed +- Author-supplied field text can no longer desync the markdown reply + skeleton (confirmation-pass-2 needs-a-look, 2026-08-20 — the LOUD + sibling of the pass-2 silent-injection fix). A literal triple-backtick + fence inside a label, help text, or OPTION used to open a stray code + fence in the rendered form, so the trailing `answers` skeleton was no + longer cleanly delimited and paste-back failed loudly ("fenced code + block is not valid JSON"). Every author/host line rendered by + `form_to_markdown` and the `problems_to_markdown` re-ask is now defused + — runs of three+ backticks get a woven zero-width break so no ``` + substring survives, while inline `` `code` `` (runs under three) + renders untouched. A fence-bearing value that reaches the JSON skeleton + itself (a default/recommended/suggested option carrying a fence) has + each backtick emitted as the JSON unicode escape `\u0060`, which + `json.loads` restores on ingestion, so the skeleton's own fence stays + intact and exact option matching is unchanged. The widget surface + HTML-escapes and was already immune - **Directly-built D2 gate with a `default` can no longer pass unanswered** (checkpoint-2 promoted item, 2026-08-20 — empirically confirmed). The no-`default` rule for the two-way constructs (confirm, diff --git a/src/attune_forms/markdown_ingestion.py b/src/attune_forms/markdown_ingestion.py index c8b7d3c..f8942dd 100644 --- a/src/attune_forms/markdown_ingestion.py +++ b/src/attune_forms/markdown_ingestion.py @@ -31,7 +31,12 @@ import re from typing import Any -from attune_forms.markdown_surface import _field_lines, reply_skeleton +from attune_forms.markdown_surface import ( + _defuse_fences, + _field_lines, + _skeleton_block, + reply_skeleton, +) from attune_forms.models import ( ASSUMPTION_TEXT_SUFFIX, FormQuestion, @@ -475,15 +480,16 @@ def problems_to_markdown(form: FormSchema, problems: list[str]) -> str: field = _field_lines(q) field[0] = f"{idx}. {field[0]}" lines += ["", *field] + # Defuse first: a re-asked field's text OR a quoted problem string may + # carry a fence, and either would desync the skeleton emitted below + # (same last-block-wins invariant form_to_markdown relies on). + lines = [_defuse_fences(line) for line in lines] if offenders: - skeleton = reply_skeleton(form, offenders) lines += [ "", "Reply for just these fields — shorthand works (`field_id: value` " "or `N: value`), or fill the `answers` skeleton below:", "", - "```json", - json.dumps(skeleton, indent=2, ensure_ascii=False), - "```", + *_skeleton_block(reply_skeleton(form, offenders)), ] return "\n".join(lines) diff --git a/src/attune_forms/markdown_surface.py b/src/attune_forms/markdown_surface.py index 8613bad..749371e 100644 --- a/src/attune_forms/markdown_surface.py +++ b/src/attune_forms/markdown_surface.py @@ -19,6 +19,7 @@ from __future__ import annotations import json +import re from typing import Any from attune_forms.models import ( @@ -35,6 +36,47 @@ ) from attune_forms.widget import WIDGET_RESPONSE_MARKER +#: Any run of three or more backticks — a markdown fence opener/closer. +_FENCE_RUN_RE = re.compile(r"`{3,}") + +#: Woven between backticks to defuse a run; invisible in a terminal. +_FENCE_ZWSP = "​" + + +def _defuse_fences(text: str) -> str: + """Break any run of three+ backticks in author/host-supplied text so + it can never open or close a markdown fence and desync the trailing + reply skeleton (confirmation pass 2, 2026-08-20). + + A fence-bearing label, help text, or OPTION rendered verbatim used to + corrupt the delimiting of the ``answers`` skeleton + :func:`form_to_markdown` appends — the primary taught JSON-reply path + then failed to round-trip (``markdown_to_answers`` saw the wrong + fence boundaries). A zero-width space is woven between the backticks: + the run still reads as backticks in a terminal but no ``` substring + survives, so ``markdown_ingestion._FENCE_RE`` cannot mistake field + text for a fenced block. Runs shorter than three (real inline code, + `` `x` ``) are left untouched so legitimate inline backticks render. + """ + if "```" not in text: + return text + return _FENCE_RUN_RE.sub(lambda m: _FENCE_ZWSP.join(m.group()), text) + + +def _skeleton_block(skeleton: dict[str, Any]) -> list[str]: + """The trailing ``answers`` skeleton as a fenced ``json`` block. + + Backticks inside the payload are emitted as the JSON escape + ``\\u0060`` — a fence-bearing author value that reaches the skeleton + (a default/recommended/suggested option carrying ```) would otherwise + close this block's own ``json`` fence early and break the paste-back. + Every backtick in the dump sits inside a JSON string, where + ``\\u0060`` is valid and ``json.loads`` restores it, so exact option + matching on ingestion is unchanged (confirmation pass 2, 2026-08-20). + """ + payload = json.dumps(skeleton, indent=2, ensure_ascii=False).replace("`", "\\u0060") + return ["```json", payload, "```"] + def _option_lines(q: FormQuestion, *, badge_for: dict[str, str] | None = None) -> list[str]: """Bullet (or numbered) lines for a select-like question's options. @@ -280,7 +322,9 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str: field = _field_lines(q) field[0] = f"{idx}. {field[0]}" lines += ["", *field] - skeleton = reply_skeleton(form) + # Defuse every author/host line before the machine skeleton: a fence + # in field text must not desync the ``answers`` block below it. + lines = [_defuse_fences(line) for line in lines] lines += [ "", "---", @@ -291,8 +335,6 @@ def form_to_markdown(form: FormSchema, message: str = "") -> str: "(`field_id.1: b`); an assumption row is `field_id.item_id: accept`, " "`field_id.item_id: reject`, or `field_id.item_id: edit: `:", "", - "```json", - json.dumps(skeleton, indent=2, ensure_ascii=False), - "```", + *_skeleton_block(reply_skeleton(form)), ] return "\n".join(lines) diff --git a/tests/test_markdown_ingestion.py b/tests/test_markdown_ingestion.py index ccbcf03..7d4ff49 100644 --- a/tests/test_markdown_ingestion.py +++ b/tests/test_markdown_ingestion.py @@ -510,3 +510,99 @@ def test_non_edit_dict_ruling_not_laundered_into_edit(self) -> None: assert answers["a"]["item"] == {"keep": True} # NOT rewritten to {"edit": ...} with pytest.raises(FormValidationError, match="invalid ruling"): collect_form_response(form, answers) + + def test_fence_bearing_option_keeps_skeleton_parseable(self) -> None: + # LOUD variant of the field-text injection: a literal ``` fence + # inside an OPTION used to desync `_FENCE_RE`, so the trailing + # skeleton was no longer cleanly delimited and paste-back failed + # loudly ("fenced code block is not valid JSON"). The rendered + # fence is now defused so the round-trip stays clean. + form = form_from_dict( + { + "title": "Demo", + "fields": [ + { + "id": "pick", + "type": "single_select", + "text": "Choose", + "options": ["ok\n```\nsneaky", "other"], + } + ], + } + ) + markdown = form_to_markdown(form) + answers, problems = markdown_to_answers(form, markdown) + assert problems == [] # skeleton still parses; no injected problems + assert answers == {} # nothing picked -> all placeholders unanswered + # A real pick typed as shorthand alongside the pasted form resolves. + picked, picked_problems = markdown_to_answers(form, markdown + "\npick: other") + assert picked == {"pick": "other"} + assert picked_problems == [] + + def test_fence_bearing_default_survives_in_skeleton(self) -> None: + # A fence-bearing value that reaches the JSON skeleton (a default + # option carrying ```) would close the skeleton's own ```json + # fence early; backticks emit as the \\u0060 escape, which + # `json.loads` restores, so the exact option round-trips. + form = form_from_dict( + { + "title": "Demo", + "fields": [ + { + "id": "pick", + "type": "single_select", + "text": "Choose", + "options": ["a```b", "other"], + "default": "a```b", + } + ], + } + ) + markdown = form_to_markdown(form) + assert "```json" in markdown # the skeleton fence is intact + answers, problems = markdown_to_answers(form, markdown) + assert answers == {"pick": "a```b"} # raw value preserved for matching + assert problems == [] + collect_form_response(form, answers) # validates against the option + + def test_fence_in_reask_field_text_keeps_reask_skeleton_parseable(self) -> None: + # The re-ask path shares `_field_lines`; a fence in a re-asked + # field's text must not desync the re-ask skeleton either. + form = form_from_dict( + { + "title": "Demo", + "fields": [ + { + "id": "pick", + "type": "single_select", + "text": "Choose ```\nfence", + "options": ["x", "y"], + }, + {"id": "other", "type": "text_input", "text": "Other"}, + ], + } + ) + reask = problems_to_markdown(form, ["'pick' is not a valid option"]) + answers, problems = markdown_to_answers(form, reask) + assert answers == {} + assert problems == [] + + def test_inline_backticks_in_field_text_still_render(self) -> None: + # Defusing must only touch runs of three+ backticks; real inline + # code (single/double backticks) survives verbatim. + form = form_from_dict( + { + "title": "T", + "fields": [ + { + "id": "q", + "type": "text_input", + "text": "Use `code` here", + "help_text": "like ``x`` too", + } + ], + } + ) + markdown = form_to_markdown(form) + assert "`code`" in markdown + assert "``x``" in markdown