fix(rust): extract `type` aliases and `union` items as type nodes (#4285) by madhusudanpatnaik · Pull Request #4581 · Graphify-Labs/graphify · GitHub
Skip to content

fix(rust): extract type aliases and union items as type nodes (#4285) - #4581

Open
madhusudanpatnaik wants to merge 2 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/rust-type-alias-union
Open

madhusudanpatnaik wants to merge 2 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/rust-type-alias-union

Conversation

@madhusudanpatnaik

@madhusudanpatnaik madhusudanpatnaik commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Part of #4285 (item 5, "Missing node kinds": type, union).

extract_rust emitted nodes for struct/enum/trait only. A field, parameter or return typed with a type alias or a union declared in the same file therefore resolved through ensure_named_node to 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 common pub type Result<T> = std::result::Result<T, Error>; in an error.rs was hit worst: -> Result<u8> in that file pointed at a global sourceless Result.

Changes (graphify/extractors/rust.py):

  • union_item and type_item go through the same branch as struct/enum/trait. Each becomes a type node in its declaring file, with a contains edge from the file and the _rust_declaration_count that the cross-file tie-break reads.
  • A union's named fields reference their types, as a struct's do. An alias is a bare node; its right-hand side is not walked, to keep the change narrow.
  • Both kinds are added to the per-file collision census (_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.
  • An associated type in an impl body (type Output = Meters;) is also a type_item, but it's not a file-level item. It's skipped structurally (grandparent is impl_item); otherwise every impl Add would create a colliding Output node.
  • Declaration nodes are now found through a node_by_id index, filled at the only two places nodes are appended (both guarded by seen_ids, so ids are unique). The old next(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. The impl lookup 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

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

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 extract without --force carries unchanged files forward from the manifest, as with every earlier bump.

  • Id change in one case: a file with both type HANDLE and struct 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?

  • New tests/test_rust_type_alias_union.py (6 tests). 4 fail on v8 and 2 are guards. Reverting only rust.py and cache.py makes 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.
  • Edge cases on the committed code: type Result<T> gets a node and parse() references it; impl Iterator { type Item = T; } creates no Item; an alias inside an inline mod becomes a node; output is identical under CRLF, a UTF-8 BOM and tab indentation.
  • ripgrep (110 files): sourceless nodes named after a local alias/union went from 11 (14 edges) to 8 (8 edges). BagOfWords, PatternSetPoolFn and FnVisitor now resolve. The remainder are associated types and generics, correctly left out.
  • Performance: ripgrep full 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.
uv run pytest tests/test_rust_type_alias_union.py tests/test_multilang.py tests/test_rust*.py tests/test_languages.py -q
# tests/test_rust_type_alias_union.py on v8 (0.9.93): 4 of 6 fail; on this branch: 6 passed
# Full suite: per Python, `uv sync --all-extras --frozen`, then `uv run pytest tests/ -q --tb=short`.
#   this branch on v8 0.9.93 — 3.13: 8468 passed
#   with my other open branches merged on top — 3.10: 8536 | 3.12: 8535 | 3.13: 8535 | 3.14: 8535 passed
#   only the 7 DNS-dependent test_security tests fail (no network here; they fail the same way on v8)
uv run python -m tools.skillgen --check / --audit-coverage / --schema-singleton / --monolith-roundtrip / --always-on-roundtrip
uv run graphify --help && uv run graphify install   # in a throwaway HOME
bun test tests/pi.test.ts      # 20/20
uv run python -m tools.skillgen --bless   # 134 artifacts, no changes
uv run python -m tools.skillgen --check   # OK
uv run ruff check .
uv run pyright                 # 643 errors on v8 (0.9.93) and on this branch; none new
uv run bandit -r graphify -ll  # 12 findings, identical to v8

Full-suite failures, identical on v8 in my environment: 7 tests/test_security.py tests 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

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (Ran --bless: all 134 artifacts regenerated with no changes, and --check passes. No source fragments are touched.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…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>
@github-actions

Copy link
Copy Markdown

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 type items 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 — impact
  • tests/test_cache_stale_import_target.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_grammar_cache_key.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_java_member_calls.py — impact
  • tests/test_js_member_collisions.py — impact
  • tests/test_language_override_lifecycle.py — impact
  • tests/test_language_overrides.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_multilang.py — impact, changed-test
  • tests/test_objc_field_table_remap.py — impact
  • tests/test_out_dir_evidence.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_pipeline.py — impact
  • tests/test_python_inherited_method_calls.py — impact
  • tests/test_python_property_reads.py — impact
  • tests/test_python_pytest_fixture_receivers.py — impact
  • tests/test_rust_in_file_call_binding.py — impact
  • tests/test_rust_macro_rules.py — impact
  • tests/test_rust_self_member_calls.py — impact
  • tests/test_rust_type_alias_union.py — impact, changed-test
  • tests/test_rust_use_trees.py — impact
  • tests/test_semantic_cache_basename_recovery.py — impact
  • tests/test_semantic_cache_out_root.py — impact
  • tests/test_skill_persisted_scan_options.py — impact
  • tests/test_skill_semantic_all_cached.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_stat_index_husk.py — impact
  • tests/test_stat_index_multi_root.py — impact
  • tests/test_stat_index_portability.py — impact
  • tests/test_watch_manifest_location.py — impact
  • tests/test_word_count_cache.py — impact
  • tests/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)

…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>
@madhusudanpatnaik

Copy link
Copy Markdown
Contributor Author

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant