Skip to content

fix(csharp): blank preprocessor directives and #if branches that derail parsing - #5003

Open
rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:fix/csharp-preprocessor
Open

rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:fix/csharp-preprocessor

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

Closes #5002

What does this PR do?

tree-sitter-c-sharp parses #if / #elif / #else / #endif with every branch in place. Around a directive its error recovery can close the enclosing class early, so every later member loses its class (repro in the issue).

Before parsing, extract_csharp now rewrites the source:

  1. Every directive line is blanked (#if, #else, #region, #pragma, #nullable, #define, ...).
  2. The first branch of each #if group is always kept.
  3. A later #elif / #else branch is also kept, so members that exist only there stay in the graph (C#: members inside #if / #else blocks produce no nodes, and calls to them produce no edges #2631, its test in tests/test_dotnet.py passes unchanged). It is blanked only when keeping it would derail the parse:
    • its {} or () do not net to zero on their own (class A : B { / #else / class A {), or
    • it declares a member (name + arity) that a kept branch of the same group already declares (void Write(Span<char> s) / #else / void Write(string s) sharing one body).

Nested groups are decided first. Blanked bytes become spaces and CR/LF are kept, so every byte keeps its line and column. A # line inside a comment or a multi-line string is not treated as a directive.

AST cache schema 37 -> 38 because C# output changes. (#5001 also bumps it to 38. Whichever lands second takes 39.)

Diff: graphify/extract.py +277 / -3, 5 new tests in tests/test_csharp_member_calls.py.

Numbers

calls links that Roslyn resolves on Newtonsoft.Json, Dapper and AutoMapper, and how many graphify finds:

kind Roslyn before after
same class 2819 2497 2586
inherited 422 302 376
static 948 807 830
typed receiver 986 680 838
generic 321 263 264
ctor 710 478 474
extension / interface / other 871 / 188 / 84 1 / 104 / 71 unchanged
total 7349 5203 (70.8%) 5544 (75.4%)
wrong calls edges 288 264

Per project: Newtonsoft.Json 72.8% -> 79.0%, Dapper 81.2% -> 81.7%, AutoMapper unchanged (55.3%).

The ctor drop (-4) comes from five new JValue(...) calls inside the partial class JValue. Those methods now correctly belong to JValue, and the graph keeps one edge per node pair, so the JValue -> method edge takes the place of the calls edge.

I also tried keeping only the first branch. It scores one link more (5545) but drops #else-only members, which breaks #2631.

Limitations

  • 5 of the 40 files with tree-sitter errors still have one. Their leftover spots are balanced alternatives with no member, like bool Ignored then => false; / #else / { get; } in AutoMapper. They cost no Roslyn links here.
  • The duplicate-member check is a text scan of the branch, not a parse.
  • Calls inside a kept #else branch that the net8.0 build does not compile now show up as edges. That is expected when both branches are kept.

Type of change

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

Verification & Invariants

Invariant: the rewrite never moves a byte. It only turns directive lines and derailing branches into spaces, so every node keeps its real source_location. Members in any balanced, non-duplicate branch stay in the graph.

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

How was this tested?

uv run --frozen pytest -q tests/test_csharp_member_calls.py tests/test_dotnet.py   # pass; with v8's extract.py the 5 new tests fail, the #2631 test passes on both
uv run --frozen pytest tests -q                                                     # 9985 passed; the only 11 failures were tests/test_skillgen.py in a shallow clone (pinned commit 47042be missing), they pass in a full clone
uv run --frozen ruff check graphify tests                                           # clean
uv run --frozen pyright graphify/extract.py tests/test_csharp_member_calls.py       # same errors as v8, none new
graphify update . on Newtonsoft.Json, Dapper, AutoMapper, before and after          # numbers above, compared with Roslyn

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (Not needed: no skill fragments changed.)
  • 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.

🤖 Generated with Claude Code

…derail parsing

tree-sitter-c-sharp parses `#if`/`#elif`/`#else`/`#endif` with every branch
in place. Its error recovery around a directive can close the enclosing class
early, so every later member became a class-less node: it lost its `Type.`
owner, its field/receiver typing and same-class resolution, and its calls
were lost or wrong (e.g. `Writer.Flush -> Buffer.Append` was missing).

Before parsing, extract_csharp now blanks every directive line (including
#region, #pragma, #nullable and #define). The first branch of each #if group
is always kept. A later #elif/#else branch is kept as well, so members that
exist only there stay in the graph (Graphify-Labs#2631). It is blanked only when keeping
it would derail the parse:
- its `{}` or `()` do not net to zero on their own
  (`class A : B {` #else `class A {`), or
- it declares a member (name + arity) that a kept branch of the same group
  already declares (`void Write(Span<char>)` #else `void Write(string)`
  sharing one body).
Nested groups are decided first. The rewrite keeps the byte length the same
(removed bytes become spaces, CR/LF kept), so every remaining byte keeps its
line and column. A `#` line inside a comment or a multi-line string is not
treated as a directive.

Measured against the Roslyn answer key on Newtonsoft.Json, Dapper and
AutoMapper: 5544/7349 links (75.4%, was 70.8%), and 264 wrong `calls` edges
(was 288). No kind loses recall except `ctor` (-4): five `new JValue(...)`
calls inside JValue. Those methods now correctly belong to JValue, and the
graph keeps one edge per node pair, so the JValue -> method edge replaces
the calls edge. Keeping only the first branch scores 5545 but drops #else-only
members.

Bumps the AST cache schema to 38 so cached C# extractions re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Thanks for the pull request, @rohit-jsfreaky. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 4, 2026

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

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: extract\_csharp (vacuous: never exercised).


Graphify review — findings

Fixes C# extraction losing class membership around preprocessor directives: directives are now blanked before parsing, and an #if branch that would derail tree-sitter-c-sharp is blanked too, with byte length preserved so lines and columns stay put. Previously the grammar's error recovery could close the enclosing class early, leaving every later member class-less with its calls dropped. The branch-selection logic in _blank_csharp_preprocessor is where fragment #else branches (base lists, split headers) can survive next to the #if branch, and _csharp_token_end has to scan interpolated strings to find token boundaries. Bumps _AST_CACHE_SCHEMA to 38 so cached C# extractions re-run, and adds C# member-call tests covering receiver typing (using-directive disambiguation, out declarations, primary-constructor parameters, local shadowing) and nested split-signature preprocessor headers.

Worth a look

  • Fragment #else branches (base lists, split headers) are kept alongside #if branch, derailing the parse — graphify/extract.py:3922 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Deep nested interpolated strings can crash C# extraction — graphify/extract.py · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 4699 functions depend on the 545 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract_csharp() — 20 callers, 3 callees

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

Test selection

Test selection

325 of 518 test file(s) selected (63%) via static blast radius.

  • tests/test_4874_jvm_resolution.py — impact
  • tests/test_affected_overrides.py — impact
  • tests/test_alias_folding.py — impact
  • tests/test_analyze.py — impact
  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_binary_media_words.py — impact
  • tests/test_blade_extractor.py — impact
  • tests/test_build.py — impact
  • tests/test_build_located_semantic_identity.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_ghost_fold_4955.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_c_include_resolution.py — impact
  • tests/test_cache.py — impact
  • tests/test_cache_long_paths.py — impact
  • tests/test_cache_stale_import_target.py — impact
  • tests/test_cache_windows_case_4925.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_case_only_decl_ids_4904.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cluster.py — impact
  • tests/test_cobol_extractor.py — impact
  • tests/test_code_only_scope_4923.py — impact
  • tests/test_confidence.py — impact
  • tests/test_confidence_score_output.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cpp_local_callable_shadowing.py — impact
  • tests/test_cpp_member_call_by_receiver_type.py — impact
  • tests/test_cpp_method_declarations.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cpp_receiver_parameters.py — impact
  • tests/test_cpp_split_header_owner.py — impact
  • tests/test_cpp_template_type_params.py — impact
  • tests/test_cpp_type_alias.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_delegate_declarations.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • … and 275 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.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Could not verify: Could not verify extract\_csharp.

The verifier did not have enough to check extract\_csharp, 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 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)

· 1 grounded finding(s) anchored inline below.

Comment thread graphify/extract.py
return bytes(out)


def extract_csharp(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — extract_csharp()

20 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor Author

I checked both bot findings:

  • Fragment #else branches (base lists) kept next to the #if branch: public class A / #if NET / : B / #else / : C / #endif / { ... } gives the same graph on v8 and on this branch: A inherits B, A inherits C, both methods attached, the call linked. tree-sitter recovers from : B : C. Split headers that declare a member are what the duplicate-member rule blanks (test_preprocessor_nested_split_signature_keeps_one_header).
  • Deeply nested interpolated strings crash: they don't. $"{$"{...}"}" nested 2000 deep gives the same result on v8 and on this branch: a tree-sitter syntax warning for that file, no exception. At 50 levels both extract normally.

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

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: C#: an #if block can close the class early, so later members lose their class and their calls

1 participant