Repository navigation
fix(cpp): extract namespace-scope using and typedef aliases as type nodes - #4580
madhusudanpatnaik wants to merge 5 commits into
Conversation
…pe nodes
The C++ extractor emitted no node for an alias declaration or a typedef,
so a field, parameter or return typed with one, even in the declaring
header, resolved to a sourceless stub.
A namespace- or file-scope alias is now a type node contained by its
file: every declarator of a typedef (`typedef int H, *HP;`,
`typedef int (*Fn)(int);`) and alias templates count. Member aliases
(`using value_type = T;` sits in every container, and member ids are not
class-qualified), the struct a C-style `typedef struct Foo {...} Foo;`
names, and same-name re-exports (`using Bar = ns::Bar;`) are not. The
walk continues, so a typedef'd struct still gets its own node.
Two anonymous-specifier tests asserted the typedef name is no node; they
now expect an alias node owning none of the members, and still forbid a
nameless node or the anonymous specifier's members.
Bumps the AST cache schema: cached C++ results lack these nodes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`name not in names` on a list made one typedef with N declarators O(N^2): a 40,000-name typedef took 6.2 s (0.24 s on v8). A set keeps it linear (1.1 s, the rest is emitting the 40,000 nodes v8 dropped). 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 not verified).
Formal verification. PR-changed functions: 0/1 verified (0 proven, 0 may-equivalent, 0 distinguished) · 1 not verified (1 vacuous).
Not verified on this run: \_cpp\_extra\_walk (vacuous: never exercised).
Graphify review — findings
Adds C++ type nodes for namespace- and file-scope using X = ...; and typedef declarations, so a field, parameter or return typed with an alias lands on a sourced declaration instead of a sourceless stub that can grow into a hub. _cpp_alias_names skips member aliases, the struct a C-style typedef struct Foo {...} Foo; already names, and same-name re-exports, and it only recognises aliases whose parent is a translation_unit or declaration_list, which is the scope check that guard-wrapped declarations can miss. Bumps _AST_CACHE_SCHEMA to 18 so cached C++ extractions are rebuilt, and updates the enum and union tests so typedef names like Flag and Anon now appear as member-less alias nodes.
Worth a look
- Include-guarded or #if-wrapped typedefs/aliases are silently not extracted —
graphify/extractors/engine.py:4790· 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 — 3534 functions depend on the 495 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
extract()— 976 callers, 59 callees
Verification — 3534 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: 2165 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
72 of 433 test file(s) selected (17%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_build.py— impacttests/test_cache.py— impacttests/test_cache_stale_import_target.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_cjs_module_extension.py— impacttests/test_cpp_nested_and_cli.py— impact, changed-testtests/test_cpp_type_alias.py— impact, changed-testtests/test_csharp_partial_classes.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_dotnet.py— impacttests/test_ejs_extractor.py— impacttests/test_ext_tagged_id_clash.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_extract_php_closures.py— impacttests/test_grammar_cache_key.py— impacttests/test_ignore_file_encoding.py— impacttests/test_import_extension_resolution.py— impacttests/test_incremental_mtime_collision.py— impacttests/test_indirect_call_block_scoped_shadow.py— impacttests/test_indirect_dispatch.py— impacttests/test_indirect_dispatch_assign_return.py— impacttests/test_indirect_dispatch_getattr.py— impacttests/test_java_member_calls.py— impacttests/test_js_exported_scalar_bindings.py— impacttests/test_js_member_collisions.py— impacttests/test_language_override_lifecycle.py— impacttests/test_language_overrides.py— impacttests/test_languages.py— impacttests/test_llm_backends.py— impacttests/test_manifest_ingest.py— impacttests/test_multilang.py— impacttests/test_objc_field_table_remap.py— impacttests/test_out_dir_evidence.py— impacttests/test_partial_cache.py— impacttests/test_php_unparsed_casts.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_python_underscore_resolution.py— impacttests/test_rationale.py— impacttests/test_ruby_resolution.py— impacttests/test_rust_self_member_calls.py— impacttests/test_rust_use_trees.py— impacttests/test_scala_context_bounds.py— impact- … and 22 more
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.
Formal verification
Could not verify: Could not verify \_cpp\_extra\_walk.
The verifier did not have enough to check \_cpp\_extra\_walk, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 264 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
· 1 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>
tree-sitter wraps a declaration under `#ifndef`/`#if`/`#ifdef`/`#else` in a preproc_* node, and the scope check stopped there, so a file-scope `using X = ...;` or `typedef` inside an include guard or a conditional branch got no node. Every other C++ walk already sees through preproc_* wrappers; the alias scope check now does too. A member alias inside a conditional in a class body is still a member and still skipped. On fmt this adds 20 alias nodes (`native_int128`, `ullong`, ...) and moves 50 references off sourceless stubs. `days(42)` in chrono-test.cc now lands on the test's own `using days = ...` in an `#if` block instead of an unrelated `days()` method in chrono.h. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`uv run pyright` reported the `_pairs` helper's set as `set[tuple[str | None, str | None]]`: the edge ids are untyped, so the `dict.get` default did not pin the overload. Passing the default through `str()` does (ids are already strings), and `import importlib.util` makes the `find_spec` attribute explicit. No change at runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The one failing job, test (3.13) on 797bdaa, is an existing flaky watch test, not this change: Cause: the test harness |
797bdaa to
f3bf25b
Compare

What does this PR do?
The C++ extractor emitted no node for an alias declaration (
using X = …;) or atypedef. A field, parameter or return typed with one, even in the declaring header, therefore resolved to a sourceless stub, which the corpus rewire can grow into a hub or bind to an unrelated same-named symbol elsewhere.Changes (
graphify/extractors/engine.py, in_cpp_extra_walk):alias_declaration/type_definitionbecomes a type node contained by its file. Every declarator of a typedef counts:typedef int Handle, *HandlePtr;,typedef int (*Fn)(int);,typedef int Arr[4];(_cpp_declarator_type_namewalks pointer, function, parenthesized and array declarators). Alias templates (template <class T> using Ptr = T*;) count too. The handler returnsFalse, so the walk continues and a typedef'd struct body still gets its own node and fields.using value_type = T;,typedef … size_type;in a class). They sit in every container, and C++ member ids aren't class-qualified, so they would fold into one hub node. Scope is checked structurally (parent istranslation_unitor a namespacedeclaration_list, looking throughtemplate_declarationand through#ifndef/#if/#ifdef/#elsewrappers, which tree-sitter models aspreproc_*nodes: an include guard is not a scope, as the graphify-labs bot pointed out). A member alias inside a conditional in a class body is still a member.typedef struct Foo {…} Foo;names: the struct's own node is it.using Bar = ns::Bar;), which denote the original type and declare no new name. My Go alias PR uses the same rule; there a re-export node measurably regressed uber-go/zap.incheck was O(N²) (a 40,000-name typedef took 6.2 s; now 1.1 s, most of it emitting the 40,000 nodes v8 dropped).namespace_stack, so the id is_make_id(stem, name), the oneensure_named_nodelooks up for a name declared in the file.graphify/cache.py: AST cache schema 20 → 21 (the branch includes v8 at 0.9.93).Two existing tests updated (
test_cpp_nested_and_cli.py):typedef enum { A, B } Flag;/typedef union {…} Anon;asserted thatFlag/Anonare not nodes. Their stated concern is a nameless node and the anonymous specifier's members. They now expectFlag/Anonas alias nodes owning none of the members, and still forbid a nameless node and the members (A,a,b).Type of change
Verification & Invariants
Invariant: a source-backed declaration wins over a sourceless stub, without making a previously unique, correct resolution ambiguous.
User-visible: a name declared in two files is now honestly ambiguous. On fmt,
graphify explain test_stringused to pick compile-test.cc's struct silently. It now refuses with "Ambiguous: matches 2 nodes" (exit 3), becauseutil.hdeclaresusing test_string = …too. That's the existing fail-closed behaviour, now fed by the real declarations.Persisted state invalidated: AST cache schema bump (first build after upgrading re-extracts code files once).
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: the anonymous specifier named by a typedef still contributes no members (pre-existing). C (
.cwith the C config) models no types at all, so C typedefs are a separate feature.How was this tested?
New
tests/test_cpp_type_alias.py(9 tests): sourced alias nodes on their lines for every form,containsedges, field references binding to the aliases, member aliases not emitted, the C-styletypedef struct Foo {…} Foo;resolving to the struct, the re-export boundary, a cross-file reference rewired onto the alias, aliases inside an include guard and#if/#elif/#else/#ifdefblocks, and a guarded member alias staying a member. 4 fail on v8 (0.9.93) and 5 are guards; the conditional test also fails on this branch's earlier revision.Real repos, full-graph diff v8 (0.9.93) → this branch (every edge keyed by its site):
#ifblocks (enable_if_t,conditional_t,basic_format_context, …). I checked all 12 edge sites that exist only on v8:#include <variant>/<any>hitting the same external stub, whose label had been taken over by a case-folded type stubVariant/Any;Print(const Optional<T>&)wrongly bound to gmock.h's functionOptional(), now bound to theusing Optional = …alias in gtest.h itself;test_string(...)in ostream-test.cc wrongly bound to a struct in the unrelated compile-test.cc. It's now unresolved: the includedutil.hdeclaresusing test_string = …, and two declarations make the name ambiguous (fail-closed);native_int128/native_uint128/ullong, declared twice in mutually exclusive#ifbranches (an alias, and anenum classfallback): the node now sits at the first declaration (core.h L412 instead of L421), the duplicatemap()reference collapses into one, and a sourcelessnative_int128stub that a forward reference used to mint is gone;days(42)in chrono-test.cc bound to an unrelateddays()method in chrono.h; it now lands on the test's ownusing days = …inside#if.Scenarios (fmt, leveldb): identical graph under a different
PYTHONHASHSEED, parallel extraction, a warm cache, and a cache filled by v8 first. The guarded-alias fixture gives the same graph as LF, CRLF, BOM+CRLF and tab-indented. 1,680 mutated fmt/leveldb sources with injected guards,#if/#else, aliases and braces: 0 crashes or hangs on either side, same total time (22.6 s vs 22.7 s).Performance: full
extract(), cold cache, 3 interleaved rounds, v8 vs this branch: fmt 4.10/4.22/4.23 s vs 4.00/4.18/4.01 s, leveldb 1.81/1.89/1.81 s vs 1.88/1.75/2.01 s; peak RSS identical. Per file, no file is slower beyond noise. The handler runs only on alias and typedef nodes, and the scope walk only climbs a node's ancestors.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.)