Skip to content

fix: settle what a case boundary is, and how a branch is named - #189

Merged
OmarAlJarrah merged 6 commits into
mainfrom
fix/grammar-properties-and-branch-hints
Jul 30, 2026
Merged

fix: settle what a case boundary is, and how a branch is named#189
OmarAlJarrah merged 6 commits into
mainfrom
fix/grammar-properties-and-branch-hints

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

Two naming decisions the compiler was making inconsistently, and the mechanism
that found one of them.

A canonical name drifted on a second pass. CanonicalWords("Aℤ") was "aℤ",
and CanonicalWords("aℤ") was "a_ℤ". wordBoundary asked unicode.IsUpper
while irverify.isCased asks whether lowercasing changes the value, and the two
disagree about a rune that is uppercase with no lowercase form of its own —
double-struck , GREEK UPSILON WITH HOOK ϒ, the Roman numerals. The grammar
split on such a rune, lowercasing left it unchanged, so it still looked like a
boundary and the next pass split it again. The consequence is worse than drift:
segmentation came to depend on the casing of the source spelling rather than on
its words, so COUNTℤ was one word and countℤ two, for names an emitter is
meant to treat alike.

The two boundary rules turn out to ask different questions, and settling that is
the fix. A camel-case boundary is a case transition, so it asks whether
lowercasing changes the rune — the same test the verifier applies, so producer
and checker now agree by construction. An acronym tail is the last of a run of
capitals, which is about the letter's form: ℤServer is ℤ_server whether or
not lowercasing would touch , and DžBc is dž_bc because a titlecase letter
belongs to a run too and IsUpper reports false for it.

An inline union branch was named by whichever lowering arrived first. A
oneOf/anyOf branch whose pointer an outside $ref also names took variant_0
from the composition or 0 from the pointer walk, depending on declaration
order — two different documents from the same components, silently. The $ref
half of this already agreed, because a $ref branch has a target both paths read
a name from; an inline branch has only its position. The composition's spelling
wins, both paths now go through one derivation, and 0 is not a name an emitter
can build an identifier from.

The grammar had no validation beyond a table of examples. canonicalCases
pins the answers, which is the right shape for a specification decision, but a
table only covers the rows someone wrote — and the defect above sat in a spelling
no row contained. A fuzz target now asserts what must hold for any input: the
grammar is a fixed point, its output is the word sequence Naming.Canonical
promises, and the word runes of that output are the lowercased word runes of the
input in order.

Test plan

  • gofmt, go vet, golangci-lint, go build, and scripts/check-coverage.sh
    at exactly 100% of 3963 statements.
  • Nine planted mutations, nine caught: both halves of the case definition, the
    grammar dropping case, dropping single-rune words, or mishandling one script,
    the branch hint reverting on either path, and the pointer parse widening or
    narrowing. The sweep earned its place — it found the acronym-tail change
    unverified, which is what surfaced that the two rules ask different questions.
  • The properties are shown to catch what the table cannot: against the table as it
    stood before this branch, reinstating the case defect leaves it green and the
    target red. A grammar mishandling a single script does the same, and is a class
    a list of Latin examples cannot reach.
  • The corpus carries the branch-collision shape for the first time. The two-order
    oracle detects this class already and no committed spec put it in reach. The
    case declares the reference before the union deliberately, since interning
    through the reference is the path that was wrong — otherwise a single-order
    golden cannot see the fix at all, which it could not until the two were swapped.
  • No golden moved for the grammar change: no corpus name carries such a rune. The
    decision is pinned by nine conformance rows instead, and ir-design.md §3.2 now
    says which definition the contract means for each rule.

Notes

