Skip to content

fix(renderer): §82's escape hatch has to survive canonical rendering - #77

Merged
rmichaelthomas merged 1 commit into
mainfrom
fix/canonical-quoting-predicate-collision
Sep 12, 2026
Merged

rmichaelthomas merged 1 commit into
mainfrom
fix/canonical-quoting-predicate-collision

Conversation

@rmichaelthomas

Copy link
Copy Markdown
Owner

The defect

source:     require total is "large"        (with `define large: is above 50` above it)
canonical:  require total is large
re-parses:  RequireNode → PredicateApplicationNode   (was: RequireNode → ConditionNode → QuotedString)

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:3498 tests for an UNKNOWN token, so a QUOTED_STRING never 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 predates define and 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 QuotedString has 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 — so parse(render(ast)) == ast still means what it meant.

Scope

Narrow, and asserted as narrow: require status is "active" still renders require status is active when nothing named active is 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 nodes field 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:

remember a number called total with 75
remember a string called label with "total"     → canonical: ... with total
show label      original: "total"     canonical: 75

BareWord does symbol-table fallback and QuotedString does 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

`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
rmichaelthomas merged commit c0c50da into main Sep 12, 2026
3 checks passed
@rmichaelthomas
rmichaelthomas deleted the fix/canonical-quoting-predicate-collision branch September 12, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant