Skip to content

fix(csharp): recover C# 14 extension blocks — bind the receiver, keep members in the class (#3510) - #3523

Open
Ayushraj06-bit wants to merge 4 commits into
Graphify-Labs:v8from
Ayushraj06-bit:fix/csharp-extension-blocks-3510
Open

Ayushraj06-bit wants to merge 4 commits into
Graphify-Labs:v8from
Ayushraj06-bit:fix/csharp-extension-blocks-3510

Conversation

@Ayushraj06-bit

@Ayushraj06-bit Ayushraj06-bit commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Problem. A C# 14 extension(Receiver r) { … } block loses its receiver: no references edge to the extended type and r.Method() inside the block cannot resolve, so call edges into these helpers go missing (#3510). Probing the bundled grammar shows it is wider than reported: with a type-parameter list (extension<T>(IFoo<T> spec)) the header lands in an ERROR node and the members survive as class methods; without one (extension(IFoo spec)) the block parses clean as a constructor named extension, so the members vanish outright and the partial-parse warning never fires.

Cause. tree-sitter-c-sharp 0.23.5 predates the syntax. Upstream merged C# 14 support (tree-sitter/tree-sitter-c-sharp#429) but has cut no release since v0.23.5 (PyPI latest), so a version bump is not available yet — and when it ships, the <0.25 pin will pick it up, and its extension_declaration node is an unknown wrapper to the walker, which demotes extension members to file level.

Approach. Recognise all three shapes in the walker and apply the existing rules to each: the receiver binds and is referenced like a C# 12 primary-constructor parameter (csharp_field_types, so receiver-typed member calls resolve), and the block body is a transparent container like the preproc_ wrappers, so members stay with the static class. Binding happens after the class body is walked; a same-named field/property, or a second block binding the same name to a different type, drops the name (no edge, never a guess). Everything is gated on tree_sitter_c_sharp + static class + the literal name extension; no existing edge changes.

Alternatives considered.

  1. Mine only the ERROR shape, as the issue suggests — leaves the non-generic form silently dropping members, and leaves the grammar-bump regression in place.
  2. Mask <T> / where in the source before parsing (the Vue source_override trick) so 0.23.5 sees one clean shape — loses the block's type-parameter names, has to be gated per grammar, and rewrites input, which Kotlin: bundled grammar rejects one-line type bodies (class C { val v = 1 }) — 21/541 files partially extracted, exit 0, no warning #2551 deliberately avoided.

How to verify.

pytest tests/test_csharp_extension_blocks.py

18 tests (the extension_declaration one skips until a newer grammar is installed). Unpatched: 12 fail (members missing / references and calls absent). Also run against a local build of upstream master (extension_declaration present): 18/18 pass; unpatched 13 fail. The issue's exact snippet now yields E → IFoo (references) and E.Url() → IFoo.Next() (calls), the same edges as the classic this IFoo spec spelling. Full suite, ruff, and pyright are unchanged from v8 head.

Note for C# 14 users. Anything after an extension<T> block in the same class still falls out of the class under 0.23.5 (grammar error recovery); the existing partial-parse warning names those files. Full support arrives with the next tree-sitter-c-sharp release, at which point the 0.23.5 branches in _csharp_extension_body / _csharp_extension_blocks can be deleted.

Open questions. The receiver references edge is emitted from the class (primary-constructor precedent); per-member edges (E.Url → IFoo) would mirror the classic spelling exactly — easy to switch if preferred. The extension_declaration handling is ~6 lines and tested against upstream master; happy to split it out if you'd rather land it with the version bump.

Fixes #3510

@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.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds C# 14 extension(...) block support to the generic extractor across three parse shapes the pinned tree-sitter-c-sharp 0.23.5 produces: a real extension_declaration, a clean constructor_declaration named extension, and a generic-form ERROR node holding a variable_declaration. Extension members are flattened into the enclosing static class (a local_function_statement under a class parent is retreated as a method_declaration, and _csharp_extra_walk walks the block body with the class as parent), and the receiver parameter is bound like a primary-constructor parameter so members can resolve against its type. The receiver binding runs after the member walk and refuses to guess: a same-named field/property of a different type, or two blocks binding one name to different receiver types, drops the name rather than emitting an edge.