CLAUDE.md gains two rules, both paid for by the previous two branches: check the
tracker before settling a decision on a line a new guard forced you onto (#185
settled one that #184 had already framed, and needed #188 to correct), and treat a
fixture's own declaration order as part of the test.

Applying the first of those to this branch turned up two stale claims, corrected
here rather than left: #73's proposal for the naming grammar was ir and the docs
recorded the opposite, and #42's reproduction no longer holds. Both are commented
on their issues; #73 stays open for the half it still asks for.

Closes #181
Closes #186
Closes #187

CanonicalWords was not a fixed point. Feeding a canonical back through it could
change it: CanonicalWords("Aℤ") is "aℤ", and CanonicalWords("aℤ") is "a_ℤ".

wordBoundary asked unicode.IsUpper while irverify.isCased asks whether
lowercasing changes the value, and the two disagree about a rune that is
uppercase with no lowercase form of its own — double-struck ℤ, GREEK UPSILON
WITH HOOK ϒ, the Roman numerals. The grammar split on such a rune, lowercasing
left it unchanged, so it still looked like a boundary in the output and the
second pass split it again. The consequence is worse than the drift: segmentation
came to depend on the casing of the source spelling rather than on its words, so
COUNTℤ was one word and countℤ two, for names an emitter is meant to treat alike.

The grammar now asks the same question the verifier does — does lowercasing
change this rune — so producer and checker agree by construction. It also takes
in the titlecase letters, which are a boundary and which IsUpper reports false
for, so xDžy is two words rather than one.

No golden moved: no corpus name carries such a rune. The decision is pinned by
six conformance rows instead, and ir-design §3.2 now says which definition the
contract means, since it defined a word without saying what case means for a
letter that has only one.

Closes #187
canonicalCases pins the grammar's answers, and a table is the right shape for
that: which words userID splits into is a specification decision. But a table
only covers the rows someone thought to write, and the defect fixed in the commit
before this one sat in a spelling no row contained.

This adds the claims that hold for every input rather than a listed one: the
grammar is a fixed point, its output is the word sequence ir.Naming.Canonical
promises, and the word runes of that output are the lowercased word runes of the
input in order — so a word going missing is visible, which no shape check can
see.

It is committed after the fix rather than before so no commit in history is red,
but it was written first and reddened on three of its own seeds. Against the
table as it stood before this branch, reinstating the defect leaves the table
green and this target red, which is the whole claim. A grammar mishandling a
single script does the same, and is the class a list of Latin examples cannot
reach at all.

None of this says the grammar is right — a property computed through the grammar
moves with it. The table says what the answer should be, these say what any
answer must satisfy, and irverify says every Naming in a Document agrees with
whatever the grammar answered.

The seeds are adversarial rather than drawn from real specs because the gate runs
a fuzz target's seeds without searching. What ships as standing coverage is
exactly those spellings, and the doc comment says so rather than leaving a reader
to assume the target explores on every run.

Closes #186
An inline oneOf/anyOf branch whose pointer an outside $ref also names took its
hint from whichever lowering interned the node first. The composition derived
variant_0; hoistSubSchema, reached through the reference, derived the pointer's
last segment — the bare ordinal "0". Both are valid hints and nothing compared
them, so the same components in two declaration orders compiled to two different
documents, silently.

The $ref-branch half of this already agreed, because a $ref branch has a target
both paths read a name from. An inline branch has none, so the two genuinely held
different information: the composition knew the branch's ordinal within the
union, and the pointer walk knew only the segment, which is that ordinal with
nothing to say it is one.

The composition's spelling wins and the pointer walk now derives it. "0" is not a
name an emitter can build an identifier from, and it is the same information
either way — the ordinal — so the only question was which of them says what it
means. Both now go through one derivation, so they cannot drift apart again.

The corpus carries the shape for the first time. The two-order oracle detects
this class already and no committed spec put it in reach; the case declares the
reference before the union deliberately, since interning through the reference is
the path that was wrong, so a single-order golden can see it too. Reverting the
fix now fails the conformance case and the corpus sweep, where before it changed
output that every check accepted.

Closes #181
Both are the same kind of rule as the ones around them: learned by shipping the
opposite here, and cheap to follow once written down.

A line a new guard forces you onto may already carry a recorded decision. #185
tightened a check, which made one Naming literal a violation, and settled the
choice without knowing #184 had framed it, answered it, and asked for its own
change. It took #188 to correct.

And a fixture's declaration order is part of the test. A golden declaring things
in the order the correct lowering already produced cannot see an order-dependent
fix: reverting it leaves the golden green. The inline-branch case on this branch
did exactly that until its two components were swapped.
The commit that settled what "upper" means applied one test to both boundary
rules. They ask different questions, and a mutation sweep caught the second going
unverified — reverting it changed no test.

The camel-case rule asks whether a rune is a case *transition*, so it must ask
whether lowercasing changes it; that is what makes the grammar a fixed point.
The acronym-tail rule asks whether a rune belongs to a run of capitals, which is
about the letter's form. Answering it with the transition test lost ℤServer's
boundary — ℤ is a capital whether or not lowercasing would change it — and
answering it with the uppercase category alone lost the titlecase forms, which
IsUpper reports false for. It now takes both, so ℤServer is ℤ_server and DžBc is
dž_bc.

Three conformance rows pin it, and reverting either half of the definition now
reddens them. No golden moved: no corpus name carries such a rune.
Both documents recorded #73 as answered on the grounds that the grammars landed
in the framework "rather than in `ir` as its text proposed". Half of that is no
longer true: the naming grammar moved to `ir` in #185, and `irverify` validates
against it — which is #73's first acceptance bullet met as its own text proposed.

Its second bullet is untouched. #73 also asks for the `t/prim/<kind>` constructor
in `ir`, and that is still in `compilers/compile`, so the issue stays open rather
than being closed on a summary that no longer describes it.

The ID grammar staying in the framework is not a departure: a compiler's path is
its own and nothing in `ir` can compute one, which is why only the shape moved.
@OmarAlJarrah
OmarAlJarrah merged commit d22707f into main Jul 30, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/grammar-properties-and-branch-hints branch July 30, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant