* fix(csharp): walk field and property initializers as the member Field declarators and property `= initializer` values were skipped by both extractors, so `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` linked nothing, and a method or class used only from an initializer looked unused. - tree-sitter.ts / csharp.rs: walk each C# field declarator whole with its field on the stack, and every property `value:` (not only `=> expr`) with the property on the stack. Calls, instantiations, static reads, lambda bodies and fn-ref candidates (varinit included) belong to the member; the fn-ref scan skips the walked subtrees, so each candidate is captured once. Attributes stay unwalked. - A target-typed `new()` that is the initializer instantiates the declared type (`List<Foo> _items = new();` -> List). In bodies it stays invisible, as before. - name-matcher.ts: a .NET type position never names a `constant` (the kind `const` / `static readonly` fields get). Walking initializers exposed `new Version(5, 18)` binding to a `const string Version` and `static readonly Meter Meter = new(...)` instantiating itself. Kernel/wasm parity: 0 diffs on serilog, Newtonsoft.Json and jellyfin, and full-index dumps byte-identical between the two arms. No EXTRACTION_VERSION bump: #2345 already moved it to 28 this release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): credit the report and contributor behind the constant change The type-position change here is what issue #2337 reports (`new Station { … }` unresolved beside a `private const string Station`) and the same one-line change as @drakeo338's #2339. Verified on the issue's own three-file repro: the `instantiates` edge to `Demo.Core::Station`, missing on main, is present with this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
62 KiB
C# kernel port (R7b) — the bug-for-bug checklist
Status: PORT COMPLETE (2026-07-20) — walker codegraph-kernel/src/csharp.rs,
all gates passed (parity sweeps 0-diff on serilog 211/216 / Newtonsoft.Json
914/945 / jellyfin 2104/2105 — every deferral matching §arch-6's predictions;
full-init dump gates byte-identical ×3; kernel-csharp-parity suite + grammar
row; suite ×2 under CODEGRAPH_KERNEL_EXPECT=1; DEFAULT_ROUTED += csharp). This
doc remains the quirk reference for the walker. Survey basis: every
TS-side branch a .cs file exercises, with file:line anchors as of f1ca991
(HEAD at survey time, clean main). Every grammar-shape claim below was probed
against the vendored tree-sitter-c-sharp 0.23.5 wasm
(src/extraction/wasm/tree-sitter-c_sharp.wasm — note the underscore filename);
probe scripts + raw outputs live in the survey session scratchpad
(/private/tmp/claude-501/-Users-colby-Development-CodeGraph-codegraph/0c11bda1-0b19-4fec-bcd9-d0cb4b2d6e8a/scratchpad/svy-csharp/:
probe1-core.mjs, probe2-shapes.mjs, probe3-edge.mjs, census-wasm.mjs,
error-incidence.mjs, blank-impl.mjs + probe*-out.txt). Read WITH
docs/design/rust-kernel-migration-plan.md (§0a recipe, §5 gates) and the
rust/ccpp checklists (format precedents). Blocking findings: none — the
vendored wasm is table-identical to the crates.io 0.23.5 crate (below), so no
grammar bump is needed; grammar prep is a Cargo pin + parity-test entry only.
Grammar provenance (verified, not assumed):
- Vendored wasm landed in PR #717 (commit
80db274e, 2026-06-07 — the #237 primary-constructor fix), replacing tree-sitter-wasms' ABI-13 build. The commit message says "tree-sitter-c-sharp 0.23.5 (ABI 15)" but does NOT claim a parser.c sha-match, so this survey verified compatibility directly against the crates.iotree-sitter-c-sharp-0.23.5tarball (src/parser.csha2560a2651e4…,src/scanner.csha25600920daa…; upstream tagv0.23.5→ commitcac6d5fb):- wasm ABI 15 == parser.c
LANGUAGE_VERSION 15; - wasm
stateCount8053 == parser.cSTATE_COUNT 8053; - wasm
nodeTypeCount533 ==SYMBOL_COUNT 530 + ALIAS_COUNT 3; - wasm
fieldCount26 with a field-name set identical to the crate's node-types.json (accessors…value); - named node-kind set: wasm ⊆ crate exactly (the 9 crate-only names are
node-types.json supertypes —
declaration,expression,pattern, … — which never instantiate; zero real divergence); - the crate ships an external scanner (
scanner.c, 12 external tokens) and the wasm embeds it — probed: interpolated raw strings ($"""… {Interp(u)} …"""), raw string literals, and verbatim strings all parse clean.
- wasm ABI 15 == parser.c
- Cargo plan: add
tree-sitter-c-sharp = "=0.23.5"tocodegraph-kernel/Cargo.tomlfollowing the exact-pin comment block (the=0.24.2c/rust rows); crate symbol istree_sitter_c_sharp::LANGUAGE(aLanguageFn,.into()like the other 0.23-era crates); register"csharp" => Some(tree_sitter_c_sharp::LANGUAGE.into())incodegraph-kernel/src/langs.rs+ add"csharp"toLANGUAGES. The crate's build.rs compiles parser.c and scanner.c (both in the tarball) — no extra work.__tests__/kernel-grammar-parity.test.ts(id-by-id ABI + node-kind + field-table compare) is the real gate and needs a csharp row; no wasm change and no TS-side behavior change — the production wasm stays exactly as shipped since #717, so this port has NO grammar-bump-first step.
Architecture decisions
- preParse stays TS-side — already hoisted, port NOTHING.
csharpExtractor.preParse = blankCsharpPreprocessorDirectives(languages/csharp.ts:26, #237) blanks#if/#elif/#else/#endifdirective LINES (both branches of the guarded code are KEPT). The route point already applies it for kernel calls —preParsedSource(kernel/index.ts:82) names csharp explicitly — so both arms parse identical blanked bytes and the deferred-file memo reuses them (takeDeferredPreParse, kernel/index.ts:120; wasm fallbacksourceIsPreParsed, tree-sitter.ts:499). Never port the regex to Rust: it is a JS(?m)^-anchored replace (/^([ \t]*)#[ \t]*(if|elif|else|endif)\b[^\n]*/gm) whose^matches after\r(JS multiline semantics) and whose[^\n]*EATS the\rof a CRLF line (probed:"…\r\n#if X\r\n…"blanks to"…\r\n \n…"— byte count preserved,\rbecomes a space). Both are JS-semantics traps (the #1329 class) that the hoist makes moot. - ASP.NET repos take the DECODED path, not raw buffers.
aspnetResolver(resolution/frameworks/csharp.ts:11,languages: ['csharp'], registered in frameworks/index.ts:62) has anextract()hook (line 133), and parse-worker.ts:93-100 forces any language with an applicable frameworkextract()onto the decodedextractFromSourcepath. detect() (csharp.ts:15) fires on.csprojAspNetCore markers,Program.cs/Startup.csshapes, or controller-source signatures — so web-app repos (jellyfin-class) decode per file while pure libraries (serilog, Newtonsoft.Json) ride the raw buffers-to-store transport. Don't chase a raw-path perf number on an ASP.NET repo and conclude the port is broken. - The framework extractor itself needs NO port — regex over raw source (§Frameworks below), runs in extractFromSource:6736-6758 after either arm.
- One walker module (suggest
codegraph-kernel/src/csharp.rs), registered in langs.rs; per-filehas_error()→defer:like every walker. java.rs is the skeleton (§java.rs mapping below) — same namespace-node concept, same field/constant split, same static-member/value-ref/fn-ref chassis. .cs→csharpat detectLanguage (grammars.ts:87), wasm filenametree-sitter-c_sharp.wasm(grammars.ts:31), vendored-set membership at grammars.ts:292, provenance comment at grammars.ts:252-261. No content sniffing, no dialects (.cshtml/.razorare languagerazor, a T3 standalone extractor — NOT this port). MAX_FILE_SIZE (1 MiB) and generated-file skips are orchestrator/TS-side and shared.- Deferral expectations (measured on the gate repos, post-blank = what the
kernel sees): serilog 5/216 = 2.31% (raw 8.33% — the blanking hoist
halves it), Newtonsoft.Json 31/945 = 3.28% (raw 6.88%), jellyfin
1/2105 = 0.05%. Keep the sweep default
--max-deferral 0.1— C# needs NO c/cpp-style 0.5 exemption. The residual error class is grammar-inherent both-branches-kept damage (#if FEATURE_DEFAULT_INTERFACEaround interface members in serilog's ILogger.cs; half-expression#if HAVE_DATE_TIME_OFFSETguards in Newtonsoft's JsonReader.cs) — those files error on BOTH arms and defer by policy. C# 12 collection expressions ([a, b]) parse natively (probed) — the lone jellyfin error is unrelated.
Extractor config (languages/csharp.ts — 163 lines, read it whole)
Types: functionTypes=[] — C# has NO function branch anywhere (this single
fact kills several generic paths below: no nested-fn extraction, no
arrow-name recovery, extractMethod's fallback-to-extractFunction is
unreachable on non-erroring files). classTypes=[class_declaration,
record_declaration] with classifyClassNode (csharp.ts:69): a
record_declaration with an anonymous struct keyword child → 'struct', else
'class' (probed: EVERY record form is record_declaration — record struct
/ readonly record struct included; record_struct_declaration in structTypes
is forward-compat only, the grammar has no such node).
methodTypes=[method_declaration, constructor_declaration];
interfaceTypes=[interface_declaration] (no interfaceKind → kind
'interface'); structTypes=[struct_declaration, record_struct_declaration];
enumTypes=[enum_declaration]; enumMemberTypes=[enum_member_declaration];
typeAliasTypes=[] (a using Alias = … NEVER makes a type_alias — it goes
through extractImport, quirk below);
packageTypes=[namespace_declaration, file_scoped_namespace_declaration];
importTypes=[using_directive]; callTypes=[invocation_expression];
variableTypes=[local_declaration_statement]; fieldTypes=[field_declaration];
propertyTypes=[property_declaration]. nameField=name, bodyField=body,
paramsField=parameters, returnField=type (note: type is a dead value
for methods — the 0.23.x grammar renamed the method return field to
returns, and both consumers, getReturnType and extractCsharpTypeRefs, read
returns themselves; returnField is only reachable via the generic
extractTypeAnnotations path which csharp short-circuits out of).
Hooks PRESENT (port each exactly):
- preParse — TS-side, hoisted (§arch-1). Not ported.
- getReturnType = extractCsharpReturnType (csharp.ts:43) — reads the
returnsfield;predefined_type(void/int/string/…) orarray_type→ undefined; else raw text trim → strip trailing\?+(nullable) → strip/<[^>]*>/g(generics) → last.-segment → must match/^[A-Za-z_]\w*$/else undefined. QUIRKS (same class as rust): the non-nested<[^>]*>strip breaks on nested generics —Task<List<Foo>>→"Task>"→ regex fails → undefined (single-levelTask<Widget>→Tasksurvives);Ns.Foo→Foo;Foo?→Foo. Constructors have noreturnsfield → undefined. This feeds the #645/#608 chained-call resolution (name-matcher.ts:2156matchDottedCallChain) — emission shape must hold. - getVisibility (csharp.ts:102) — scan ALL children (
node.child(i)) for typemodifier; FIRST text match among public/private/protected/internal wins; none →'private'. Modifiers are individual namedmodifierchildren (probed — NO Java-stylemodifierswrapper). Soprotected internal→'protected',private protected→'private'. Called for classes, structs, enums, methods, properties, fields (NOT interfaces — extractInterface never asks). - isStatic (csharp.ts:115) — any
modifierchild with textstatic. - isConst (csharp.ts:127) —
constmodifier → true; elsestaticANDreadonlyboth present → true. Consumed ONLY by extractField's kind gate (tree-sitter.ts:2058-2062) and extractVariable:2546 (moot — see §local_declaration_statement). - isAsync (csharp.ts:140) — any
modifierchild with textasync. WORKS for C# (unlike rust's dead-code isAsync) — probed:asyncis a directmodifierchild of method_declaration. - extractImport (csharp.ts:149) — signature = trimmed full node text
(UTF-16 substring). moduleName = FIRST namedChild of type
qualified_name's text, else FIRST namedChild of typeidentifier's text, else null. Probed shapes and the resulting QUIRKS (all PRESERVE):using System;→ identifier →System.using System.Collections.Generic;→ qualified_name → full dotted text.using static System.Math;→ qualified_name →System.Math(thestatickeyword is an anonymous token — no trace).global using GlobalNs.Thing;→ same as a plain using (theglobalkeyword is anonymous) →GlobalNs.Thing.using Alias = Some.Type<T>;→ the find(qualified_name) hits the TARGET, whose raw text INCLUDES generic args — moduleNameSystem.Collections.Generic.Dictionary<string, int>(probed verbatim).using Short = SomeType;(single-identifier target) → no qualified_name → find(identifier) returns the FIRST identifier, which is thename:field = the ALIAS → moduleNameShort, the targetSomeTypeis lost.- The hook never returns null on real shapes probed; if it did,
tree-sitter.ts:3350 (
if (this.extractor.extractImport) return;) means NO import node at all — mirror that gate.
Hooks ABSENT (the walker must NOT do these): getSignature — C# methods,
constructors and classes carry signature: undefined (only properties/fields
get signatures, built inline by their extractors); getReceiverType (receiver
machinery inert — extractMethod:1799 owner-lookup never runs); resolveName,
recoverMangledName, isMisparsedFunction, isExported (every node's
isExported is undefined; the file node's is false), resolveBody,
classifyMethodNode, extractPropertyName, extraClassNodeTypes,
extractModifiers, synthesizeMembers (no Lombok analogue — confirmed; the
java.rs Lombok section has no C# counterpart), extractBareCall, visitNode
hook, skipBodilessClass (bodiless CLASSES still mint nodes — but see
extractStruct/extractEnum's own body gates), methodsAreTopLevel,
interfaceKind.
The namespace node (extractFilePackage — the biggest structural quirk)
extract() (tree-sitter.ts:531-532) calls extractFilePackage(root) ONCE,
before the walk: it scans root's DIRECT namedChildren for the FIRST
packageTypes node, calls extractPackage (csharp.ts:86 — name field ??
first qualified_name/identifier child; probed: both namespace forms carry a
name: field, identifier or qualified_name), and mints ONE
kind:'namespace' node (name = dotted text, e.g. My.App; id =
generateNodeId(file,'namespace','My.App',line); contains edge from the file
node) which stays PUSHED for the ENTIRE file walk. Consequences (all probed,
all PRESERVE):
- qualifiedNames join with
::but the namespace segment keeps its dots: classWidgetinnamespace My.App→ QNMy.App::Widget, method →My.App::Widget::Build. - visitNode has NO packageTypes branch — namespace_declaration falls
through the whole ladder and its children are visited generically. So:
a NESTED namespace (
namespace Outer { namespace Inner { class Deep } }) leaves NO trace ofInner— Deep's QN isOuter::Deep; a file with TWO top-level namespaces scopes the SECOND one's types under the FIRST's node —namespace Second { class Other }afternamespace Outer {…}→ Other has QNOuter::Otherand a contains edge from theOuternamespace node. - A file-scoped
namespace My.App.Sub;behaves identically (node spans just the declaration line). usingdirectives INSIDE a namespace block are reached by the generic walk and behave exactly like top-of-file ones; ALL import refs and import nodes in a namespaced file hang off the namespace node (nodeStack top), not the file node.isInsideClassLikeNode()(1486) checks the stack TOP against class/struct/interface/trait/enum/module —namespaceis NOT class-like, so top-level types under a namespace extract exactly like file-scope ones.
tree-sitter.ts branches (anchors as of f1ca991)
visitNode dispatch — what each C# node hits
| Node | Branch | Behavior |
|---|---|---|
class_declaration |
classTypes:1005 → classify:1007 → extractClass:1679 | kind class; body walk via declaration_list; skipChildren |
record_declaration |
classTypes → classify | struct keyword child → extractStruct:1869 (record struct); else extractClass. Bodiless record Empty; still mints a class node (no skipBodilessClass; body-walk loop then iterates the record node's own children harmlessly) |
struct_declaration |
structTypes:1059 → extractStruct:1869 | body (declaration_list) required: bodiless struct Fwd; mints NO node (1876 exempts only record_declaration) |
interface_declaration |
interfaceTypes:1054 → extractInterface:1834 | kind interface; NO visibility read; members visited with interface pushed (class-like) — so interface methods/properties become method/property nodes |
enum_declaration |
enumTypes:1064 → extractEnum:1914 | body enum_member_declaration_list required (bodiless → no node); extractInheritance sees base_list → the underlying type : byte emits an extends ref named byte (quirk, §inheritance); enum_member_declaration children → extractEnumMembers:1958 — name field path: ONE enum_member node per member, positioned at the member node (attributes included in its span), values/attributes ignored; non-member children (preproc_*, comment) → visitNode (no-op) |
method_declaration |
methodTypes:1027 → extractMethod:1737 | classifyMethodNode absent → always extractMethod. Gate 1747 passes via class-like (a method_declaration outside a type does not occur in non-erroring C# — top-level void M(){} parses as local_function_statement, probed); bodyless interface/partial signatures mint nodes with no body walk; expression-bodied methods have body: arrow_expression_clause (a real body FIELD, probed) → walked |
constructor_declaration |
methodTypes → extractMethod | name field = the class-name identifier → method node named like the class; returnType undefined; constructor_initializer (: base(args) / : this(args)) is a sibling of the body field → NEVER walked → calls inside initializer args are LOST (probed); expression-bodied ctor body = arrow_expression_clause → walked |
property_declaration (inside class-like) |
propertyTypes:1075 → extractProperty:1986 | property node, then propertyBodies — each accessor's body (get { … }, set => …), then the value: (an expression body's => expr or an = initializer) — walked by visitFunctionBody with the property pushed (calls, instantiates, static reads and fn-ref candidates attribute to the property; accessor/arrow bodies changed 2026-10-04, = initializer 2026-10-05 — previously never walked); a target-typed = new() value instantiates the declared type (extractTargetTypedNew); then scanFnRefSubtree (capture-only, attributed to the class) over the rest (attributes, type) skipping the walked bodies, + skipChildren. §property below |
field_declaration (inside class-like) |
fieldTypes:1084 → extractField:2046 | field/constant nodes per declarator, each declarator walked whole by visitFunctionBody with its field pushed (its = initializer: calls, instantiates, static reads, fn-ref candidates incl. varinit attribute to the field; changed 2026-10-05, previously never walked); a target-typed = new() instantiates the declared type; then scanFnRefSubtree (capture-only, attributed to the class) skipping the walked declarators, + skipChildren. §field below |
local_declaration_statement |
variableTypes:1098 (only reachable at top level — global statements; body locals go through visitFunctionBody instead) | not class-like → extractVariable:2538 → generic fallback (2863-2881) finds no direct identifier/variable_declarator children (the declarator nests inside variable_declaration, probed) → ZERO nodes minted; isClassScopeConstantAssignment (1508) needs node.type assignment → never true. skipChildren=true + scanFnRefSubtree → a top-level var builder = WebApplication.CreateBuilder(args); produces NO node, NO calls ref, NO instantiates — only fn-ref candidates. PRESERVE |
using_directive |
importTypes:1209 → extractImport:3170 | hook (§config) → import node + ONE generic imports ref {fromNodeId: nodeStack top (namespace node if present, else file), referenceName: moduleName, line/col of the directive}; no per-binding emitter (the TS/py/rust/php/ruby ladder at 3197-3234 excludes csharp) |
invocation_expression (top level — global statements) |
callTypes:1248 → extractCall:3684 | fires with caller = file/namespace node; children still visited (no skipChildren) so nested invocations recurse |
object_creation_expression (top level, expression-statement position) |
INSTANTIATION_KINDS:354/1255 → extractInstantiation:4610 | + findAnonymousClassBody:4815 checks for a class_body/declaration_list child — probed: C# object_creation children are [new, type, argument_list, initializer_expression] — the anon branch NEVER fires on non-erroring C# (C# has no anonymous classes; object initializers are initializer_expression). Mirror the check anyway (java.rs:1550 already has it) |
global_statement / expression_statement / await_expression / lambdas / namespace_declaration / preproc_* / global_attribute / delegate_declaration / event_declaration / event_field_declaration / operator_declaration / conversion_operator_declaration / indexer_declaration / destructor_declaration / local_function_statement / type_parameter_constraints_clause |
no branch | fall through → children visited. Consequences (all probed, all PRESERVE): delegates mint NO node; events mint NO node; operators/conversions/indexers/destructors mint NO node but their bodies' calls attribute to the ENCLOSING CLASS (visitNode descends into accessor/body blocks with the class still on top); local functions mint NO node (functionTypes empty — visitFunctionBody:5245 gate never passes) and their bodies' calls attribute to the enclosing method (or file node at top level); where T : IEntity constraint types emit NOTHING (constraints are class-node children outside the body — never visited by extractClass's body loop, and extractCsharpTypeRefs doesn't walk them) |
property_signature / method_signature / impl_item / export/store branches |
TS/rust-only node types | never C# |
Node creation, IDs, qualified names
- createNode (1308): id =
generateNodeId(filePath, kind, name, startRow+1)=`${kind}:${sha256(`${filePath}:${kind}:${name}:${line}`).hex.slice(0,32)}`(tree-sitter-helpers.ts:18). File node id = literalfile:${filePath}(tree-sitter.ts:509). Same-(kind,name,line) ID collisions are routine — dedupe/self-checks compare ID STRINGS (node_idsvec pattern; partial methodspartial void Hook();+partial void Hook() { }are two method nodes on different lines — fine). - resolveBody endLine extension (1329) is a no-op (no hook).
- contains edge from nodeStack top for every created node (1363);
captureValueRefScopeafter every create (1374). - qualifiedName = stack names joined
::(buildQualifiedName:1447; namespacePrefix always empty for csharp — it's cpp-only). No receiver QNs (no getReceiverType). - File node: kind
file, name basename, qualifiedName = filePath, endLine =source.split('\n').length(post-preParse source — identical bytes both arms), isExported false.
extractClass / extractStruct / extractInterface / extractEnum
- extractClass (1679): node = class/record; docstring
(getPrecedingDocstring), visibility, isExported=undefined → createNode(kind
'class') → extractInheritance (1704) → extractCsharpPrimaryCtorParamRefs
(1707) → extractDecoratorsFor (1710, NO-OP for C# — §decorators) → push →
body loop over
declaration_listnamedChildren via visitNode → pop. No synthesizeMembers. EXACT ORDER of the three pre-body calls is emission-order-sensitive. - extractStruct (1869): body gate (record exemption 1876);
createNode('struct') → extractInheritance (1891) → primaryCtor refs (1895) →
body walk only when body exists (bodiless positional
record struct M(…);mints the node, walks nothing). NOTE extractStruct does NOT call extractDecoratorsFor — struct attributes emit nothing anyway. - extractInterface (1834): createNode('interface', {docstring, isExported})
— NO visibility field (never asked); extractInheritance; body loop with
interface pushed. Interface members:
Task<T> Get(int id);→ method node (no body);string Label { get; }→ property node; default-impl methods (arrow or block body) → method node WITH body walk. - extractEnum (1914): body required; createNode('enum', {docstring,
visibility, isExported}); extractInheritance (→ the
: byteextends quirk); body loop: enumMemberTypes children → extractEnumMembers (name-field path: 1960-1963 — oneenum_membernode, NO docstring, NO value), everything else → visitNode. Enum members with attributes: the member NODE's position starts at the attribute_list (probed —[Obsolete] ReadAsInt = 1spans from[).
extractProperty (1986) — property_declaration
docstring; visibility; isStatic (?? false — always a concrete bool). Name =
name field (property_declaration always has one, probed). Type/signature:
isTsJsField false → typeNode = FIRST namedChild whose type is NOT
modifier/modifiers/identifier/accessor_list/accessors/equals_value_clause
(2013-2017); signature = typeText name (the .replace(/^:\s*/,'') is inert
for C#) or bare name. QUIRKS (probed, PRESERVE):
- A property whose declared type is a BARE identifier (
public Widget Parent { get; init; }) finds NO typeNode (identifier is excluded) → signature ="Parent"— identifier-typed properties lose their type in the signature (predefined_typestring, generic_nameList<Foo>, qualified_name, nullable_type etc. all keep theirs). - An expression-bodied property (
public int Computed => MaxItems + 1;) has children [modifier, predefined_type, identifier, arrow_expression_clause (value:field)] → typeNode = predefined_type → signature"int Computed"; the arrow clause is a property body (walked as the property, below). { get; } = new();initializers: thevalue:is the implicit_object_creation_expression itself (no type child) → walked as a body, and extractTargetTypedNew emitsinstantiatesnamed for the declaredtype(below); the accessor_list is excluded from the type scan.
Then extractDecoratorsFor (no-op) and extractTypeAnnotations (2037) →
extractCsharpTypeRefs — the type field IS walked for refs (so public List<Foo> Items emits references List + Foo even though the signature
kept the raw text). The returned node is pushed while propertyBodies
(tree-sitter.ts; csharp.rs property_bodies) are walked: every
accessor_declaration's body field (block or arrow_expression_clause, in
accessor order), then the property's value: whatever it is (an
arrow_expression_clause or an = initializer expression). Each body is
preceded by extractTargetTypedNew(body, type field) (csharp.rs
extract_target_typed_new): when the body IS an
implicit_object_creation_expression it emits ONE instantiates ref from
the property at the new()'s position, named by csharpClassTypeName
(csharp.rs class_type_name): identifier → its text; generic_name → its
identifier (List<Foo> → List); qualified_name / alias_qualified_name
→ recurse into the name field (Ns.Foo<T> → Foo, global::Foo →
Foo, Outer<int>.Inner → Inner); nullable_type → recurse into type
(Foo? → Foo); anything else (predefined/array/tuple/pointer) → nothing.
Only a new() that IS the value counts — (new()), c ? new() : null and
nested new()s emit nothing. The fn-ref scan that follows skips the walked
bodies by node id, so a candidate is captured once — from the property; only
what is left (attribute arguments, e.g. [Attr(Make(H))] → H from the
class) is the class's. Attributes are never walked for calls. (The
classifyMethodNode initializer-walk path at 1031-1047 is TS-only.)
extractField (2046) — field_declaration
- kind (2058-2062):
(java|csharp) && isConst(node)→constant(constfields,static readonlyfields), elsefield. Evaluated once per DECLARATION — all declarators share it. - Declarators: direct
variable_declaratorfilter finds none → the C# wrapper path (2070-2075): thevariable_declarationchild's variable_declarator children (probed: field_declaration → [modifier…, variable_declaration[type field, declarator, declarator…]]). - typeText (2110-2116): first namedChild of the variable_declaration that
isn't modifiers/modifier/variable_declarator/variable_declaration/
marker_annotation/annotation → the
typenode's raw text. - Per declarator: name =
namefield (?? first identifier); signature =`${typeText} ${name}`; createNode(kind, name, decl — the declarator node, so line/col/ID anchor to the declarator); then extractDecoratorsFor(node, id) (no-op) and extractTypeAnnotations(node, id) → extractCsharpTypeRefs on the OUTER field_declaration — which walks the variable_declaration'stypefield (5905-5909) → multi-declarator fields (Foo A, B;) emit the type refs ONCE PER DECLARATOR, each from its own field node. - Then, per created field node (changed 2026-10-05 — initializers used to
emit nothing but fn-ref candidates, attributed to the class): push the
field, extractTargetTypedNew(the declarator's LAST named child, the
variable_declaration's
typefield) (§property above — a target-typedprivate readonly List<Foo> _items = new();instantiatesList), then visitFunctionBody(the whole declarator), pop. The C# declarator has novaluefield — the initializer is its last, unnamed child (probed: [name, expression]; a fixed buffer is [name, bracketed_argument_list]) — so the declarator is walked whole, like VB.NET's: maybeCaptureFnRefs fires on it first (varinit:Del d = Handler;→ candidateHandlerFROM THE FIELD), then the name (inert) and the initializer — calls, instantiates, static reads (Defaults.Name→Defaults) and lambda bodies (no halt:Func<int,int> f = x => Compute(x)→ the field callsCompute) attribute to the field. extractField returns the walked declarator ids and the dispatcher's scanFnRefSubtree skips them, so each candidate is captured once, by the field. (Java's walk of itsvaluefield reports nothing back — its class-level scan still captures those candidates too, unchanged.) - docstring/visibility/isStatic computed once from the outer declaration, shared by all declarators. The PHP property_element and bare-fallback branches (2078-2154) are unreachable for C#.
event_field_declarationis NOT a field_declaration — no nodes (§dispatch).
extractMethod (1737) — method_declaration + constructor_declaration
receiverType = undefined (no hook) → gate 1747 passes via class-like →
name = name field (constructor: the class name; explicit interface impl
void IDisposable.Dispose(): name = Dispose — the
explicit_interface_specifier is a separate child, probed); no
isMisparsedFunction. Node props: docstring, signature: undefined (no
getSignature hook), visibility, isAsync (real), isStatic, returnType
(getReturnType — constructors undefined). Then extractTypeAnnotations (1816 →
extractCsharpTypeRefs: returns-field refs FIRST, then per-parameter type
refs — order matters), extractDecoratorsFor (1819, no-op), push, walk the
body FIELD only (block or arrow_expression_clause), pop. The
receiver-contains lookup (1799-1813) never runs (no receiverType).
extractCall (3684) — the C# paths
Generic else-branch (4312+): func = childForFieldName('function') ?? namedChild(0). C# invocation_expression always has a function: field
(probed). The 4364 member_expression/field_expression branch does NOT match
member_access_expression, so C# calls route as:
func.type === 'member_access_expression'→ the csharp branch (4502-4517): recv =expressionfield, methodName =namefield text.- recv is
invocation_expression(chained callFoo.Create(1).Bar()): innerFunc = recv'sfunctionfield; innerCallee = innerFunc raw text with/\s+/gstripped; calleeName =`${innerCallee}().${methodName}`(empty innerCallee → bare methodName). C# re-encodes EVERY call-receiver chain — no capitalization gate (unlike kotlin/scala):GetThing().Bar()→GetThing().Bar; multi-lineFoo.Create(1)\n .Bar()→Foo.Create().Bar(whitespace stripped on the INNER only). - recv anything else → calleeName = RAW full text of the
member_access_expression (getNodeText — UTF-16 substring, whitespace
and newlines INCLUDED). Probed consequences, all PRESERVE:
this.Run(x)→this.Run(SKIP_RECEIVERS never applies — the 4400 set is in the branch C# doesn't take);base.Method()→base.Method(recv is the anonymous-tokenbasevia the field — childForFieldName returns it);_repo.Save(x)→_repo.Save;builder\n .Services\n .AddSingleton<IRepo, Repo>()→ the full multi-line textbuilder\n .Services\n .AddSingleton<IRepo, Repo>(generic args included, newlines included); literal receivers are NOT filtered —"lit".ToUpper()→"lit".ToUpper(LITERAL_RECEIVER_TYPES is consulted only in the non-C# branch);p!.Force()→p!.Force; 2-hop reads keep everything.
- recv is
func.type === 'scoped_identifier'(4499) — never C# (no::node).- else → calleeName = raw func text: bare
Helper()→Helper; generic invocationGeneric<int>(local)→ func = generic_name →Generic<int>kept verbatim (the template strip at 4542 is c/cpp-gated);nameof(Widget)→ func = identifiernameof→ acallsref namednameof(probed — nameof is a plain invocation in this grammar); conditional accessrequest?.Method()→ func = conditional_access_expression → raw textrequest?.Method; delegate parens(myDel)(x)→ func = parenthesized_expression → text(myDel)→ the conversion regex at 4530 (/^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$/) FIRES →myDel(the one shared normalization C# actually hits — port it).
Post-processing: template strip (4542) and cpp fn-ptr fan-out (4556) are
c/cpp-gated — NOT csharp. Final push: one calls ref {fromNodeId = nodeStack
top, referenceName, line = call startRow+1, column = call startColumn
(UTF-16)}. Children of the invocation are still traversed afterward (both
walkers), so inner calls of a chain emit separately: Foo.Create(1).Bar() →
refs Foo.Create().Bar AND Foo.Create. typeof(…), default(…), casts,
is/as patterns, element access, switch expressions, ??/ternary,
interpolated-string interpolations, tuple expressions: no dedicated handling —
plain recursion reaches any invocation_expression inside them (probed shapes
in probe3-out.txt).
extractInstantiation (4610) — object_creation_expression
ctor = constructor/type(hit)/name/namedChild(0) fields → the type: field
(probed). Generic path: raw text → strip from first < (new List<Foo>() →
List) → vbnet paren strip (inert) → keep last ./:: segment
(new Ns.Foo() → Foo) → trim → instantiates ref at the
object_creation_expression's position. Fires from visitNode:1255 (expression
statements at top level) AND visitFunctionBody:5145. QUIRKS, PRESERVE:
implicit_object_creation_expression (new()) and
anonymous_object_creation_expression (new { X = 1 }) and
array_creation_expression (new Widget[10]) are NOT in INSTANTIATION_KINDS
→ no instantiates refs (a target-typed new() in a body — Widget c = new();, an argument, a return — is invisible); the one exception is a
new() that IS a field's or property's initializer, which
extractTargetTypedNew names for the declared type (§property, §field).
Object/collection initializer args and new[] { Mk() } contents still
recurse to their own calls. Top-level var w = new Widget(); emits nothing
at all (§local_declaration_statement).
extractStaticMemberRef (4750) — csharp ∈ STATIC_MEMBER_LANGS (345)
Called for EVERY node in visitFunctionBody (5218) — body walker only (never
visitNode, so top-level statements emit no static refs; field and property
initializers ARE body-walked, so = Defaults.Name reads attribute to the
member). Node gate: MEMBER_ACCESS_TYPES (323) contains
member_access_expression. Skip when the access IS a call's callee (4772-4779:
parent ∈ callTypes && callee.startIndex === node.startIndex — so
Console.WriteLine(…)'s access is skipped but DoThing(Constants.MAX)'s
argument is not). recv = expression field (hit) → must be type
identifier/type_identifier/simple_identifier/name/scoped_type_identifier —
for C# only identifier occurs — and text must match /^[A-Z][A-Za-z0-9_]*$/
→ references ref {name = recv text, line/col of RECV}. Probed:
ReadType.ReadAsDouble → ref ReadType; Outer.Inner.DEEP → the outer
access's recv is a member_access (skip) but the INNER Outer.Inner emits ref
Outer; lowercase receivers (u.Age, builder.Services) → nothing; this.X
→ recv type this → nothing.
Decorators/attributes — NO-OP for C# (probed, PRESERVE)
extractDecoratorsFor (4897) accepts node types decorator/annotation/
marker_annotation/attribute (+solidity modifier_invocation). C# attributes are
attribute_list nodes containing attribute children, and attribute_list
is (a) a DIRECT child of the declaration (class/method/enum-member — probed),
so the direct-children scan sees attribute_list (not in the set → skipped,
and its attribute grandchildren are never reached); (b) never inside a
modifiers wrapper (C# has none); (c) as a PRECEDING sibling — only for
global_attribute, which is never a decorated declaration. Net: zero
decorates refs for C#. The walker needs no decorator code — but keep the
call sites' ORDER slots (they emit nothing). Corollary (contrast rust): an
attribute between a doc comment and its declaration does NOT break the
docstring chain — the attribute_list is a child of the declaration, so the
comment remains previousNamedSibling (probed: /// doc + [Attr] + method →
docstring found).
Inheritance — extractInheritance (5291), base_list branch (5577-5593)
The ONLY child type that matters for C# types is base_list (extends/
implements/superclass/base_class_clause etc. never occur). The branch iterates
ALL namedChildren of base_list with NO type filter and emits ONE
extends ref each (C# never emits implements from extraction — the
comment at 5574-5576 documents the deliberate conflation; interface-vs-class
splitting happens at resolution/synthesis):
identifier→ text (BaseItem,IWidget).generic_name→ its FIRSTidentifierchild's text (ClientBase<Widget>→ClientBase) — but position = the generic_name node.qualified_name→ FULL dotted text (Sys.ICloneable— no last-segment reduction).- QUIRK (probed): a C# 12 class primary-ctor base
class Svc(…) : Base(repo), IThingparses base_list children as [identifier(Base), argument_list((repo)), identifier(IThing)] → emits extendsBase, extends(repo)(!), extendsIThing. The(repo)ref is garbage that never resolves — PRESERVE it. - QUIRK (probed): a RECORD's base-with-args wraps differently —
record UserDto(…) : BaseDto(Name), IThing→ base_list children are [primary_constructor_base_type, identifier] → emits extendsBaseDto(Name)(full text, argument list included) +IThing. - QUIRK (probed): an enum's underlying type
enum ReadType : byteputs predefined_type(byte) in base_list → extends refbyte(likewise: int, etc.).
extractInterface/extractStruct/extractEnum all route through the same branch. The 5652 recursion (field_declaration_list/class_heritage) never matches C#.
Type-annotation references — csharp ∈ TYPE_ANNOTATION_LANGUAGES (5753)
extractTypeAnnotations (5788) short-circuits into extractCsharpTypeRefs (5798-5801) — the generic params/return/type_annotation path below it never runs for C#. extractCsharpTypeRefs (5893):
- directType =
typefield ??returnsfield → walkCsharpTypePosition (methods:returns; properties:type; constructors: neither). - a
variable_declarationchild'stypefield (fields) → walk. parametersfield → perparameterchild (type filter — probed:bracketed_parameter_listof indexers never reaches here since indexers aren't dispatched) → itstypefield → walk.
walkCsharpTypePosition (5955): predefined_type → nothing; identifier →
references ref unless name ∈ BUILTIN_TYPES (5768 — port the WHOLE set
verbatim: the Java/C# row int/long/short/byte/float/double/char, plus
cross-language rows — a C# type named String/Boolean/error IS suppressed
(Scala/Go rows) while Task/List/IRepo/dynamic are emitted —
probed: dynamic parses as identifier → a references ref named dynamic);
qualified_name → LAST .-segment as referenceName (position = the whole
qualified_name node) unless builtin; tuple_element → its type field only
(element names gated out); everything else (generic_name, nullable_type,
array_type, pointer_type, tuple_type, ref_type, scoped_type, …) → recurse ALL
namedChildren — so List<Foo> emits List AND Foo; Task<List<Foo>> emits
Task, List, Foo; Foo?/Bar[] unwrap; generic args inside
type_argument_list all surface. Called from extractMethod (params+returns),
extractProperty (type), extractField (per declarator), and
extractCsharpPrimaryCtorParamRefs.
extractCsharpPrimaryCtorParamRefs (5938) — from extractClass:1707 and
extractStruct:1895 (NOT interface/enum): finds a parameter_list child BY
TYPE (the primary ctor hangs as an unnamed-field child — probed on class,
record, record struct), walks each parameter's type field →
references refs from the TYPE node (class Svc(IRepo repo, ICache cache) →
refs IRepo, ICache). Extension-method this params ride the normal
parameter path (the this is a modifier child; type field still walked).
Body-local type annotations: NONE. visitFunctionBody:5230's
variable_declarator branch calls extractVariableTypeAnnotation (6074), which
looks for a type_annotation child — C# has no such node (the type is a field
of variable_declaration) → structurally inert. Cast/typeof/is/as/catch types
emit nothing (§extractCall).
Docstrings (tree-sitter-helpers.ts:95)
/// XML doc comments and // comments are comment nodes (probed);
consecutive preceding named siblings accumulate (unshift → source order);
cleanCommentMarkers strips ^\/\/[/!]?\s? per line — so the stored docstring
is the XML markup minus slashes (<summary>Doc line.</summary>).
DOCSTRING_WRAPPER_TYPES contains no C#-relevant wrappers (variable_declaration
/variable_declarator ARE in the set but no C# docstring call site passes
those nodes — extractField/extractProperty pass the outer declaration).
Block /** */ comments are also comment nodes and /*-stripped. Attributes
don't break the chain (§decorators). **Reuse docstring::preceding_docstring
js_multiline_stripas-is** — the CRLF^-after-\rsemantics (#1329) are already handled there; C#'s XML docs on CRLF checkouts hit exactly that path. Docstring recipients: class/record/struct/interface/enum/method/ctor/property/ field (NOT enum members, NOT the namespace node, NOT imports).
Value-reference edges — csharp ∈ VALUE_REF_LANGS (401)
Port the full machinery (crib java.rs:1246 flush_value_refs — Java's cases
are csharp's minus/plus the declarator switch): CODEGRAPH_VALUE_REFS=0 kill;
MAX_VALUE_REF_NODES=20_000 caps the prune scan and each reader scan;
isGeneratedFile skip.
- Targets (captureValueRefScope:735): created nodes of kind
constant/variable, name length ≥3 AND
/[A-Z_]/, parent scope id startingfile:/class:/module:/struct:/enum:. For C# that meansconst/static readonlyfields (kind constant) inside class:/struct: parents — namespace: parents do NOT qualify (deliberate: a namespace-scoped… doesn't exist in C# anyway), and C# mints novariablenodes at all. Last same-named target wins the map; counts accumulate per name. - Reader scopes: every function/method/constant/variable node — for C#: methods (ctors included) + constant fields.
- Shadow prune (803-878): DFS the whole tree; C#-live cases of the declarator
switch:
variable_declarator(818) → bump(namedChild(0)) — counts field declarators (the targets) AND method-body locals AND top-level-statement declarators (the shadow sources);property_declaration(856) fires but is inert — no variable_declaration child, and firstSimpleIdentifier (tree-sitter.ts:261) huntssimple_identifierwhich C# lacks → bump(null). All other cases (const_item, assignment, init_declarator, …) are other grammars' node types — a C# tree never contains them. After the scan: declCount > fileScopeCount → target deleted (a method-localvar MaxItems = …shadows the class const). - Emission (880-930): per reader scope, stack-DFS its node subtree
(push-children-in-order / pop-last → REVERSE-index traversal order — mirror
exactly, dedupe is first-wins per (scope,target)); each
identifierwhose text maps to a target, target ≠ self, name ≠ scope's own name → EDGE {source: scopeId, target: targetId, kind:'references', metadata:{valueRef:true}}. Theconstant/name/simple_identifierreader types are other-language rows — inert but harmless to mirror. Dart/Pascal sibling-body pull (891) is inert. Flush order: fn-ref candidates flush FIRST (unresolvedReferences), then value refs (edges) — extract():538-539.
Function-as-value capture (#756) — CSHARP_SPEC (function-ref.ts:250)
idTypes={identifier}; dispatch: argument_list→args,
assignment_expression→rhs(field right) — covers +=/-= event
subscription (compound assignment is still assignment_expression, probed),
initializer_expression→list (object/collection initializers),
variable_declarator→varinit (NO field — the last-named-child path).
layers: argument→null (descend named children). special:
{member_access_expression}. No unwrap/ungatedModes/addressOfOnly.
- varinit mechanics (function-ref.ts:471-486): C# variable_declarator has no
valuefield → value = LAST namedChild, require ≥2 namedChildren and value ≠ name child (probed:f = x => Compute(x)→ [name, lambda_expression]; initializer-less_repo→ 1 child → skipped). Destructuring gate (object/array/tuple/struct_pattern) never matches C#. - rhs param-storage skip (430-444): LHS last-identifier == RHS text → skip
(
this.status = status).button.Click += OnClick→ LHS last-nameClick≠OnClick→ candidate. - normalizeValue: bare
identifier→ candidate (NAME_STOPLIST = this/self/super/null/nil/true/false/undefined/new/NULL/nullptr/None — notevalue,args, andbaseare NOT stoplisted;base/thiscan't become candidates anyway, they parse as non-identifier tokens);member_access_expression→ normalizeSpecial (738-746): name field required; receiver must bethis— expr field typethis_expressionORthis(the vendored grammar yields the anonymous-tokenthisvia the field — probed; the text-prefix fallback exists for field-less shapes) → candidate = the BARE member name (this.HandleThing→HandleThing— NOTthis.-prefixed, unlike TS/Java; it then gates against definedHere).C.StaticHandler(type receiver) → NOTHING. Lambdas/anonymous methods → nothing (not idTypes/special). - Capture fires from visitNode:990, visitFunctionBody:5137, and
scanFnRefSubtree (property/field/variable declarations + top-level
statements, depth ≤12, capture-only — skipping the subtrees the body
walker already went through: property bodies/initializers and field
declarators, whose candidates are the member's). scanFnRefSubtree's halt
list (tree-sitter.ts:606-612) includes the literal type
lambda_expression— which IS C#'s lambda node — so at depth>0 the scan STOPS at a lambda it reaches (a top-level statement's), whileanonymous_method_expression(delegate() { … }) is NOT in the list and is scanned through. Body-walked lambdas — methods, accessors, and since 2026-10-05 field/property initializers (Action A = () => Register(H);→ candidateHfromA) — are unaffected (visitFunctionBody recursion has no halt — capture fires per node). - Flush gate (flushFnRefCandidates:639): generated-file skip;
this.-prefixed names skip the gate (C# never produces them — its this-forms are bare); otherwise name ∈ definedHere ∪ importedNames. definedHere = same-file function/method node NAMES — for C#: methods + constructors (= class names); local functions are NOT nodes so their names don't gate in. importedNames: fromimports-kind refs — C# using-refs are dotted → QUALIFIED_IMPORT (./\separators) admits the LAST segment (using MyApp.Helpers;→Helpers), single-segmentusing Xunit;→Xunit; the alias-quirk moduleNames ride the same rules. Survivors dedupe on${fromNodeId}|${name}→function_refrefs.
Closure-collection / other passes
- csharp ∉ CC_LANGUAGES (callback-synthesizer.ts:77 — swift/kotlin only): the closure-collection synthesis pass ignores C#. Nothing to port; nothing to regress.
- Local-variable receiver inference (#1108) and typed-param receivers
(#1125/#1129/#1130) are RESOLUTION-side: name-matcher.ts:1164 has the csharp
regex rows (
= new Logger()andLogger lg;-shaped declared types, covering params via the[=;,)]tail). They consume therecv.Methodmember-access text extraction emits — the walker just has to reproduce the emission shapes above. - Chained-call resolution (#645/#608/#750): csharp is in matchDottedCallChain
(name-matcher.ts:2156) — consumes the
inner().Methodencoding + declared return types (getReturnType). Resolution-side; extraction contract pinned above. - LANGUAGE_FAMILY: csharp+razor = 'dotnet' (name-matcher.ts:149) — the razor extractor (T3, TS-side) resolves against C# nodes; unaffected by the port.
java.rs skeleton mapping (what to crib, what diverges)
Crib directly (same chassis, adjust node kinds): Scope/NodeMeta/Extra
structs, create_node + contains + capture_value_ref_scope, push_ref
helpers, visibility/is_static/is_const modifier scans (java.rs:405-447 — but
C# reads bare modifier children, java reads a modifiers wrapper),
extract_field's wrapper-declarator loop (java.rs:704 — C# adds the
variable_declaration indirection), extract_enum/extract_enum_members,
extract_static_member_ref (java.rs:938 — swap field_access →
member_access_expression + expression field), value-ref flush
(java.rs:1246 — swap the declarator cases for variable_declarator +
inert property_declaration), fn-ref capture/flush (java.rs:1120-1243 — swap
JAVA_SPEC for CSHARP_SPEC incl. bare identifiers as idTypes and the
this-receiver special), find_anonymous_class_body (java.rs:1550 — keep,
unreachable), the package/namespace node (java.rs package_declaration ↔
extractFilePackage semantics — but C#'s is FIRST-CHILD-ONLY with the
second-namespace and nested-namespace quirks, and the namespace node kind is
namespace in both). DIVERGES from java.rs — do NOT copy: Lombok synthesis
(java.rs:1339-1548 — no C# counterpart); method_invocation this.field
unwrap + Java's chain encoding (C#'s csharp-branch semantics at
tree-sitter.ts:4502 differ: raw full text, no SKIP_RECEIVERS, unconditional
re-encode); annotations→decorates (C# emits none); anonymous classes
(<T$anon@line> — unreachable for C#); signature_of (C# methods have NO
signature); Java's type_list inheritance (C# is the unfiltered base_list
loop + its three quirk shapes); getVisibility default (private for C#).
csharp-NEW (no java.rs precedent): extractCsharpTypeRefs/walkCsharpTypePosition
(the type-ref engine), extractCsharpPrimaryCtorParamRefs, the
extractProperty branch (java has no propertyTypes), classifyClassNode
record→struct, the extractImport alias/static/global quirks, the
local_declaration_statement zero-emission path, preproc_* passthrough.
Frameworks that consume C# extraction artifacts (stay TS-side)
aspnetResolver (resolution/frameworks/csharp.ts) — detect: .csproj
AspNetCore refs / Program.cs / Startup.cs / controller-source scan.
extract()(csharp.ts:133 — regex over RAW source via stripCommentsForRegex, runs in extractFromSource AFTER either arm — NO port needed): emitsroutenodes with id`route:${filePath}:${line}:${METHOD}:${path}`(NOT hashed), kindroute, name`${METHOD} ${path}`, qualifiedName`${filePath}::route:${path}`, languagecsharp; plus onereferencesref per handler FROM the route node (framework refs DO carry filePath+language, unlike extraction refs). Covers[HttpGet]-family attributes (bare or with path, class-level[Route]prefix joined) and minimal-API.MapGet("/p", handler)chains. Because it reads RAW source (not extraction output), the port cannot break it — but parse-worker routing (§arch-2) means its presence forces the decoded path.resolve()consumes plain unresolved refs by suffix (Controller/Service/Repository/ViewModel/Dto) + directory conventions — extraction-shape-agnostic beyond the referenceName spellings pinned above.
Parity mechanics (all have bitten before — be explicit)
- Emission ORDER per file (rowid-order-sensitive downstream): file node → namespace node (when present) → walk order exactly as the dispatch table above (per declaration: inheritance refs → primary-ctor refs → [decorators: nothing] → body/member walk; methods: type-annotation refs (returns, then params) before the body's calls; fields: per-declarator node → its type refs → next declarator) → flushFnRefCandidates (refs) → flushValueRefs (edges, reverse-index DFS order). Refs carry NO filePath/language (the store denormalizes) — the wire contract is exactly extractFromSource's return.
- generateNodeId inputs: (filePath, kind, name, startRow+1) — name is the EXACT string pinned per branch above (e.g. constructors use the class name; enum members the name-field text; imports the moduleName with its generic-args quirk; the namespace node the dotted package text).
- UTF-16 everywhere: positions/columns (textutil::col16 — verified against
the wasm: emoji-preceded call at column 37 in UTF-16 units), every
getNodeText slice (raw substring — multi-line callee texts keep newlines),
the import signature trim. No
.slice(0,100)truncations fire for C# (that path is the variable-initializer signature, unreachable), but signatures built from raw type text are UTF-16 substrings. - CRLF hazards: the ONLY multiline regexes on the C# path are (a) the
preParse blanking — TS-side, hoisted, never ported (§arch-1) — and (b)
docstring cleaning — already CRLF-correct via
js_multiline_strip(docstring.rs, #1329). The walker itself must add NO(?m)/^/$regex without the same scrutiny. getReturnType/extractImport regexes are single-line and anchor-free except/^[A-Za-z_]\w*$/(whole-string match — useRegex::is_matchon the full string,\A…\zsemantics). - Defer policy: per-file
has_error()→defer:signal (kernel receives BLANKED source; incidence per §arch-6). The one-slot defer memo already covers csharp (preParse hoist listed it from day one). - MAX_FILE_SIZE + generated-file skips: shared/orchestrator-side; nothing csharp-specific (isGeneratedFile gates fn-ref flush + value refs — mirror the calls, the patterns live in textutil.rs).
- Node-ID collisions: compare ID strings in fn-ref/value-ref self-checks
(
node_idsvec pattern — java.rs already does this).
Gates (per plan §5, no exceptions)
- Torture fixture
Torture.cs(+ CRLF variant derived in-memory, normalization-proof) pinning at minimum: block namespace + file-scoped namespace file + two-namespaces-in-one-file (second nests under first) + nested namespace (inner leaves no trace); class with base_list (identifier + generic_name + qualified_name), attributes (NO decorates),///docstring incl. doc-over-attribute; record class / record struct / readonly record struct (+ positional params → primary-ctor type refs) / bodilessrecord Empty;(node) /record UserDto(…) : BaseDto(Name), IThing(extendsBaseDto(Name)full-text quirk); C#12 class primary ctor with base args (extendsBase+(repo)+IThingquirk); bodilessstruct Fwd;(NO node); interface with bodyless method + property + default-impl arrow method; enum with: byte(extendsbytequirk), attributed member, valued members; const + static-readonly (→constant)- multi-declarator + instance fields (signatures
Type name), field initializers (→ the field:= new() { TargetCb }instantiates the declaredListand the list candidate is the field's);protected internal(→ protected); property shapes: predefined-type, bare-identifier type (signature loses type), generic type, expression-bodied (=>calls → the property),{ get; } = new();(→ the property, instantiates the declaredWidget),{ get; } = () => Register(H)(the lambda's call and candidate → the property), accessor bodies with calls (→ the property); event_field_declaration + event_declaration with add/remove bodies (no nodes; accessor calls → class); operator + conversion operator + indexer + destructor (no nodes; body calls → class); constructor with: base(Compute())(initializer calls LOST) + expression-bodied ctor (walked); explicit interface impl (Disposebare name); local function (no node, calls → enclosing method); calls: bare, generic (Generic<int>verbatim),this.Run/base.Method(prefixes kept),_repo.Save, multi-line fluent chain (raw newlines), literal receiver ("lit".ToUpperemitted),?.(rawrequest?.Method),p!.Force, chainedFoo.Create(1).Bar()(re-encode + inner both) +GetThing().Bar(),(myDel)(x)(conv regex →myDel),nameof(Widget)(calls refnameof);new Widget(…) { … }(instantiates + initializer calls) +new Ns.Foo<T>()(strip both) + bodynew()/new { }/new Widget[10](all NOTHING); static value reads (ReadType.ReadAsDouble→ReadType;Outer.Inner.DEEP→Outer; skip-as-callee; lowercase skip); type refs: params (nullable/array/tuple-element/generic/qualified/dynamic), returns (Task<List<Foo>>→ refs Task+List+Foo but returnType undefined), suppressed builtins (String,int);where T : IEntity(NO refs); using forms: plain/dotted/static/global/alias-to-qualified (generic-args moduleName)/alias-to-identifier (alias-name quirk) + using inside namespace (refs from namespace node); top-level-statements file:var x = F();(NOTHING), expression-statement calls (caller = file), top-level local function + trailingpartial class Program; fn-refs:Register(HandleThing),Register(this.HandleThing)(bare name),Register(C.StaticHandler)(nothing),Click += OnClick, initializer_expression list, varinit (Action g = () => …no candidate;Del d = Handler;candidate — from the method for a local, from the field for a field initializer),this.x = xparam-storage skip; value-refs: const target + reader methods + avar MaxItems = …local shadow (prune) +static readonlymulti-target; preprocessor:#region/#endregion/#pragma/#nullable/#defineinterleaved in class + enum bodies (parse-through, no emissions), and the#if/#else/#endif-in-enum shape as the DEFER fixture pre-blank — plus an intentionally-erroring file (e.g. an unclosed brace or the both-branches-kept interface shape) asserting thedefer:signal; LINQ query + interpolated/raw/verbatim strings + switch expression + collection expressions (calls inside all reach the graph).
- multi-declarator + instance fields (signatures
- Parity sweeps (
scripts/kernel-parity.mjs, ORDER-sensitive full-object): serilog (small), Newtonsoft.Json (medium), jellyfin (large) — survey clones live at/private/tmp/claude-501/-Users-colby-Development-CodeGraph-codegraph/765a9532-0a92-43de-8d50-7c8ca1cb345c/scratchpad/{serilog,Newtonsoft.Json,jellyfin}(re-clone fresh if gone). Expected deferrals per §arch-6 (2.31% / 3.28% / 0.05%) — default--max-deferral 0.1, NO c/cpp 0.5 exemption; a double-digit rate means a broken walker, not grammar reality. Then full-init dump-diffs byte-identical (kernel arm vsCODEGRAPH_KERNEL=0,scripts/dump-graph.mjs,cmp) on the same three. jellyfin is an ASP.NET detect() hit — its per-file path is decoded either way (§arch-2); the dump gate is arm-vs-arm and unaffected. - Suite: new
__tests__/kernel-csharp-parity.test.ts(torture + CRLF + defer fixtures, strict full-object compare,CODEGRAPH_KERNEL_EXPECT=1aware); add the csharp row to__tests__/kernel-grammar-parity.test.ts(cratetree_sitter_c_sharp::LANGUAGEvs vendored wasm, id-by-id); existing pinned behavior lives in__tests__/extraction.test.ts(describe blocks at 1342C# Extraction, 2548C# imports, 8924C# records) and__tests__/resolution.test.ts— full suite green ×2 withCODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += csharp(src/extraction/kernel/index.ts:37) only after ALL of the above; changelog rides the existing kernel entry.- Post-route perf sanity: §arch-2 — measure library repos (serilog/ Newtonsoft.Json) for the raw-buffer win; ASP.NET repos take the decoded path by design.
Fixtures to build (inventory)
__tests__/fixtures/kernel-parity/Torture.cs— the full pin list above (one file is fine; keep the two-namespace case in it).TortureFileScoped.cs— file-scoped namespace + records + primary ctors + top-levelusingalias quirks (a file-scoped-ns file cannot host top-level statements — keep those separate).TortureTopLevel.cs— top-level statements program (global statements, local function, trailing type decl).- CRLF variants of all three — derived in-memory in the test (the kernel-tsjs-parity pattern), never checked in as CRLF bytes.
TortureDefer.cs— the#if-in-enum ERROR shape with blanking DISABLED in the test arm… no: blanking is hoisted and always on — instead use a both-branches-kept interface shape (the serilog ILogger class) or a truly malformed tail that still errors POST-blank, asserting kerneldefer:+ wasm-fallback byte-parity through extractFromSource.- In-memory micro-fixtures inside the parity test for: the alias-import
moduleName quirks, the
(repo)extends quirk, the enum: bytequirk,nameofcalls ref,this./base.callee prefixes, and the property-signature bare-identifier-type loss — cheap unit pins that make a future grammar bump scream early.