Worth a look

  • Alias-qualified generic extension receivers are dropped — graphify/extractors/engine.py:2820 · 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 — 706 functions depend on the 262 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 29 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 9 more — each is listed as a finding

Verification — 706 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: 646 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

23 of 276 test file(s) selected (8%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_csharp_extension_blocks.py — impact, changed-test
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_import_extension_resolution.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_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_vue_extraction.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.

Formal verification

Could not verify: Could not verify \_csharp\_extra\_walk.

The verifier did not have enough to check \_csharp\_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: not verifiable: all 200 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)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 1 grounded finding(s) anchored inline below; 16 more finding(s) on lines outside this diff (see the check run).

Comment thread tests/test_csharp_extension_blocks.py
Ayushraj06-bit added a commit to Ayushraj06-bit/graphify that referenced this pull request Sep 12, 2026
…as their dotted form (Graphify-Labs#3510)

Review finding on Graphify-Labs#3523: an alias-qualified extension receiver was dropped on
the ERROR-shape text path (`::` was not a qualifier separator). The shared
node helpers had the same gap, pre-existing and wider: with no
`alias_qualified_name` branch, `global::IFoo` in any C# type position
fabricated a `global` type node and bound the receiver to it, and the dotted
`global::Acme.IFoo` carried `global::Acme` as its qualifier, which no
namespace matches, so the reference repointed to a dangling stub.

Fold the qualifier once in `_csharp_unalias`: `global::` is the root
namespace (already in every scope list) and a using/extern alias `X::B`
names what `X.B` does. Applied in `_read_csharp_type_name`,
`_csharp_collect_type_refs`, and the receiver text path, so extension
receivers, primary-constructor parameters, fields, and base lists all read
the same way. Identity on text without `::`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ayushraj06-bit added a commit to Ayushraj06-bit/graphify that referenced this pull request Sep 12, 2026
…as their dotted form (Graphify-Labs#3510)

Review finding on Graphify-Labs#3523: an alias-qualified extension receiver was dropped on
the ERROR-shape text path (`::` was not a qualifier separator). The shared
node helpers had the same gap, pre-existing and wider: with no
`alias_qualified_name` branch, `global::IFoo` in any C# type position
fabricated a `global` type node and bound the receiver to it, and the dotted
`global::Acme.IFoo` carried `global::Acme` as its qualifier, which no
namespace matches, so the reference repointed to a dangling stub.

Fold the qualifier once in `_csharp_unalias`: `global::` is the root
namespace (already in every scope list) and a using/extern alias `X::B`
names what `X.B` does. Applied in `_read_csharp_type_name`,
`_csharp_collect_type_refs`, and the receiver text path, so extension
receivers, primary-constructor parameters, fields, and base lists all read
the same way. Identity on text without `::`.
@Ayushraj06-bit
Ayushraj06-bit force-pushed the fix/csharp-extension-blocks-3510 branch from 0be509d to dbe0936 Compare September 12, 2026 23:29

@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 3 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds recovery for C# 14 extension(...) { } blocks so members inside are attached to the enclosing static class and the receiver parameter is bound and referenced like a primary-constructor parameter. Handles three parse shapes — a real extension_declaration on newer grammars, and under the bundled 0.23.5 either a constructor_declaration named extension or an ERROR node holding a variable_declaration for the generic form — resolving receiver names only for Pascal-case types and dropping the binding when two blocks disagree or a same-named field conflicts rather than guessing. Also folds C# alias qualifiers (global::, X::) into dotted form via _csharp_unalias and fixes qualified-name refs to mark themselves qualified only when a prefix actually exists.

Worth a look

  • Alias-qualified generic names split on dots inside generic arguments — graphify/extractors/engine.py:222 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • qualified_name refs now emit qualified=False for global:: names, changing resolver contract — graphify/extractors/engine.py:223 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Alias-qualified receiver/type names split on dots inside generic arguments — graphify/extractors/engine.py:3104 · 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 — 714 functions depend on the 270 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 29 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 11 more — each is listed as a finding

Verification — 714 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: 654 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

23 of 276 test file(s) selected (8%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_csharp_extension_blocks.py — impact, changed-test
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_import_extension_resolution.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_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_vue_extraction.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.

Formal verification

Could not verify: Could not verify \_csharp\_collect\_type\_refs.

The verifier did not have enough to check \_csharp\_collect\_type\_refs, 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: parameter `skip` is annotated `frozenset[str] | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_csharp\_extra\_walk.

The verifier did not have enough to check \_csharp\_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: not verifiable: all 200 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)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_read\_csharp\_type\_name.

The verifier did not have enough to check \_read\_csharp\_type\_name, 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: not verifiable: all 200 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)

· 3 grounded finding(s) anchored inline below; 16 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extractors/engine.py
Comment thread graphify/extractors/engine.py
Comment thread tests/test_csharp_extension_blocks.py
Ayushraj06-bit added a commit to Ayushraj06-bit/graphify that referenced this pull request Sep 12, 2026
…ide a type argument (Graphify-Labs#3510)

Review finding on Graphify-Labs#3523, pre-existing in the shared helpers: the qualified
branch of `_read_csharp_type_name` / `_csharp_collect_type_refs` ran
`rpartition(".")` over the whole node text, so `Acme.IFoo<System.String>`
(a field, a base list, a receiver) became a phantom `String>` type
qualified by `Acme.IFoo<System`, and `Outer<int>.Inner` carried `Outer<int>`
as its qualifier. Every `<...>` list is now dropped, nesting included,
before the split — folded into the same normaliser as the `::` alias
handling (`_csharp_dotted_name`), which the ERROR-shape receiver text path
uses too, so the three sites read a name identically.

@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 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds C# 14 extension(...) { } block support to the extractor: _csharp_extension_blocks recognizes the three shapes the pinned tree-sitter-c-sharp 0.23.5 produces (unnamed constructor, generic ERROR-recovery, and the real extension_declaration from newer grammars), treats the block as a container so its members bind to the enclosing static class, and binds the receiver parameter like a primary-constructor parameter — resolved after the body walk so a conflicting same-named field drops the name rather than emitting a wrong edge. Introduces _csharp_dotted_name to strip nested <...> type arguments and fold global::/alias qualifiers into dotted paths, and routes qualified_name and alias_qualified_name through it in both _csharp_collect_type_refs and _read_csharp_type_name so alias-qualified names resolve as their dotted equivalents instead of being read as standalone types. Adds tests covering nested generics keeping their own name, properties inside plain blocks, and receivers shadowed by a field of another type being dropped.

Worth a look

  • qualified_name refs now emit qualified=False for unqualified alias-stripped names, changing prior always-True contract — graphify/extractors/engine.py:239 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Alias-qualified generic arguments are discarded — graphify/extractors/engine.py:241 · 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 — 720 functions depend on the 276 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 29 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 11 more — each is listed as a finding

Verification — 720 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: 660 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

23 of 276 test file(s) selected (8%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_csharp_extension_blocks.py — impact, changed-test
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_import_extension_resolution.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_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_vue_extraction.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.

Formal verification

Could not verify: Could not verify \_csharp\_collect\_type\_refs.

The verifier did not have enough to check \_csharp\_collect\_type\_refs, 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: parameter `skip` is annotated `frozenset[str] | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_csharp\_extra\_walk.

The verifier did not have enough to check \_csharp\_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: not verifiable: all 200 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)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_read\_csharp\_type\_name.

The verifier did not have enough to check \_read\_csharp\_type\_name, 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: not verifiable: all 200 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)

· 3 grounded finding(s) anchored inline below; 16 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extractors/engine.py
Comment thread graphify/extractors/engine.py
Comment thread tests/test_csharp_extension_blocks.py
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Both review passes checked by execution; here is what each finding turned out to be and what changed.

Round 1 — "Alias-qualified generic extension receivers are dropped" (engine.py:2820) — real; fixed in dbe0936. extension<T>(Alias::IFoo<T> spec) / global::IFoo<T> produced no references and no calls edge because the ERROR-shape text path did not treat :: as a qualifier separator. The shared node helpers had the same gap, pre-existing and wider: with no alias_qualified_name branch, global::IFoo in any type position (field, base list, primary-constructor parameter) fabricated a phantom global type node and bound the receiver to it, and the dotted global::Acme.IFoo carried global::Acme as its qualifier, which no namespace matches, so the reference was repointed to a dangling stub.

Round 2 — #1 and #3, "split on dots inside generic arguments" (engine.py:222, :3104) — real, and pre-existing in v8: the qualified branch ran rpartition(".") over the whole node text, so Acme.IFoo<System.String> as a field or base type became a phantom String> qualified by Acme.IFoo<System (reproduced on v8 head, before this PR), and Outer<int>.Inner carried Outer<int> as its qualifier. Fixed in 3080deb.

Fix for all of the above — one normaliser, _csharp_dotted_name: drop every <…> list (nesting included) and fold the alias qualifier (global:: → root, which every scope list already contains; X::B → X.B, which resolve_qualified already handles), then split. Used by _read_csharp_type_name, _csharp_collect_type_refs, and the receiver text path, so every C# type reference reads a name the same way. Identity on text with neither < nor ::.

Round 2 — #2, "qualified=False for global:: names changes the resolver contract" — no existing emission changed. A qualified_name node always has a top-level dot, so its prefix is non-empty and it still emits qualified=True; the only qualified=False case is the undotted alias_qualified_name (global::IFoo), which the helpers previously did not handle at all (it fell into child recursion and emitted two unqualified refs, global and IFoo). Resolving it through the scope list is also safe: the root namespace is in every scope list, and resolve_type_name binds only when exactly one candidate exists, so a same-named type in another in-scope namespace yields no edge rather than a wrong one. Emitting qualified=True with an empty qualifier would instead make resolve_qualified return None and repoint to a dangling stub.

Tests — 10 added across the two commits (both block shapes × global::Acme.IFoo / Alias::IFoo resolve to the real Acme.IFoo node, not a stub; root-anchored receiver is unqualified; the primary-constructor path no longer fabricates global; dotted generic arguments on a receiver, a field, and a base list; Outer<int>.Inner). Each fails on the commit before its fix. Full suite, ruff, and pyright unchanged from v8.

Coupling notes — answered inline: _csharp_collect_type_refs gains one callee and its fan-in is the number of C# type positions; _csharp_extension_blocks fans out to the existing primitives so three parse shapes share one receiver rule; the test helper is the shared contract. The "new hotspots" list (_extract_generic, extract_js, extract_xaml, …) names pre-existing functions the diff neither adds nor edits.

CI on this exact head, via the same ci.yml dispatched on my fork while the upstream run awaits first-contributor approval — all six jobs green (skillgen-check, test 3.10/3.12/3.13/3.14, security-scan): https://github.com/Ayushraj06-bit/graphify/actions/runs/34726823728

Ayushraj06-bit added a commit to Ayushraj06-bit/graphify that referenced this pull request Sep 13, 2026
…Graphify-Labs#3510)

Review finding on Graphify-Labs#3523: `global::IFoo<Bar>` used to reach `Bar` only by
accident — the unhandled alias_qualified_name fell into child recursion,
which emitted the phantom `global` and `Bar` alongside `IFoo` — so routing
it through the qualified branch dropped `Bar`. That branch had never
collected type arguments at all: a bare `IFoo<Bar>` references Bar, a
qualified `Acme.IFoo<Bar>` did not. Collect every type_argument_list at any
depth of a qualified or alias-qualified name, the way the generic_name
branch reads a bare one, so both spellings yield the same generic_arg
references.

@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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds C# 14 extension(Receiver r) { … } block support to the extractor: _csharp_extension_blocks recognizes all three parse shapes (a real extension_declaration from newer grammars, a constructor_declaration named extension inside a static class under the bundled 0.23.5, and an ERROR-node recovery for the generic extension<T>(...) form) and yields the receiver type, its type refs, and receiver name, while _csharp_extra_walk treats such blocks as containers whose members belong to the enclosing static class rather than as their own scope. Introduces _csharp_dotted_name to strip type-argument lists at any nesting depth and fold global::/alias qualifiers into dotted form, and routes qualified-name and alias-qualified-name handling in both _csharp_collect_type_refs and _read_csharp_type_name through it so splits land on the name's own dots and nested type arguments (e.g. Widget in Acme.Box<Widget>) get referenced as generic args. Also adds extension_declaration to the type-parameter scope set.

No blocking issues surfaced. 7 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 725 functions depend on the 281 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 29 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 61 callees
  • …and 11 more — each is listed as a finding

Verification — 725 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: 665 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

23 of 276 test file(s) selected (8%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_csharp_extension_blocks.py — impact, changed-test
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_import_extension_resolution.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_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_vue_extraction.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.

Formal verification

Could not verify: Could not verify \_csharp\_collect\_type\_refs.

The verifier did not have enough to check \_csharp\_collect\_type\_refs, 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: parameter `skip` is annotated `frozenset[str] | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_csharp\_extra\_walk.

The verifier did not have enough to check \_csharp\_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: not verifiable: all 200 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)

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_read\_csharp\_type\_name.

The verifier did not have enough to check \_read\_csharp\_type\_name, 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: not verifiable: all 200 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)

· 3 grounded finding(s) anchored inline below; 16 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extractors/engine.py
Comment thread graphify/extractors/engine.py
Comment thread tests/test_csharp_extension_blocks.py
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Round 3 (on 3080deb), checked by execution:

"Alias-qualified generic arguments are discarded" (engine.py:241) — real in the narrow case, fixed in 6a693c3. On v8, global::IFoo<Bar> reached Bar only by accident: the unhandled alias_qualified_name fell into child recursion, which emitted the phantom global and IFoo and Bar; routing it through the qualified branch dropped Bar. The wider fact is that the qualified branch never collected type arguments at all — a bare IFoo<Bar> references Bar, a qualified Acme.IFoo<Bar> did not, on v8 too. The branch now walks every type_argument_list at any depth of a qualified or alias-qualified name, the way the generic_name branch reads a bare one, so both spellings yield the same generic_arg references (nested arguments like Acme.IFoo<System.Collections.Generic.List<Bar>> included). 5 tests added; each fails on 3080deb.

"qualified=False for unqualified alias-stripped names" (engine.py:239) — as answered on round 2: no existing emission changed. A qualified_name always keeps its top-level dot, so its prefix is non-empty and it still emits qualified=True; the only False case is the undotted alias_qualified_name (global::IFoo), which previously wasn't handled at all. Resolving it through the scope list is safe (root is in every scope list; resolve_type_name binds only on a single candidate), whereas qualified=True with an empty qualifier makes resolve_qualified return None and repoint to a dangling stub. I've put that invariant in a comment on the line so it doesn't need re-deriving.

Inline coupling threads answered and resolved (unchanged from the previous heads). Fork CI on this head, all six jobs green: https://github.com/Ayushraj06-bit/graphify/actions/runs/34727548500

… members in the class (Graphify-Labs#3510)

tree-sitter-c-sharp 0.23.5 predates `extension(Receiver r) { … }`. A generic
header lands in an ERROR node (members kept, receiver lost); a non-generic one
parses clean as a constructor named `extension` (members dropped, no warning);
upstream's coming `extension_declaration` was an unknown wrapper (members
demoted to file level). Recognise all three: the receiver binds and is
referenced like a primary-constructor parameter, and the body is a transparent
container like the preproc_ wrappers. A same-named field or a second block of
another type drops the name rather than guessing.
…as their dotted form (Graphify-Labs#3510)

Review finding on Graphify-Labs#3523: an alias-qualified extension receiver was dropped on
the ERROR-shape text path (`::` was not a qualifier separator). The shared
node helpers had the same gap, pre-existing and wider: with no
`alias_qualified_name` branch, `global::IFoo` in any C# type position
fabricated a `global` type node and bound the receiver to it, and the dotted
`global::Acme.IFoo` carried `global::Acme` as its qualifier, which no
namespace matches, so the reference repointed to a dangling stub.

Fold the qualifier once in `_csharp_unalias`: `global::` is the root
namespace (already in every scope list) and a using/extern alias `X::B`
names what `X.B` does. Applied in `_read_csharp_type_name`,
`_csharp_collect_type_refs`, and the receiver text path, so extension
receivers, primary-constructor parameters, fields, and base lists all read
the same way. Identity on text without `::`.
…ide a type argument (Graphify-Labs#3510)

Review finding on Graphify-Labs#3523, pre-existing in the shared helpers: the qualified
branch of `_read_csharp_type_name` / `_csharp_collect_type_refs` ran
`rpartition(".")` over the whole node text, so `Acme.IFoo<System.String>`
(a field, a base list, a receiver) became a phantom `String>` type
qualified by `Acme.IFoo<System`, and `Outer<int>.Inner` carried `Outer<int>`
as its qualifier. Every `<...>` list is now dropped, nesting included,
before the split — folded into the same normaliser as the `::` alias
handling (`_csharp_dotted_name`), which the ERROR-shape receiver text path
uses too, so the three sites read a name identically.
…Graphify-Labs#3510)

Review finding on Graphify-Labs#3523: `global::IFoo<Bar>` used to reach `Bar` only by
accident — the unhandled alias_qualified_name fell into child recursion,
which emitted the phantom `global` and `Bar` alongside `IFoo` — so routing
it through the qualified branch dropped `Bar`. That branch had never
collected type arguments at all: a bare `IFoo<Bar>` references Bar, a
qualified `Acme.IFoo<Bar>` did not. Collect every type_argument_list at any
depth of a qualified or alias-qualified name, the way the generic_name
branch reads a bare one, so both spellings yield the same generic_arg
references.
@Ayushraj06-bit
Ayushraj06-bit force-pushed the fix/csharp-extension-blocks-3510 branch from 6a693c3 to b187e85 Compare October 5, 2026 19:04
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Rebased onto current v8 (b57cf91). The branch was 366 commits behind and conflicted in engine.py.

Verified after the rebase (Windows 11, Python 3.11, uv sync --frozen):

uv run pytest tests/test_csharp_extension_blocks.py        32 passed, 1 skipped
  (the skip is this PR's grammar-gated test for when the grammar gains extension_declaration)
uv run pytest tests/test_csharp*.py tests/test_cross_language_call_resolution.py   204 passed, 1 skipped
uv run pytest tests/ -q   (two halves)
  b57cf91 (v8):  70 failed, 6307 passed, 146 skipped
  this branch:   70 failed, 6339 passed, 147 skipped
  (the 70 failing test ids are identical on both and already fail on a clean
   Windows checkout; none are C# tests. The +32/+1 are this PR's tests.)
uv run ruff check .                                     All checks passed!
uv run pyright graphify/extractors/engine.py            33 errors before and after, same set
python -m tools.skillgen --check / --audit-coverage / --schema-singleton /
  --monolith-roundtrip / --always-on-roundtrip           all ok

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.

C#: bundled tree-sitter-c-sharp 0.23.5 cannot parse C# 14 extension members — class body mis-parsed, receiver binding lost

1 participant