Skip to content

yacc: %union type declarations have real record/struct morphology but class_start is wired None #2644

Closed

Description

@squid-protocol

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 TYPE derived-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/STRUCT macros (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

  1. yacc.py: add a class_start rule anchored on the %union directive, e.g. re.compile(r"^[ \t]*%union\b", re.M), following the same line-start-anchor convention already used for %token/%type in this file's structural_boundaries rule. (Optionally capture a name group for bison's less-common named-tag variant %union name { ... } for parity with how class_start elsewhere captures an entity name — presence detection alone already closes the gap, since a grammar has at most one %union block.)
  2. Re-run crucible_check.py/golden masters — expected diff is class_start 0→1 on any real .y/.yy/.ypp file using %union (both freebsd corpus files).
  3. Update docs/language_status/yacc.md §1/§3/§4 to move class_start out of the "16 explicit None" count and document the %union mapping alongside func_start's existing "closest function-analog" framing.
  4. Corpus follow-up (keyword-rosetta): once merged, the yacc/class_start n/a cell becomes plantable — rosetta corpus PR adding a %union probe to data/yacc/*.y and updating expected_signals.json, same cross-repo choreography as jcl-2610/COND=.

Found by the #1096/#2560 rosetta n/a review sweep.

Activity

  1. added
    enhancementNew feature, sensor, or structural signature
    core-engineModifications to the central physics and parsing engine
    on Sep 2, 2026
  2. squid-protocol commented on Sep 2, 2026

    @squid-protocol
    OwnerAuthor

    Pulled from Batch B.3 wave 2 — this cannot ship as a registry-only change. Implemented, measured, and reverted; here is why.

    Wiring class_start swaps "no classes" for "phantom classes"

    detector.py ~L1091 gates named-class extraction in two stages:

    if "class_start" in rules and rules["class_start"] is None:
        class_matches = []                     # hard zero
    else:
        class_start_pattern = (
            rules.get("class_start") if self.primary_lang_id in _CLASS_START_NAMED_EXTRACTION_LANGS else None
        )
        if class_start_pattern is not None:
            ...                                # the language's own rule
        # else -> the LEGACY GENERIC FALLBACK: class|struct|interface|trait|enum

    yacc is not in _CLASS_START_NAMED_EXTRACTION_LANGS (37 languages, yacc absent). So the moment class_start stops being None, yacc does not start using its own %union rule — it falls through to the generic fallback, which then matches every bare struct Foo in the embedded C action code that fills a real grammar file.

    Measured on the two real .y files in language-crucible/data/yacc/, toggling the rule in a scratch copy:

    config.y jailparse.y
    class_count before (class_start: None) 0 0
    class_count after (rule wired, allowlist unchanged) 17 9
    the new %union rule's own raw signal 1 1

    There 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 drag classes_found and everything downstream with it. The %union rule itself is correct — exactly 1 match per file, correctly anchored at config.y:1 and jailparse.y:45.

    What it actually needs

    Adding yacc to _CLASS_START_NAMED_EXTRACTION_LANGS, which is a detector.py change — 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:

    • Rule (validated, 1/1 on both real files): 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 for detector.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-name class_start precedent.
    • Corpus plant (screened against yacc's full rules dict — contains no (, ,, =, &, <, void, const, static, comment markers, or line-ending :, so it fires nothing but class_start), the verbatim freebsd/config.y:1-6 shape:
      %union {
      	char	*str;
      	int	val;
      	struct	file_list *file;
      }
      
      Note the rosetta corpus's four .y files use no struct/class/enum at all, so there classes_found would 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: docs/language_status/yacc.md §1 and §4 currently document class_start: None as deliberate and go stale when this lands.

    The #2672 doc-family half of yacc shipped separately and is corpus-inert (yacc's planted doc line is single-star /* @param … */, which /\*\* never matched, so doc stays 1).

  3. squid-protocol commented on Sep 5, 2026

    @squid-protocol
    OwnerAuthor

    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 added yacc to _CLASS_START_NAMED_EXTRACTION_LANGS in 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=YACC exposes exactly one kind, l (label) — which is why CTAGS_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 Declarations 0 -> 1 on config.y and jailparse.y, both modes, nothing else moving. All CI green including rosetta-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 the yacc/class_start n/a cell plantable) stays open as the follow-up, described in the PR body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    core-engineModifications to the central physics and parsing engineenhancementNew feature, sensor, or structural signaturestatus: blockedWaiting on upstream dependencies or user clarification

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions