Repository navigation
fix(rust): extract type aliases and union items as type nodes (#4285) - #4581
madhusudanpatnaik wants to merge 2 commits into
Conversation
…aphify-Labs#4285) extract_rust emitted nodes for struct/enum/trait only, so a field, parameter or return typed with an alias or union declared in the same file resolved to a sourceless stub (a file holding only aliases even reported "yielded no symbols"). Both are now type nodes in their declaring file, contained by it and counted in the per-file collision census and the cross-file declaration tie-break. A union's fields reference their types as a struct's do; an alias is a bare node. An associated type in an impl body (`type Output = Meters;`) is the same tree-sitter node but not a file-level item, so it still emits nothing. Two Graphify-Labs#3782 tests asserted that an alias or union is not a node; they now expect references to land on the declaration, while the function-local, undeclared and impl-only cases keep their stubs. Bumps the AST cache schema: cached Rust results lack these nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Not checked: tests were not run; formal verification proved 0 of 1 changed function(s) (1 sampled, not proven).
Formal verification. PR-changed functions: 1/1 verified (0 proven, 1 may-equivalent, 0 distinguished) · 0 not verified.
Graphify review — findings
Adds Rust type aliases and union items as type nodes, so references to them resolve to the local declaration rather than a sourceless stub, with union fields now emitting field refs like struct. Skips associated type items inside impl bodies during walk to avoid minting a colliding Output node per impl Add, but the forward-reference prescan in _scan_type_items still treats those items as declared file-level types. Replaces the per-declaration linear scan of nodes with a node_by_id lookup to remove a quadratic cost on large files, and bumps _AST_CACHE_SCHEMA to 18 so cached extractions are rebuilt.
Worth a look
- Associated
typeitems in impl bodies are pre-scanned as declared file-level types but never emitted as nodes —graphify/extractors/rust.py:345· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2772 functions depend on the 297 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 976 callers, 59 callees - worse:
extract_rust()— 37 callers, 9 callees
Verification — 2772 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1512 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
43 of 433 test file(s) selected (10%) via static blast radius.
tests/test_cache.py— impacttests/test_cache_stale_import_target.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_csharp_partial_classes.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_grammar_cache_key.py— impacttests/test_ignore_file_encoding.py— impacttests/test_incremental_mtime_collision.py— impacttests/test_java_member_calls.py— impacttests/test_js_member_collisions.py— impacttests/test_language_override_lifecycle.py— impacttests/test_language_overrides.py— impacttests/test_llm_backends.py— impacttests/test_manifest_ingest.py— impacttests/test_multilang.py— impact, changed-testtests/test_objc_field_table_remap.py— impacttests/test_out_dir_evidence.py— impacttests/test_partial_cache.py— impacttests/test_pipeline.py— impacttests/test_python_inherited_method_calls.py— impacttests/test_python_property_reads.py— impacttests/test_python_pytest_fixture_receivers.py— impacttests/test_rust_in_file_call_binding.py— impacttests/test_rust_macro_rules.py— impacttests/test_rust_self_member_calls.py— impacttests/test_rust_type_alias_union.py— impact, changed-testtests/test_rust_use_trees.py— impacttests/test_semantic_cache_basename_recovery.py— impacttests/test_semantic_cache_out_root.py— impacttests/test_skill_persisted_scan_options.py— impacttests/test_skill_semantic_all_cached.py— impacttests/test_stale_prune.py— impacttests/test_stat_index_husk.py— impacttests/test_stat_index_multi_root.py— impacttests/test_stat_index_portability.py— impacttests/test_watch_manifest_location.py— impacttests/test_word_count_cache.py— impacttests/test_zero_node_no_cache.py— impact
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
.github/PULL_REQUEST_TEMPLATE.md§ Graphify-specific checklist (lines 33-39): references changed symbolsrunBENCHMARKS.md§ Reproducing (lines 190-203): references changed symbolsrunCHANGELOG.md§ 0.9.90 (2026-10-13) (lines 44-62): references changed symbolsrunCHANGELOG.md§ 0.9.80 (2026-10-07) (lines 224-243): references changed symbolsrunCHANGELOG.md§ 0.9.68 (2026-09-25) (lines 404-416): references changed symbolsrunCHANGELOG.md§ 0.9.59 (2026-09-12) (lines 508-515): references changed symbolsrunCHANGELOG.md§ 0.9.53 (2026-08-30) (lines 584-595): references changed symbolsrunCHANGELOG.md§ 0.9.47 (2026-08-19) (lines 672-688): references changed symbolsrunCHANGELOG.md§ 0.9.45 (2026-08-16) (lines 706-712): references changed symbolsrunCHANGELOG.md§ 0.9.44 (2026-08-15) (lines 713-725): references changed symbolsbody
…and 10 more.
Formal verification
No difference found (not proven): No behavior difference found in extract\_rust (not a proof).
The verifier ran both versions of extract\_rust on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 2 more finding(s) on lines outside this diff (see the check run).
Resolves the one-line _AST_CACHE_SCHEMA conflict: upstream is now at schema 20, so this branch's extraction change moves to 21, with upstream's history kept as "prior (20)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65d8492 to
0349ab1
Compare

What does this PR do?
Part of #4285 (item 5, "Missing node kinds":
type,union).extract_rustemitted nodes forstruct/enum/traitonly. A field, parameter or return typed with atypealias or auniondeclared in the same file therefore resolved throughensure_named_nodeto a sourceless stub, which the corpus rewire can grow into a cross-file hub. A file holding only aliases was even reported as "yielded no symbols". The commonpub type Result<T> = std::result::Result<T, Error>;in anerror.rswas hit worst:-> Result<u8>in that file pointed at a global sourcelessResult.Changes (
graphify/extractors/rust.py):union_itemandtype_itemgo through the same branch as struct/enum/trait. Each becomes a type node in its declaring file, with acontainsedge from the file and the_rust_declaration_countthat the cross-file tie-break reads._scan_type_items) as types, so same-id collisions follow the existing Python: methods differing only by a leading underscore collide on node id — the public one is silently dropped from the graph #3302/Rust: a type used above its declaration stays a sourceless stub when the name is defined in 2+ files #3782 salting rule.implbody (type Output = Meters;) is also atype_item, but it's not a file-level item. It's skipped structurally (grandparent isimpl_item); otherwise everyimpl Addwould create a collidingOutputnode.node_by_idindex, filled at the only two places nodes are appended (both guarded byseen_ids, so ids are unique). The oldnext(n for n in nodes if n["id"] == ...)was a linear scan per declaration, already on v8, so quadratic in file size. With aliases and unions going through it, a synthetic 3,000-alias + 3,000-union file went from 0.57 s to 3.2 s before the index. Theimpllookup uses the same index.graphify/cache.py: AST cache schema 20 → 21 (the branch includes v8 at 0.9.93). Cached Rust results lack the new nodes.Type of change
Verification & Invariants
Invariant: a source-backed declaration wins over a sourceless stub (provenance). A reference is never dropped, and resolves only to something that becomes a node.
Two existing Rust: a type used above its declaration stays a sourceless stub when the name is defined in 2+ files #3782 tests asserted that an alias or union is not a node. They now expect the reference to land on the declaration. The function-local struct, undeclared name and impl-only generic cases still assert a stub.
Persisted state invalidated: AST cache schema bump, so the first build after upgrading re-extracts code files once. An incremental
extractwithout--forcecarries unchanged files forward from the manifest, as with every earlier bump.Id change in one case: a file with both
type HANDLEandstruct Handle(same folded id) now salts both apart under the existing collision rule, so the struct's id changes in that file.Conflict note: my other open PRs also bump the schema 20 → 21. Whichever merges later needs a one-line rebase.
Read the CONTRIBUTING.md guide.
Reproduced the issue and identified the invariant.
Made the smallest fix necessary.
Added a regression test (if bug fix) or isolated boundary test.
Kept the PR description synchronized with the final implementation.
Documented any limitations / unsupported cases explicitly.
Limitations: an alias's right-hand side is not walked. An alias or union inside a function body is not emitted, the same as structs there today.
How was this tested?
tests/test_rust_type_alias_union.py(6 tests). 4 fail on v8 and 2 are guards. Reverting onlyrust.pyandcache.pymakes those 4 fail, plus the 2 updated Rust: a type used above its declaration stays a sourceless stub when the name is defined in 2+ files #3782 tests.type Result<T>gets a node andparse()references it;impl Iterator { type Item = T; }creates noItem; an alias inside an inlinemodbecomes a node; output is identical under CRLF, a UTF-8 BOM and tab indentation.BagOfWords,PatternSetPoolFnandFnVisitornow resolve. The remainder are associated types and generics, correctly left out.extract()2.75 s → 2.78 s, peak memory 29.7 → 29.8 MiB. At 1×/2×/4× synthetic input both v8 and this branch grow ~2.1× per doubling (linear). The constant factor is the nodes v8 dropped.Full-suite failures, identical on v8 in my environment: 7
tests/test_security.pytests that need DNS (no network in my sandbox). When four suites ran in parallel I also saw timing flakes in the watch tests (test_language_override_lifecycle.py,test_pr10_watch_overrides.py). Those tests touch no Rust code, and they passed 10/10 unloaded on both v8 and this branch.Graphify-specific checklist
uv run python -m tools.skillgen --bless) when changing their source fragments. (Ran--bless: all 134 artifacts regenerated with no changes, and--checkpasses. No source fragments are touched.)