Skip to content

Commit cd9f1ca

Browse files
committed
fix(proof): apply stub escaping per context, exactly once (#952)
CI review, and a precise catch: my backslash-doubling commit made _js_string run on _inline's already-doubled output, so json.dumps escaped those doubles again. A title of '\d+' reached the .ts source as four backslashes and read back as '\\d+'. The markdown templates had the same problem in reverse — they got Python-docstring escaping they have no use for. Split the escaping: _collapse is the context-neutral half (one line, no docstring-closing sequence), and each context applies its own escaper to that, once. Python templates get _inline (backslash-doubled, trailing quote padded), the E2E literal gets _js_string(raw), markdown gets _collapse. Tests assert round-trip fidelity rather than absence of a bad character: the E2E literal must json.loads back to exactly the original title, the Python docstring must ast.get_docstring back to exactly the original description, and markdown must contain the original verbatim.
1 parent 2292e09 commit cd9f1ca

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

codeframe/core/proof/stubs.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ def _slugify(text: str) -> str:
177177
return slugify(text)
178178

179179

180+
#: Gates whose template is markdown, where none of the Python/JS escaping
181+
#: applies — the text is rendered as prose.
182+
_MARKDOWN_GATES = frozenset({Gate.DEMO, Gate.MANUAL})
183+
184+
185+
def _collapse(text: str) -> str:
186+
"""One line, with no sequence that can close a Python docstring.
187+
188+
The context-neutral half of the escaping: safe to render anywhere, and the
189+
input every context-specific escaper starts from. Keeping it separate is
190+
the point — escaping is per context and must be applied exactly once, never
191+
stacked (CI review on #952).
192+
"""
193+
return " ".join(str(text).split()).replace('"""', "'''")
194+
195+
180196
def _inline(text: str) -> str:
181197
"""Collapse user text to a single harmless line (#952).
182198
@@ -201,7 +217,7 @@ def _inline(text: str) -> str:
201217
``\"\"\"`` run, so the replacement above does not see it. One space separates
202218
them and reads the same.
203219
"""
204-
collapsed = " ".join(str(text).split()).replace("\\", "\\\\").replace('"""', "'''")
220+
collapsed = _collapse(text).replace("\\", "\\\\")
205221
return collapsed + " " if collapsed.endswith('"') else collapsed
206222

207223

@@ -212,8 +228,14 @@ def _js_string(text: str) -> str:
212228
the literal and append statements. JSON string syntax is a subset of
213229
JavaScript's, so ``json.dumps`` produces a correct literal — quotes
214230
included, which is why the template no longer supplies its own.
231+
232+
Takes the *collapsed* text, never ``_inline``'s output: that is already
233+
backslash-doubled for a Python docstring, and ``json.dumps`` would escape
234+
those doubles again — so a title of ``\\d+`` reached the .ts source as four
235+
backslashes and read back as ``\\\\d+`` (CI review on #952). Escaping is per
236+
context, applied once, never stacked.
215237
"""
216-
return json.dumps(str(text))
238+
return json.dumps(_collapse(text))
217239

218240

219241
def generate_stubs(req: Requirement) -> dict[Gate, str]:
@@ -226,20 +248,24 @@ def generate_stubs(req: Requirement) -> dict[Gate, str]:
226248
"""
227249
result: dict[Gate, str] = {}
228250
slug = _slugify(req.title)
229-
title = _inline(req.title)
230-
description = _inline(req.description)
231251

232252
for obligation in req.obligations:
233-
template = _TEMPLATES.get(obligation.gate, _TEMPLATES[Gate.UNIT])
253+
gate = obligation.gate
254+
template = _TEMPLATES.get(gate, _TEMPLATES[Gate.UNIT])
255+
# Escaping is chosen by the template's language and applied exactly
256+
# once. Markdown renders the text as prose, so it wants neither the
257+
# Python backslash-doubling nor JSON escaping; ``title_js`` always
258+
# starts from the raw title for the same reason.
259+
escape = _collapse if gate in _MARKDOWN_GATES else _inline
234260
content = template.format(
235261
req_id=req.id,
236-
title=title,
237-
title_js=_js_string(title),
238-
description=description,
262+
title=escape(req.title),
263+
title_js=_js_string(req.title),
264+
description=escape(req.description),
239265
slug=slug,
240-
filename=f"test_{slug}_{obligation.gate.value}",
266+
filename=f"test_{slug}_{gate.value}",
241267
)
242-
result[obligation.gate] = content
268+
result[gate] = content
243269

244270
return result
245271

tests/core/test_proof9_evidence_integrity_952.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,54 @@ def test_the_merge_gate_blocks_rather_than_raising(
610610
artifact.mkdir()
611611

612612
assert [x.id for x in list_blocking_requirements(workspace)] == [r.id]
613+
614+
615+
class TestEscapingIsPerContextNotStacked:
616+
"""Each template context needs its own escaping, applied once.
617+
618+
Backslash-doubling is correct for a Python docstring — the parser collapses
619+
it back on read. Feeding that already-doubled text to `json.dumps` for the
620+
E2E literal escapes it a second time, so a title of `\\d+` reaches the .ts
621+
source as four backslashes and reads back as `\\\\d+` (CI review).
622+
"""
623+
624+
RAW_TITLE = "Regex check: \\d+ matches"
625+
RAW_DESC = "Path C:\\temp\\out must exist"
626+
627+
def _stubs(self, gates):
628+
from codeframe.core.proof.stubs import generate_stubs
629+
630+
return generate_stubs(
631+
_requirement("REQ-952-12", self.RAW_TITLE, self.RAW_DESC, gates)
632+
)
633+
634+
def test_the_e2e_title_round_trips_to_exactly_the_original(self):
635+
import json
636+
637+
content = self._stubs([Gate.E2E])[Gate.E2E]
638+
line = next(ln for ln in content.splitlines() if ln.startswith("test("))
639+
literal = line[len("test("):line.rindex(", async")]
640+
641+
assert json.loads(literal) == self.RAW_TITLE, (
642+
"the E2E title was escaped twice"
643+
)
644+
645+
def test_the_python_docstring_reads_back_as_the_original(self):
646+
"""Doubling is right here — but only once."""
647+
import ast
648+
649+
content = self._stubs([Gate.UNIT])[Gate.UNIT]
650+
tree = ast.parse(content)
651+
func = next(n for n in tree.body if isinstance(n, ast.FunctionDef))
652+
653+
assert ast.get_docstring(func) == f"Proves: {self.RAW_DESC}"
654+
assert self.RAW_TITLE in ast.get_docstring(tree)
655+
656+
@pytest.mark.parametrize("gate", [Gate.DEMO, Gate.MANUAL])
657+
def test_markdown_stubs_show_the_original_text(self, gate):
658+
"""Markdown is not Python — a doubled backslash is just wrong there."""
659+
content = self._stubs([gate])[gate]
660+
661+
assert self.RAW_TITLE in content
662+
if gate is Gate.MANUAL:
663+
assert self.RAW_DESC in content

0 commit comments

Comments
 (0)