Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 47 additions & 7 deletions docs/language_status/yacc.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ part of this doc with the most recent, most detailed investigation behind it.
| `_meta.blueprint_version` | v5.1 |
| `_meta.last_updated` | 2026-03-11 |
| `lexical_family` | `standard_block` |
| Structural signature keys wired | 31 / 47 (16 explicit `None`, incl. `class_start` — a grammar file has no object/type concept) |
| Structural signature keys wired | 33 / 48 (15 explicit `None`) — `class_start` joined the wired set in [#2644](https://github.com/squid-protocol/gitgalaxy/issues/2644) |
| Function-slicing integration mode | **Mode A (label-greedy)** since 2026-08-27 (was Mode B / brace-based — see §9) |
| Extraction-gauntlet + strict test files | `test_yacc.py`, `test_yacc_strict.py` (68 passing, 1 skipped) |
| Extraction-gauntlet + strict test files | `test_yacc.py`, `test_yacc_strict.py` (98 passing, 1 skipped) |

## 2. Identification surface

Expand All @@ -40,13 +40,23 @@ whitespace/comments before the `:`) — the closest function-analog the cross-la
for a grammar language, the same design decision behind `makefile` targets and `assembly` labels.
`args` counts `$1`/`$2`/`$$` positional value references inside a rule's action as a per-rule
argument-count proxy (the same spirit as the documented bash/Perl `$1`/`$2` precedent in
`docs/why_gitgalaxy_beats_ast_here.md`). `class_start` is `None`. The remaining 29 wired keys
(branch, io, safety, memory_alloc, macros, pointers, …) run against the embedded C/C++ action and
prologue/epilogue code.
`docs/why_gitgalaxy_beats_ast_here.md`).

`class_start` targets the **`%union` directive** (#2644) — the C union spanning every rule's
semantic value (`$$`/`$1`, which `args` already counts), and the one real compound type a grammar
declares. That is the same "non-OOP language's struct/class equivalent" mapping the engine already
makes for Fortran's `TYPE … END TYPE`, COBOL's `PROGRAM-ID` and assembly's `struc` macros, and it
is core grammar syntax rather than incidentally-embedded C: `internal_discriminator` already lists
`union` among the `%`-directives used to identify a file as yacc in the first place. Bison's rarer
named-tag form (`%union name {`) captures the tag; the common anonymous form resolves to
`Anonymous_Class`, the same path assembly's own no-name `class_start` takes.

The remaining wired keys (branch, io, safety, memory_alloc, macros, pointers, …) run against the
embedded C/C++ action and prologue/epilogue code.

## 4. What GitGalaxy explicitly does not track

`class_start` and 15 other keys are wired to `None`: `test`, `concurrency`, `ui_framework`,
15 keys are wired to `None`: `test`, `concurrency`, `ui_framework`,
`closures`, `decorators`, `comprehensions`, `scientific`, `ssr_boundaries`, `events`,
`dependency_injection`, `inline_asm`, `thread_sleeps`, `sync_locks`, `listeners`, `test_skip` —
none have a meaningful analog in a grammar-definition file.
Expand All @@ -67,7 +77,8 @@ none have a meaningful analog in a grammar-definition file.
## 6. Test depth

`tests/extraction/languages/test_yacc.py` (extraction gauntlet) and `test_yacc_strict.py` (ReDoS /
boundary correctness, scaling-ratio methodology). 68 passing, 1 skipped as of this snapshot.
boundary correctness, scaling-ratio methodology). 98 passing, 1 skipped as of this snapshot — the
`class_start` / `%union` cases landed with #2644.

## 7. Relevant closed work

