fix(renderer): §82's escape hatch has to survive canonical rendering - #77
Merged
Merged
Conversation
`require total is "large"`, where `large` is a defined predicate, rendered canonically as `require total is large` — which re-parses as a predicate application, not a string equality. Same text, different program. The two halves were each right on their own. v31 §82 makes quoting the escape hatch for a value that collides with a predicate name, and the parser implements it exactly: the `is <predicate>` branch tests for an UNKNOWN token, so a QUOTED_STRING never reaches it. v2c §90's conditional quoting then drops quotes around any "safe single word", where safe means *not reserved* — and a predicate name is never reserved. It is declared. Neither checkpoint could see the other, and the gap between them is a rendering that means something else. The fix follows the arrangement v2d §96 already reached for composition-call arguments, which are name-resolvable in the same way and for which a QuotedString has always kept its quotes. The parser is the only stage that holds the predicate table, so it marks the collision on the literal and the renderer honours the mark. The flag is `compare=False`, matching this file's idiom for inert metadata: two `"large"` literals are the same value whether or not one of them must be written with quotes to stay one, so `parse(render(ast)) == ast` still means what it meant. Scoped to the collision. A quoted single word that shadows nothing still normalises to bare — asserted, so a later widening has to be deliberate. Corpus case added, because this is precisely the class of defect the behavioural corpus exists to catch: `require total is large` and `require total is "large"` are one character apart and produce different ASTs, which no word-list gate and no text comparison can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6qcDj1e1dEYvc8jLnpGZy
rmichaelthomas
deleted the
fix/canonical-quoting-predicate-collision
branch
September 12, 2026 08:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Same text out, different program back. Reported during the CommonGage port and left unfixed at the time because changing canonical output looked like a migration.
Why both halves were right
v31 §82 makes quoting the escape hatch for a value that collides with a predicate name, and the parser implements it exactly —
parser.py:3498tests for anUNKNOWNtoken, so aQUOTED_STRINGnever reaches the predicate branch. There is already a test for this (test_quoted_string_forces_equality_even_if_predicate_name_matches).v2c §90 drops the quotes around a "safe single word", where safe means not reserved. A predicate name is never reserved — it is declared.
_emit_string's safety test predatesdefineand cannot see it.Neither checkpoint saw the other, and what fell through the gap is a rendering that means something else.
The fix
It follows the arrangement v2d §96 already reached for composition-call arguments — name-resolvable in exactly the same way, and for which a
QuotedStringhas always kept its quotes (renderer.py:264). The parser is the only stage holding the predicate table, so it marks the collision on the literal and the renderer honours the mark.The flag is
compare=False, matching this file's existing idiom for inert metadata (starting_date/until_date, two declarations up). Two"large"literals are the same value whether or not one must be written with quotes to stay one — soparse(render(ast)) == aststill means what it meant.Scope
Narrow, and asserted as narrow:
require status is "active"still rendersrequire status is activewhen nothing namedactiveis declared. A later widening has to be deliberate rather than accidental.Corpus
Case added. This is the exact class the behavioural corpus exists for — the two programs are one character apart and no word-list gate or text comparison can distinguish them. The
nodesfield does.Verification
1899 passed, 0 failed. The new test was written first and watched fail with
assert 'require total is large' == 'require total is "large"'.grammar/regenerated (source-line drift only — no rule or error-text changes).One thing this does not fix, deliberately
The same class exists in a second position, and there it changes a value, not a node kind:
BareWorddoes symbol-table fallback andQuotedStringdoes not, so normalising the quotes away converts one node into the other. Unlike the predicate case, this is not a gap between two rules — §90 says single-word quotes drop by design, and three tests assert it by name (test_render_round_trip_single_word_quote_drops). Closing it means reversing a locked decision, which is a call for @rmichaelthomas, not a fix to smuggle into this PR. Measured cost if we do: 6 failures out of 1899, three of them genuine spec assertions and three regeneration artifacts.🤖 Generated with Claude Code
https://claude.ai/code/session_01E6qcDj1e1dEYvc8jLnpGZy