Repository navigation
yacc: %union type declarations have real record/struct morphology but class_start is wired None #2644
Description
Found while reviewing rosetta's unreviewed class_start n/a cells (keyword-rosetta docs/GATING.md "n/a semantics" review).
What happens
yacc.py:72 wires "class_start": None with docs/language_status/yacc.md (§1, §4) documenting this as deliberate: "a grammar file has no object/type concept." That's true for OOP-style classes, but it misses that Bison/Yacc's %union { ... } directive declares a real compound type — the C union spanning every grammar rule's semantic value ($$/$1/$2, already captured by args). This is the exact same shape the engine already wires class_start to elsewhere for non-OOP languages:
- Fortran's
TYPE ... END TYPEderived-type declarations (fortran.py:180-183, comment: "Fortran's struct/class equivalent") - COBOL's
PROGRAM-ID/CLASS-ID(cobol.py:231-234) - generic assembly's
struc/STRUCTmacros (assembly.py:105-108)
%union isn't embedded C incidentally picked up by the C-action rules either — it's core yacc/bison grammar syntax. yacc.py's own internal_discriminator (line 48) already lists union among the definitive %-directive markers used to identify a file as yacc in the first place, but no signal rule anywhere in the 47-key dict captures it as a structural entity.
Evidence
Both real .y files in the crucible corpus use %union in the standard form:
# language-crucible-local/data/yacc/freebsd/config.y:1-6
%union {
char *str;
int val;
struct file_list *file;
}
(jailparse.y:45 has the same shape.) This is standard, common bison idiom, not an obscure corner case — 2/2 real gathered files use it.
Fix shape
yacc.py: add aclass_startrule anchored on the%uniondirective, e.g.re.compile(r"^[ \t]*%union\b", re.M), following the same line-start-anchor convention already used for%token/%typein this file'sstructural_boundariesrule. (Optionally capture a name group for bison's less-common named-tag variant%union name { ... }for parity with howclass_startelsewhere captures an entity name — presence detection alone already closes the gap, since a grammar has at most one%unionblock.)- Re-run
crucible_check.py/golden masters — expected diff isclass_start0→1 on any real.y/.yy/.yppfile using%union(both freebsd corpus files). - Update
docs/language_status/yacc.md§1/§3/§4 to moveclass_startout of the "16 explicit None" count and document the%unionmapping alongsidefunc_start's existing "closest function-analog" framing. - Corpus follow-up (keyword-rosetta): once merged, the
yacc/class_startn/a cell becomes plantable — rosetta corpus PR adding a%unionprobe todata/yacc/*.yand updatingexpected_signals.json, same cross-repo choreography asjcl-2610/COND=.
Activity
squid-protocol commentedon Sep 2, 2026
Pulled from Batch B.3 wave 2 — this cannot ship as a registry-only change. Implemented, measured, and reverted; here is why.
Wiring
class_startswaps "no classes" for "phantom classes"detector.py~L1091 gates named-class extraction in two stages:yacc is not in
_CLASS_START_NAMED_EXTRACTION_LANGS(37 languages, yacc absent). So the momentclass_startstops beingNone, yacc does not start using its own%unionrule — it falls through to the generic fallback, which then matches every barestruct Fooin the embedded C action code that fills a real grammar file.Measured on the two real
.yfiles inlanguage-crucible/data/yacc/, toggling the rule in a scratch copy:config.yjailparse.yclass_countbefore (class_start: None)class_countafter (rule wired, allowlist unchanged)%unionrule's own raw signalThere are 57 bare
struct <name>occurrences across those two files (struct opt,struct device,struct cfparam, …), all ordinary C in action blocks. So the fix as filed would replace an honest zero with 26 phantom classes on real grammars, and dragclasses_foundand everything downstream with it. The%unionrule itself is correct — exactly 1 match per file, correctly anchored atconfig.y:1andjailparse.y:45.What it actually needs
Adding
yaccto_CLASS_START_NAMED_EXTRACTION_LANGS, which is adetector.pychange — the slicer layer. #2669's stacking rule keeps that out of a registry-regex PR, so this issue is now blocked on a companion slicer-layer change and belongs in its own PR with the two together.The rest of the design work is done and holds up, so whoever picks this up starts from:
re.compile(r"^[ \t]*%union\b(?:[ \t]+([a-zA-Z_]\w*))?", re.M)— optional group 1 for bison's rarer named-tag form (%union name {), and deliberately no group 2, since a union has no inheritance parent fordetector.py's group-2-is-parent convention to misread. The common anonymous form leaves group 1 unset and falls to_resolve_class_start_match's "Anonymous_Class" path, matching assembly's own no-nameclass_startprecedent.(,,,=,&,<,void,const,static, comment markers, or line-ending:, so it fires nothing butclass_start), the verbatimfreebsd/config.y:1-6shape:.yfiles use nostruct/class/enumat all, so thereclasses_foundwould correctly stay 0 even with the fallback active — the phantom-class problem is real-world only, which is exactly why the rosetta gate would not have caught it.docs/language_status/yacc.md§1 and §4 currently documentclass_start: Noneas deliberate and go stale when this lands.The
#2672doc-family half of yacc shipped separately and is corpus-inert (yacc's planted doc line is single-star/* @param … */, which/\*\*never matched, sodocstays 1).fix(core-engine): Rosetta batch 3 wave 2 — doc-comment pairing, rule …
squid-protocol commentedon Sep 5, 2026
Unblocked and fixed in #2737, with both halves in one PR as the comment above concluded they had to be.
Took the validated rule verbatim (
^[ \t]*%union\b(?:[ \t]+([a-zA-Z_]\w*))?, optional group 1 for the named-tag form, no group 2) and addedyaccto_CLASS_START_NAMED_EXTRACTION_LANGSin the same change.Re-measured the phantom-class counterfactual on current main before writing anything, and it is worse than the two-file figure in the comment above: 109 classes across all four real grammar files in the crucible corpus (config.y 17, jailparse.y 9, gnucobol parser.y 56, scanner.l 27) where the true total is 2. parser.y is the sharpest case — 56 phantom classes on a grammar that declares no union at all, since it uses
%define api.value.type. With the allowlist entry the four files read 1 / 1 / 0 / 0.One correction to the verification plan: ctags cannot corroborate this either.
ctags --list-kinds-full=YACCexposes exactly one kind,l(label) — which is whyCTAGS_CLASS_KINDS["yacc"]is empty on purpose and §9 covers functions only. So the direct source cross-check is the only external check available, the same standing abap/cobol/jcl/sqlite have on this allowlist, and that is how it was verified.Golden-master diff is exactly the two lines the issue predicted:
Class/Entity Declarations0 -> 1 on config.y and jailparse.y, both modes, nothing else moving. All CI green includingrosetta-audit— the rosetta yacc probes contain no%union, so no corpus cell moves and no re-bless is owed. Step 4 (the corpus plant that makes theyacc/class_startn/a cell plantable) stays open as the follow-up, described in the PR body.test: re-bless golden master after merging main (#2644)