fix(cpp): extract namespace-scope `using` and `typedef` aliases as type nodes by madhusudanpatnaik · Pull Request #4580 · Graphify-Labs/graphify · GitHub
Skip to content

fix(cpp): extract namespace-scope using and typedef aliases as type nodes - #4580

Open
madhusudanpatnaik wants to merge 5 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/cpp-type-alias
Open

madhusudanpatnaik wants to merge 5 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/cpp-type-alias

Conversation

@madhusudanpatnaik

@madhusudanpatnaik madhusudanpatnaik commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

The C++ extractor emitted no node for an alias declaration (using X = …;) or a typedef. 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):

  • A namespace- or file-scope alias_declaration / type_definition becomes 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_name walks pointer, function, parenthesized and array declarators). Alias templates (template <class T> using Ptr = T*;) count too. The handler returns False, so the walk continues and a typedef'd struct body still gets its own node and fields.
  • Not declarations here, on purpose:
    • Member aliases (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 is translation_unit or a namespace declaration_list, looking through template_declaration and through #ifndef/#if/#ifdef/#else wrappers, which tree-sitter models as preproc_* 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.
    • The struct a C-style typedef struct Foo {…} Foo; names: the struct's own node is it.
    • Same-name re-exports (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.
  • Declarator names are de-duplicated with a set: one typedef can declare thousands of names, and a list in check 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).
  • C++ never populates namespace_stack, so the id is _make_id(stem, name), the one ensure_named_node looks 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 that Flag / Anon are not nodes. Their stated concern is a nameless node and the anonymous specifier's members. They now expect Flag / Anon as alias nodes owning none of the members, and still forbid a nameless node and the members (A, a, b).

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, 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_string used to pick compile-test.cc's struct silently. It now refuses with "Ambiguous: matches 2 nodes" (exit 3), because util.h declares using 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 (.c with 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, contains edges, field references binding to the aliases, member aliases not emitted, the C-style typedef 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/#ifdef blocks, 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):

    • google/leveldb (132 files): sourceless nodes 644 → 630, edges into sourceless 1,440 → 1,393, 47 stub → real, 0 real → stub, 0 moved, 0 lost.
    • fmtlib/fmt (73 files): edges 12,251 → 12,373, sourceless nodes 1,112 → 987, edges into sourceless 2,271 → 1,958, 311 stub → real, 0 real → stub, 0 moved. fmt keeps many aliases inside #if blocks (enable_if_t, conditional_t, basic_format_context, …). I checked all 12 edge sites that exist only on v8:
      • 4 are #include <variant> / <any> hitting the same external stub, whose label had been taken over by a case-folded type stub Variant/Any;
      • 1 was gtest.h's Print(const Optional<T>&) wrongly bound to gmock.h's function Optional(), now bound to the using Optional = … alias in gtest.h itself;
      • 1 was test_string(...) in ostream-test.cc wrongly bound to a struct in the unrelated compile-test.cc. It's now unresolved: the included util.h declares using test_string = …, and two declarations make the name ambiguous (fail-closed);
      • 5 are native_int128 / native_uint128 / ullong, declared twice in mutually exclusive #if branches (an alias, and an enum class fallback): the node now sits at the first declaration (core.h L412 instead of L421), the duplicate map() reference collapses into one, and a sourceless native_int128 stub that a forward reference used to mint is gone;
      • 1 was days(42) in chrono-test.cc bound to an unrelated days() method in chrono.h; it now lands on the test's own using 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.

uv run pytest tests/test_cpp*.py tests/test_languages.py tests/test_multilang.py -q   # 664 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.10: 8472 | 3.13: 8470 passed (3.13 also hit a watch-lifecycle timing flake that fails on v8 under the same load)
#   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   # all OK
uv run python -m tools.skillgen --bless   # 134 artifacts, no changes
uv run ruff check .                       # v0.15.14, as pinned in .pre-commit-config.yaml
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
GRAPHIFY_TEST_CLI=.venv/bin/graphify bun test tests/pi.test.ts   # 20/20
uv run graphify --help && uv run graphify install   # OK, in a throwaway HOME

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.

madhusudanpatnaik and others added 2 commits October 20, 2026 07:43
…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>
@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 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 — impact
  • tests/test_build.py — impact
  • 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_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact, changed-test
  • tests/test_cpp_type_alias.py — impact, changed-test
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_ejs_extractor.py — impact
  • tests/test_ext_tagged_id_clash.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_extract_php_closures.py — impact
  • tests/test_grammar_cache_key.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_indirect_call_block_scoped_shadow.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_java_member_calls.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_js_member_collisions.py — impact
  • tests/test_language_override_lifecycle.py — impact
  • tests/test_language_overrides.py — impact
  • tests/test_languages.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_multilang.py — impact
  • tests/test_objc_field_table_remap.py — impact
  • tests/test_out_dir_evidence.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_php_unparsed_casts.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_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_rust_self_member_calls.py — impact
  • tests/test_rust_use_trees.py — impact
  • tests/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).

madhusudanpatnaik and others added 3 commits October 20, 2026 18:19
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>
@madhusudanpatnaik

Copy link
Copy Markdown
Contributor Author

The one failing job, test (3.13) on 797bdaa, is an existing flaky watch test, not this change:
test_watch_root_config_lifecycle_rebuilds_and_refreshes_event_mapping[modify]. The same commit passed on 3.10, 3.12 and 3.14, and the full suite passes on 3.13 locally (8471 passed; only the 7 DNS tests fail, since I have no network).

Cause: the test harness drive_watch patches sleep on the shared time module. The rebuild's git rev-parse (_git_head, subprocess.run(..., timeout=3)) sleeps in CPython's wait loop whenever git hasn't been reaped yet. That sleep ran the harness's end-of-script KeyboardInterrupt, so the watcher stopped before extract(). I reproduced it on v8 itself: 3 failures in 3,000 runs. The fix is test-only, in a separate PR: #4620. Could you re-run the failed job? Thanks!

@madhusudanpatnaik

madhusudanpatnaik commented Oct 21, 2026 •

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