Expand All @@ -76,6 +87,9 @@ boundary correctness, scaling-ratio methodology). 68 passing, 1 skipped as of th
- [#846](https://github.com/squid-protocol/gitgalaxy/issues/846) — extraction hardening for yacc.
- [#713](https://github.com/squid-protocol/gitgalaxy/issues/713) — `spec_exposure` unbounded-`[^\]]*`
ReDoS fix, applied across 28 languages including yacc.
- [#2644](https://github.com/squid-protocol/gitgalaxy/issues/2644) — `%union` wired as
`class_start`, together with yacc's entry in `detector.py`'s
`_CLASS_START_NAMED_EXTRACTION_LANGS` (see §8).
- [#1926](https://github.com/squid-protocol/gitgalaxy/issues/1926) — both real `.y` corpus files
were silently excluded from `file_data` by `statistical_auditor.py`; fixing it is what first
made yacc visible to the tri-comparison tool at all (the §9 ledger shape was `first_seen` the
Expand All @@ -87,6 +101,32 @@ The comparison corpus is small (`language-crucible/data/yacc/freebsd/` — FreeB
`jailparse.y`), plus `.y`/`.l` files that live inside other language corpora
(`cobol/gnucobol_internals/parser.y` + `scanner.l`, an 18k-line real Bison grammar).

**`class_start` / `%union` precision (#2644).** yacc is tree-sitter-blind, so the rule was verified
by direct source cross-check against those four real grammar files rather than by
`tree_sitter_accuracy_audit.py` — the same position abap, cobol, jcl and sqlite are in on
`_CLASS_START_NAMED_EXTRACTION_LANGS`. ctags cannot corroborate it 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's comparison covers functions only. So a
direct read of the four grammar files is the only external check there is, and it is the one that
was done:

| file | `%union` | `class_count` | note |
|---|---|---|---|
| `yacc/freebsd/config.y` | 1 (line 1) | 1 | anonymous union → `Anonymous_Class` |
| `yacc/freebsd/jailparse.y` | 1 (line 45) | 1 | same shape |
| `cobol/gnucobol_internals/parser.y` | 0 | 0 | uses `%define api.value.type`, declares no union |
| `cobol/gnucobol_internals/scanner.l` | 0 | 0 | a lex scanner: no semantic-value union |

100% precision, no false positives, and an honest zero where a grammar has no union.

**Why the `_CLASS_START_NAMED_EXTRACTION_LANGS` entry is not optional.** The named-class extractor
only consults a language's own `class_start` for allowlisted languages; everyone else falls through
to a legacy generic regex (`class|struct|interface|trait|enum`). A grammar's embedded C action code
is full of ordinary `struct` declarations, so leaving yacc off the allowlist while wiring the rule
reports **17** classes on `config.y`, **9** on `jailparse.y` and **109** across all four files —
where the honest answers are 1, 1 and 2. Wiring the rule alone would have been worse than the
`None` it replaced; the two changes only make sense together.

## 9. Tri-comparison: GitGalaxy vs. ctags (no privileged ground truth)

**Summary.** The one discrepancy shape the tri-comparison tool ever flagged for yacc
Expand Down
20 changes: 20 additions & 0 deletions gitgalaxy/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,26 @@ def get_mode(cls, lang_id: str) -> Optional[str]:
"swift",
"tcl",
"typescript",
# #2644: yacc gets its own `class_start` (`%union`, a grammar's one real
# compound-type declaration) in the SAME change that adds this entry, and
# the entry is the load-bearing half. A grammar file's embedded C action
# code is full of ordinary `struct foo` declarations, so the legacy
# generic fallback (`class|struct|interface|trait|enum`) reads them all as
# classes: 17 on config.y and 9 on jailparse.y where the honest answer is
# 1 each, and 109 across all four real grammar files in the crucible
# corpus (gnucobol's 18k-line parser.y alone contributes 56, with no
# `%union` and therefore no real class at all). Wiring the rule without
# this entry would make yacc WORSE than the `None` it replaced.
#
# Verified by direct source cross-check rather than
# tree_sitter_accuracy_audit.py: yacc is one of the tree-sitter-blind
# languages (no grammar available to this repo's tooling), the same
# position abap/cobol/jcl/sqlite are in above. Measured: `%union` fires
# exactly once in each grammar that has one (config.y:1, jailparse.y:45),
# zero times in the two that don't (gnucobol's parser.y/scanner.l use
# `%define api.value.type` instead) -- 100% precision, no false positives,
# documented in docs/language_status/yacc.md §8.
"yacc",
"zig",
}
)
Expand Down
24 changes: 23 additions & 1 deletion gitgalaxy/standards/language_standards/languages/yacc.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,29 @@
r"^[ \t]*(?!(?:case|default|public|private|protected)\b)([a-zA-Z_]\w*)(?=(?:[ \t\n]|/\*(?:[^*]|\*[^/])*\*/|//[^\n]*)*:)",
re.M,
),
"class_start": None,
# #2644: Bison/Yacc's `%union` directive declares a real compound type --
# the C union spanning every rule's semantic value (`$$`/`$1`, already
# counted by `args`). Same "non-OOP language's struct/class equivalent"
# mapping the engine already makes for Fortran's `TYPE ... END TYPE`,
# COBOL's `PROGRAM-ID` and assembly's `struc` macros -- and core grammar
# syntax, not embedded C: `internal_discriminator` above already lists
# `union` among the `%`-directives that IDENTIFY a file as yacc.
#
# Optional group 1 captures bison's rarer named-tag form (`%union name {`);
# the common anonymous form leaves it unset and resolves to
# "Anonymous_Class" through `_resolve_class_start_match`, the same path
# assembly's own no-name `class_start` takes. Deliberately no group 2 --
# a union has no inheritance parent for detector.py's group-2-is-parent
# convention to misread.
#
# NOTE: this rule is only reachable for named extraction because yacc is
# in detector.py's `_CLASS_START_NAMED_EXTRACTION_LANGS`. Removing it
# there does not restore the old behavior -- it drops yacc onto the
# legacy generic fallback (`class|struct|interface|trait|enum`), which
# reads every `struct foo` declaration in a grammar's embedded C actions
# as a class: 17 and 9 on the two real corpus grammars where the honest
# answer is 1 each. The two changes only make sense together.
"class_start": re.compile(r"^[ \t]*%union\b(?:[ \t]+([a-zA-Z_]\w*))?", re.M),
# --- PHASE 2: RISK & STRUCTURAL INTEGRITY ---
"safety": re.compile(r"\b(assert|YYABORT|YYACCEPT|YYERROR)\b"),
"safety_bypasses": re.compile(r"\bgoto\b|\bvoid\s*\*"),
Expand Down
76 changes: 76 additions & 0 deletions tests/core_engine/test_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -3909,3 +3909,79 @@ def test_detector_api_declared_orphans_ignores_hits_outside_the_declaration():
"only the non-static declaration is public surface the api rule already counted -- "
"the static one's body-local `int counter = 0;` api hit must not suppress it"
)


# ==============================================================================
# TEST: YACC NAMED-CLASS EXTRACTION USES %union, NOT THE GENERIC FALLBACK (#2644)
# ==============================================================================
def test_detector_yacc_class_extraction_ignores_embedded_c_structs():
"""
Regression for #2644. yacc's `class_start` was `None`, so a grammar's one
real compound type -- bison's `%union`, the C union spanning every rule's
semantic value -- was invisible. Wiring that rule is only half the change:
the named-class extractor consults a language's own `class_start` ONLY if
the language is in `_CLASS_START_NAMED_EXTRACTION_LANGS`, otherwise it falls
through to the legacy generic regex (`class|struct|interface|trait|enum`),
which reads every `struct foo` declaration in a grammar's embedded C action
code as a class -- 17 and 9 on the two real corpus grammars where the honest
answer is 1 each.

So this test pins the pair together: one `%union`, several ordinary C
`struct` declarations around it, exactly one extracted class.
"""
from gitgalaxy.standards.language_standards import LANGUAGE_DEFINITIONS

yacc_detector = StructuralExtractor("yacc", LANGUAGE_DEFINITIONS)
code = (
"%union {\n"
"\tchar\t*str;\n"
"\tstruct\tfile_list *file;\n"
"}\n"
"%%\n"
"file_spec:\n"
"\tNAME {\n"
"\t\tstruct file_list *fl;\n"
"\t\tstruct device dev;\n"
"\t\tnewfile($1);\n"
"\t}\n"
"\t;\n"
)

result = yacc_detector.splice(code, "")

names = [c.get("name") for c in result.get("classes", [])]
assert names == ["Anonymous_Class"], (
f"expected exactly the %union block as the file's one class, got {names} -- "
"yacc dropped off _CLASS_START_NAMED_EXTRACTION_LANGS and the generic "
"fallback is reading embedded C structs as classes again"
)
assert result["equations"].get("class_start") == 1, "the %union directive must count once as a class_start signal"


def test_detector_yacc_grammar_without_a_union_declares_no_class():
"""
#2644's other half: a grammar that uses `%define api.value.type` instead of
`%union` (gnucobol's 18k-line parser.y does) genuinely has no compound type
to declare. Its embedded C is still full of `struct` declarations -- 56 of
them would surface as classes on the generic fallback -- so an honest zero
here is what proves the language's own rule is the one being consulted.
"""
from gitgalaxy.standards.language_standards import LANGUAGE_DEFINITIONS

yacc_detector = StructuralExtractor("yacc", LANGUAGE_DEFINITIONS)
code = (
"%define api.value.type union\n"
"%%\n"
"statement:\n"
"\tWORD {\n"
"\t\tstruct cb_field *f;\n"
"\t\tstruct cb_tree_common *x;\n"
"\t\temit($1);\n"
"\t}\n"
"\t;\n"
)

result = yacc_detector.splice(code, "")

assert result.get("classes") == [], f"a grammar with no %union must declare no class, got {result.get('classes')}"
assert not result["equations"].get("class_start"), "no %union directive means no class_start signal"
53 changes: 52 additions & 1 deletion tests/extraction/languages/test_yacc.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,40 @@
"pathological": [],
}

CLASS_CASES = {"valid": [], "invalid": [], "pathological": []}
# #2644: `%union` is a grammar's one real compound-type declaration -- the C
# union spanning every rule's semantic value. The common anonymous form
# captures no name (expected_name None: the payload must match, and
# _resolve_class_start_match resolves it to "Anonymous_Class", the same path
# assembly's no-name class_start takes); bison's rarer named-tag form
# `%union name {` captures the tag in group 1.
CLASS_CASES = {
"valid": [
("%union {", None),
("%union\t{", None),
(" %union {", None),
("%union TargetUnion {", "TargetUnion"),
("%union _target_union", "_target_union"),
],
"invalid": [
# Other `%` directives, including the ones `structural_boundaries` owns.
"%token TargetUnion",
"%type <val> expr",
# \b guards the directive name: `%unionize` is not `%union`.
"%unionize {",
# A bare C `union` in embedded action code is not the directive -- this
# is the whole reason yacc needs its own rule instead of the generic
# `class|struct|interface|trait|enum` fallback.
"union TargetUnion {",
"\tstruct file_list *file;",
# Only whitespace may precede the directive: mid-line and commented-out
# occurrences are not declarations.
"yyval = 0; %union {",
"/* %union { */",
],
"pathological": [
("\t \t%union \t TargetUnion {", "TargetUnion"),
],
}

DEPENDENCY_CASES = {"valid": [], "invalid": [], "pathological": []}

Expand Down Expand Up @@ -89,3 +122,21 @@ def test_invalid_args_extraction(case):
def test_pathological_args_extraction(case):
pattern = LANGUAGE_DEFINITIONS["yacc"]["rules"]["args"]
assert_pathological_match(pattern, case[0], case[1], "yacc.args")


@pytest.mark.parametrize("payload,expected_name", CLASS_CASES["valid"])
def test_valid_class_extraction(payload, expected_name):
pattern = LANGUAGE_DEFINITIONS["yacc"]["rules"]["class_start"]
assert_valid_match(pattern, payload, expected_name, "yacc.class_start")


@pytest.mark.parametrize("payload", CLASS_CASES["invalid"])
def test_invalid_class_extraction(payload):
pattern = LANGUAGE_DEFINITIONS["yacc"]["rules"]["class_start"]
assert_invalid_no_match(pattern, payload, "yacc.class_start")


@pytest.mark.parametrize("payload,expected_name", CLASS_CASES["pathological"])
def test_pathological_class_extraction(payload, expected_name):
pattern = LANGUAGE_DEFINITIONS["yacc"]["rules"]["class_start"]
assert_pathological_match(pattern, payload, expected_name, "yacc.class_start")
4 changes: 4 additions & 0 deletions tests/extraction/languages/test_yacc_strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,12 @@ def test_yacc_redos_immunity():
assert_redos_immune(YACC_RULES["dead_code"], "//" + " " * 100000, timeout_sec=2.0)
assert_redos_immune(YACC_RULES["structural_boundaries"], "%token " * 20000, timeout_sec=2.0)
assert_redos_immune(YACC_RULES["api"], "%define " * 20000, timeout_sec=2.0)
# #2644: the `[ \t]+` before the optional named tag is the only quantifier
# in class_start -- fired against a directive whose tag never arrives.
assert_redos_immune(YACC_RULES["class_start"], "%union" + " \t" * 50000, timeout_sec=2.0)

# Realistic-but-large inputs must still match after any bounding.
assert YACC_RULES["class_start"].search("%union {")
assert YACC_RULES["ownership"].search("// Author: Jane Doe")
assert YACC_RULES["import"].search('#include "parser.h"')
assert YACC_RULES["spec_exposure"].search("[SPEC-123] audit trail")
Expand Down
Loading
Loading