diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d9..44144e160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. +- In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. +- In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. + ## [1.6.2] - 2026-10-03 diff --git a/__tests__/csharp-property-accessors.test.ts b/__tests__/csharp-property-accessors.test.ts new file mode 100644 index 000000000..b77d8549d --- /dev/null +++ b/__tests__/csharp-property-accessors.test.ts @@ -0,0 +1,91 @@ +/** + * C# property accessor bodies belong to the property. `get { … }`, + * `set { … }`, `get => …` and an expression-bodied `=> …` property used to + * be skipped by both extractors, so the calls, instantiations and static + * reads written there were lost and a getter's callee looked unused. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'Helper.cs': `public static class Helper { + public static int Compute(int n) => n; + public static void Store(int n) {} + public static int Arrow() => 1; + public static int GetA() => 1; + public static void SetA(int v) {} +} +public class Widget {} +public static class Defaults { public const string Name = "x"; } +`, + 'Box.cs': `public class Box { + private int _x; + public int Value { + get { return Helper.Compute(_x); } + set { Helper.Store(value); _x = value; } + } + public int Arrow => Helper.Arrow(); + public int Both { get => Helper.GetA(); set => Helper.SetA(value); } + public Widget Made { get { return new Widget(); } } + public string Label { get { return Defaults.Name; } } + private void Handle(int v) { } + public System.Action Handler { get { return Pick(Handle); } } +} +`, +}; + +describe('C# property accessor bodies', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cs-accessors-')); + for (const [rel, content] of Object.entries(FILES)) fs.writeFileSync(path.join(root, rel), content); + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('are walked as the property (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const member = (qualifiedName: string): Node => { + const node = graph.getNodesInFile('Box.cs').find((n) => n.qualifiedName === qualifiedName); + expect(node, qualifiedName).toBeDefined(); + return node!; + }; + const targets = (node: Node, kind: string): string[] => + graph + .getOutgoingEdgesFrom([node.id]) + .filter((e) => e.kind === kind) + .map((e) => graph.getNode(e.target)!.qualifiedName) + .sort(); + + expect(targets(member('Box::Value'), 'calls')).toEqual(['Helper::Compute', 'Helper::Store']); + expect(targets(member('Box::Arrow'), 'calls')).toEqual(['Helper::Arrow']); + expect(targets(member('Box::Both'), 'calls')).toEqual(['Helper::GetA', 'Helper::SetA']); + expect(targets(member('Box::Made'), 'instantiates')).toEqual(['Widget']); + expect(targets(member('Box::Label'), 'references')).toContain('Defaults'); + // A method passed as a value inside an accessor is the property's + // reference, captured once — not the class's as well. + expect(targets(member('Box::Handler'), 'references')).toContain('Box::Handle'); + expect(targets(member('Box'), 'references')).not.toContain('Box::Handle'); + expect(targets(member('Box'), 'calls')).toEqual([]); + }); +}); diff --git a/__tests__/kernel-csharp-parity.test.ts b/__tests__/kernel-csharp-parity.test.ts index f26f5072d..3d09c19d9 100644 --- a/__tests__/kernel-csharp-parity.test.ts +++ b/__tests__/kernel-csharp-parity.test.ts @@ -8,7 +8,8 @@ * * - Torture.cs — block namespace + nested/second-namespace quirks, * base_list shapes, records, properties (incl. the bare-identifier - * signature loss and never-walked accessor bodies), fields/constants, + * signature loss, accessor bodies walked as the property and + * never-walked initializers), fields/constants, * events/operators/indexer/destructor (no nodes, calls → class), ctor * initializer hole, explicit interface impl, local functions, the call * zoo (raw member-access texts, chained re-encode, `(myDel)(x)` conv, @@ -167,6 +168,23 @@ describe.skipIf(!kernelBuilt)('kernel C# extraction parity', () => { source: 'public record Empty;\n', minNodes: 2, }, + { + // Accessor bodies and `=> expr` are walked as the property (calls, + // instantiations, static reads, fn-ref candidates); an `= initializer` + // is only scanned for candidates, attributed to the class. + name: 'property bodies are walked as the property; initializers only scanned', + source: [ + 'public class C {', + ' void H(int v) { }', + ' public Action P { get { return Make(H); } set { Register(H); } }', + ' public int Q => Run(H);', + ' public Action R { get; } = Wrap(H);', + ' public int S { get => Calc.Max; init => Store(new Widget()); }', + '}', + '', + ].join('\n'), + minNodes: 7, + }, ]; for (const m of MICROS) { diff --git a/__tests__/vbnet-member-bodies.test.ts b/__tests__/vbnet-member-bodies.test.ts new file mode 100644 index 000000000..8c280a5b5 --- /dev/null +++ b/__tests__/vbnet-member-bodies.test.ts @@ -0,0 +1,209 @@ +/** + * VB.NET member bodies the extractor used to skip: + * + * - A `Structure` lists each member as its own `body` field, so reading the + * field returned only the first member: SCrawler's `UserMedia` indexed its + * nested `States` enum (declared first) and nothing else, and a structure + * that opened with a field indexed no members at all. + * - A property's `Get` / `Set` blocks, its `= initializer` and its + * `As New T` were never walked, nor a `Custom Event`'s accessors. + * - A field's `= initializer` and `As New T` were never walked. + * + * The calls, instantiations and reads in those places belong to the member + * that declares them. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vb-bodies-')); + const files: Record = { + 'Helper.vb': `Public Class Helper + Public Sub New(ByVal n As Integer) + End Sub + Public Shared Function Compute(ByVal n As Integer) As Integer + Return n + End Function + Public Shared Sub Store(ByVal n As Integer) + End Sub + Public Shared Function Seed() As Integer + Return 1 + End Function + Public Shared Function Format(ByVal o As Object) As String + Return "" + End Function + Public Shared Sub Log(ByVal s As String) + End Sub + Public Shared Sub Attach(ByVal h As EventHandler) + End Sub +End Class +`, + 'Widget.vb': `Public Class Widget +End Class +`, + 'UserMedia.vb': `Public Structure UserMedia + Public State As States + Private Count As Integer + Public Enum States + Unknown + Downloaded + End Enum + Public ReadOnly Property Name As String + Get + Return Helper.Format(State) + End Get + End Property + Public Sub New(ByVal s As States) + State = s + Helper.Log("created") + End Sub + Public Function IsDone() As Boolean + Return State = States.Downloaded + End Function +End Structure +`, + 'Box.vb': `Public Class Box + Private _x As Integer + Private _items As New Widget + Private _helper As Helper = New Helper(1) + Private Shared ReadOnly Def As Integer = Helper.Compute(3), Other As Integer = Helper.Seed() + Public Property Value As Integer + Get + Return Helper.Compute(_x) + End Get + Set(ByVal v As Integer) + Helper.Store(v) + _x = v + End Set + End Property + Public Property Auto As Integer = Helper.Seed() + Public ReadOnly Property Lst As New Widget + Public Custom Event Changed As EventHandler + AddHandler(ByVal value As EventHandler) + Helper.Attach(value) + End AddHandler + RemoveHandler(ByVal value As EventHandler) + End RemoveHandler + RaiseEvent(ByVal sender As Object, ByVal e As EventArgs) + End RaiseEvent + End Event +End Class +`, + // The 1.6.2 receiver gate must hold in the newly walked places too: + // `Me.Panel.Controls.Add(…)` and `New System.Drawing.Size(…)` name no + // project member. + 'Collections/DataColorCollection.vb': `Friend Class DataColorCollection + Friend Sub Add(ByVal Item As Object) + End Sub +End Class +`, + 'Editors/UsersInfoForm.vb': `Friend Class UsersInfoForm + Private Enum EComparers + Name + Size + End Enum +End Class +`, + 'MainForm.vb': `Public Class MainForm + Private ReadOnly DefaultSize As System.Drawing.Size = New System.Drawing.Size(184, 25) + Public ReadOnly Property Ready As Boolean + Get + Me.Panel.Controls.Add(Me.Button1) + Return True + End Get + End Property +End Class +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +function member(file: string, qualifiedName: string): Node { + const node = cg.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName); + expect(node, `${qualifiedName} in ${file}`).toBeDefined(); + return node!; +} + +/** `kind` edges leaving `node`, as target qualified names. */ +function targets(node: Node, kind: string): string[] { + return cg + .getOutgoingEdgesFrom([node.id]) + .filter((e) => e.kind === kind) + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('VB.NET member bodies', () => { + it('indexes every member of a Structure, not just the first', () => { + const struct = member('UserMedia.vb', 'UserMedia'); + expect(struct.kind).toBe('struct'); + const contained = cg + .getOutgoingEdgesFrom([struct.id]) + .filter((e) => e.kind === 'contains') + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.kind} ${n.qualifiedName}`) + .sort(); + expect(contained).toEqual([ + 'enum UserMedia::States', + 'field UserMedia::Count', + 'field UserMedia::State', + 'method UserMedia::IsDone', + 'method UserMedia::New', + 'property UserMedia::Name', + ]); + expect(member('UserMedia.vb', 'UserMedia::States::Downloaded').kind).toBe('enum_member'); + expect(targets(member('UserMedia.vb', 'UserMedia::New'), 'calls')).toEqual(['Helper::Log']); + }); + + it("attributes a property's Get and Set blocks to the property", () => { + expect(targets(member('Box.vb', 'Box::Value'), 'calls')).toEqual(['Helper::Compute', 'Helper::Store']); + expect(targets(member('UserMedia.vb', 'UserMedia::Name'), 'calls')).toEqual(['Helper::Format']); + }); + + it("walks a property's initializer and its As New", () => { + expect(targets(member('Box.vb', 'Box::Auto'), 'calls')).toEqual(['Helper::Seed']); + expect(targets(member('Box.vb', 'Box::Lst'), 'instantiates')).toEqual(['Widget']); + }); + + it("walks each field's initializer and its As New, per declarator", () => { + expect(targets(member('Box.vb', 'Box::Def'), 'calls')).toEqual(['Helper::Compute']); + expect(targets(member('Box.vb', 'Box::Other'), 'calls')).toEqual(['Helper::Seed']); + expect(targets(member('Box.vb', 'Box::_items'), 'instantiates')).toEqual(['Widget']); + expect(targets(member('Box.vb', 'Box::_helper'), 'instantiates')).toEqual(['Helper']); + }); + + it("attributes a Custom Event's accessors to the event", () => { + expect(targets(member('Box.vb', 'Box::Changed'), 'calls')).toEqual(['Helper::Attach']); + }); + + it('keeps the class out of what its members do', () => { + expect(targets(member('Box.vb', 'Box'), 'calls')).toEqual([]); + expect(targets(member('Box.vb', 'Box'), 'instantiates')).toEqual([]); + }); + + it('still links nothing through a receiver no name resolves', () => { + const ids = cg.getNodesInFile('MainForm.vb').map((n) => n.id); + const linked = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains') + .map((e) => cg.getNode(e.target)!.qualifiedName); + expect(linked).not.toContain('DataColorCollection::Add'); + expect(linked).not.toContain('UsersInfoForm::EComparers::Size'); + }); +}); diff --git a/codegraph-kernel/src/csharp.rs b/codegraph-kernel/src/csharp.rs index 7d481662a..04e20ca5a 100644 --- a/codegraph-kernel/src/csharp.rs +++ b/codegraph-kernel/src/csharp.rs @@ -5,7 +5,7 @@ //! path, bug-for-bug, verified by scripts/kernel-parity.mjs and the full-index //! dump-diff gate. The authoritative quirk list is //! docs/design/csharp-kernel-port-checklist.md — including every deliberate -//! emission hole (property/accessor bodies, constructor initializers, +//! emission hole (field/property initializers, constructor initializers, //! delegates/events/operators/indexers, top-level locals) and garbage ref //! (`(repo)` primary-ctor extends, `: byte` enum extends, `nameof` calls) //! this file preserves on purpose. Positions in UTF-16 code units. Files whose @@ -572,21 +572,36 @@ impl<'t> Walker<'t> { self.extract_enum(node); skip_children = true; } else if kind == "property_declaration" && self.inside_class_like() { - // Property accessor/expression bodies are NEVER walked (calls - // inside are lost by design) — candidates-only scan. - self.extract_property(node); - self.scan_fn_ref_subtree(node, 0); + // The code a property runs — its accessor bodies and `=> expr` — + // is walked with the property on the stack (propertyBodies). The + // candidates-only scan covers the rest (an `= initializer`), + // skipping what the body walk captured. + let walked: Vec = match self.extract_property(node) { + Some((row, name)) => { + let bodies = property_bodies(node); + if !bodies.is_empty() { + self.stack.push(Scope { row, kind: "property", name }); + for body in &bodies { + self.visit_function_body(*body); + } + self.stack.pop(); + } + bodies.iter().map(|b| b.id()).collect() + } + None => Vec::new(), + }; + self.scan_fn_ref_subtree(node, 0, &walked); skip_children = true; } else if kind == "field_declaration" && self.inside_class_like() { self.extract_field(node); - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, &[]); skip_children = true; } else if kind == "local_declaration_statement" && !self.inside_class_like() { // Top-level statements: extractVariable's generic fallback finds no // direct identifier/variable_declarator children (C# nests them in // variable_declaration) → ZERO nodes, zero refs. Candidates only. self.extract_variable(node); - self.scan_fn_ref_subtree(node, 0); + self.scan_fn_ref_subtree(node, 0, &[]); skip_children = true; } else if kind == "using_directive" { self.extract_import(node); @@ -789,9 +804,9 @@ impl<'t> Walker<'t> { } /// extractProperty (1986) — property_declaration only (dispatch-gated to - /// class-like scopes). Accessor bodies and `=>` value clauses are never - /// walked; type refs DO come from the `type` field. - fn extract_property(&mut self, node: Node<'t>) { + /// class-like scopes). Type refs come from the `type` field; the caller + /// walks the bodies (property_bodies) with the returned row on the stack. + fn extract_property(&mut self, node: Node<'t>) -> Option<(u32, String)> { let docstring = preceding_docstring(node, self.src); let visibility = Some(self.visibility_of(node)); let is_static = Some(self.is_static(node)); // ?? false — always concrete @@ -804,10 +819,10 @@ impl<'t> Walker<'t> { .filter_map(|i| node.named_child(i)) .find(|c| c.kind() == "identifier") }); - let Some(name_node) = name_node else { return }; + let name_node = name_node?; let name = self.text(name_node).to_string(); if name.is_empty() { - return; + return None; } // Generic scan (isTsJsField=false): FIRST namedChild that isn't a @@ -842,11 +857,10 @@ impl<'t> Walker<'t> { &name, node, Extra { docstring, signature: Some(signature), visibility, is_static, ..Extra::default() }, - ); - if let Some(row) = row { - // decorators: none for C#; then the csharp type-ref path. - self.extract_csharp_type_refs(node, row); - } + )?; + // decorators: none for C#; then the csharp type-ref path. + self.extract_csharp_type_refs(node, row); + Some((row, name)) } /// extractField (2046) — field_declaration; each declarator becomes a @@ -1468,11 +1482,15 @@ impl<'t> Walker<'t> { }); } - fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) { + fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32, walked: &[usize]) { stack_guard!(); if depth > 12 { return; } + // Subtrees the body walker has already been through. + if walked.contains(&node.id()) { + return; + } // functionTypes is EMPTY for C#; the literal halt list applies — // lambda_expression IS C#'s lambda, so initializer lambdas stop the // scan; anonymous_method_expression is NOT listed and scans through. @@ -1487,7 +1505,7 @@ impl<'t> Walker<'t> { self.maybe_capture_fn_refs(node); for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { - self.scan_fn_ref_subtree(c, depth + 1); + self.scan_fn_ref_subtree(c, depth + 1, walked); } } } @@ -1624,6 +1642,30 @@ impl<'t> Walker<'t> { } } +/// propertyBodies (tree-sitter.ts) — the parts of a property that run code: +/// each accessor's `body` (a block or `=> expr`) and an expression-bodied +/// property's `=> …` value. An `= initializer` value is not a body. +fn property_bodies(node: Node) -> Vec { + let mut bodies = Vec::new(); + if let Some(accessors) = node.child_by_field_name("accessors") { + for i in 0..accessors.named_child_count() { + let Some(accessor) = accessors.named_child(i) else { continue }; + if accessor.kind() != "accessor_declaration" { + continue; + } + if let Some(body) = accessor.child_by_field_name("body") { + bodies.push(body); + } + } + } + if let Some(value) = node.child_by_field_name("value") { + if value.kind() == "arrow_expression_clause" { + bodies.push(value); + } + } + bodies +} + fn find_anonymous_class_body(node: Node) -> Option { for i in 0..node.named_child_count() { if let Some(child) = node.named_child(i) { diff --git a/docs/design/csharp-kernel-port-checklist.md b/docs/design/csharp-kernel-port-checklist.md index 41894cb06..e817968c9 100644 --- a/docs/design/csharp-kernel-port-checklist.md +++ b/docs/design/csharp-kernel-port-checklist.md @@ -234,7 +234,7 @@ all PRESERVE): | `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 + scanFnRefSubtree (capture-only) + skipChildren → **accessor bodies (`get { … }`, `get => …`) and the `=> expr` value clause are NEVER walked — calls inside property getters/setters/expression bodies emit NOTHING** (only fn-ref candidates). §property below | +| `property_declaration` (inside class-like) | propertyTypes:1075 → extractProperty:1986 | property node, then **propertyBodies — each accessor's `body` (`get { … }`, `set => …`) and an expression-bodied `=> expr` `value:` — walked by visitFunctionBody with the property pushed** (calls, instantiates, static reads and fn-ref candidates attribute to the property; changed 2026-10-04, previously never walked), then scanFnRefSubtree (capture-only, attributed to the class) over the rest — **the `= initializer` stays unwalked** — skipping the walked bodies, + skipChildren. §property below | | `field_declaration` (inside class-like) | fieldTypes:1084 → extractField:2046 | field/constant nodes per declarator + scanFnRefSubtree + skipChildren → **field initializers emit no calls/instantiates/static-member refs** (fn-ref candidates only). §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) | @@ -305,15 +305,22 @@ for C#) or bare `name`. QUIRKS (probed, PRESERVE): - 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 NEVER walked (calls inside lost). -- `{ get; } = new();` initializers: the `value:` implicit_object_creation and - the accessor_list are both skipped/excluded → no refs, no instantiates. + the arrow clause is a property body (walked as the property, below). +- `{ get; } = new();` initializers: the `value:` implicit_object_creation is + NOT a body → no refs, no instantiates (candidates-only scan, attributed to + the class); 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 Items` emits references `List` + `Foo` even though the signature -kept the raw text). Return value feeds no body walk (the classifyMethodNode -initializer-walk path at 1031-1047 is TS-only). +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:` when it is an +arrow_expression_clause. The fn-ref scan that follows skips those subtrees +by node id, so a candidate is captured once — from the property when it sits +in a body, from the class when it sits in an initializer. (The +classifyMethodNode initializer-walk path at 1031-1047 is TS-only.) ### extractField (2046) — field_declaration @@ -758,7 +765,8 @@ AspNetCore refs / Program.cs / Startup.cs / controller-source scan. + multi-declarator + instance fields (signatures `Type name`); `protected internal` (→ protected); property shapes: predefined-type, bare-identifier type (signature loses type), generic type, expression-bodied - (`=>` calls LOST), `{ get; } = new();`, accessor bodies with calls (LOST); + (`=>` calls → the property), `{ get; } = new();` (initializer LOST), + 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 diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 3691e95f0..e41b5f614 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 27; +export const EXTRACTION_VERSION = 28; diff --git a/src/extraction/languages/vbnet.ts b/src/extraction/languages/vbnet.ts index b2ec4aab5..e8502b2e3 100644 --- a/src/extraction/languages/vbnet.ts +++ b/src/extraction/languages/vbnet.ts @@ -116,8 +116,13 @@ export const vbnetExtractor: LanguageExtractor = { // findable declaration (WinForms/WPF code is built around them). if (node.type === 'event_declaration' || node.type === 'custom_event_declaration') { const nameNode = node.childForFieldName('name'); - if (nameNode) { - ctx.createNode('field', getNodeText(nameNode, ctx.source), node); + const event = nameNode ? ctx.createNode('field', getNodeText(nameNode, ctx.source), node) : null; + // A Custom Event's AddHandler / RemoveHandler / RaiseEvent blocks run + // code, which is the event's. + if (event && node.type === 'custom_event_declaration') { + ctx.pushScope(event.id); + ctx.visitFunctionBody(node, event.id); + ctx.popScope(); } return true; } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5c27523ac..9a1086247 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -733,8 +733,10 @@ export class TreeSitterExtractor { * nested function definitions: their bodies are walked — and their * candidates attributed — by extractFunction's own body walk. */ - private scanFnRefSubtree(node: SyntaxNode, depth: number): void { + private scanFnRefSubtree(node: SyntaxNode, depth: number, walked?: ReadonlySet): void { if (!this.fnRefSpec || depth > 12) return; + // Subtrees the body walker has already been through. + if (walked?.has(node.id)) return; const nodeType = node.type; if (depth > 0 && ( this.extractor?.functionTypes.includes(nodeType) || @@ -749,7 +751,7 @@ export class TreeSitterExtractor { this.maybeCaptureFnRefs(node, nodeType); for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); - if (child) this.scanFnRefSubtree(child, depth + 1); + if (child) this.scanFnRefSubtree(child, depth + 1, walked); } } @@ -1279,11 +1281,19 @@ export class TreeSitterExtractor { } // Check for class properties (e.g. C# property_declaration) else if (this.extractor.propertyTypes?.includes(nodeType) && this.isInsideClassLikeNode()) { - this.extractProperty(node); - // Property initializers aren't walked — scan for function-as-value - // candidates (#756): Scala `val table = Seq(targetCb)` in an object, - // Kotlin `val cb = ::handler` class properties. - this.scanFnRefSubtree(node, 0); + const propNode = this.extractProperty(node); + // The code a property runs is the property's: its calls, + // instantiations and reads attribute to it, as a method's do. + const bodies = propNode ? this.propertyBodies(node) : []; + if (propNode && bodies.length > 0) { + this.nodeStack.push(propNode.id); + for (const body of bodies) this.visitFunctionBody(body, propNode.id); + this.nodeStack.pop(); + } + // Whatever the body walk didn't cover (a C# `= initializer`, any other + // language's whole declaration) is scanned for function-as-value + // candidates (#756); the bodies captured their own. + this.scanFnRefSubtree(node, 0, new Set(bodies.map((b) => b.id))); skipChildren = true; } // Check for class fields (e.g. Java field_declaration, C# field_declaration) @@ -2113,7 +2123,12 @@ export class TreeSitterExtractor { // (#1093) because the two defaults differ — a bodiless CLASS is kept // unless a language opts into skipping, a bodiless STRUCT is skipped // unless a language opts into keeping. - const body = getChildByField(node, this.extractor.bodyField); + // + // resolveBody first, as for classes and enums: a VB.NET Structure tags + // every member as its own `body` field, so the field alone yields only + // the first member. + const body = this.extractor.resolveBody?.(node, this.extractor.bodyField) + ?? getChildByField(node, this.extractor.bodyField); if (!body && node.type !== 'record_declaration' && !this.extractor.allowBodilessStruct) return; @@ -2304,6 +2319,28 @@ export class TreeSitterExtractor { return propNode; } + /** + * The parts of a property declaration that run code, for the body walker. + * VB.NET writes its `Get` / `Set` blocks, `= initializer` and `As New T` + * as children of the declaration itself, so the declaration is walked + * whole, the way its methods are (resolveBody). C# runs code in each + * accessor's body (`get { … }`, `set => …`) and in an expression-bodied + * property's `=> …`; its `= initializer`, like a field's, stays unwalked. + * Mirrored in the kernel (csharp.rs property_bodies). + */ + private propertyBodies(node: SyntaxNode): SyntaxNode[] { + if (this.language === 'vbnet') return [node]; + if (this.language !== 'csharp') return []; + const bodies: SyntaxNode[] = []; + for (const accessor of getChildByField(node, 'accessors')?.namedChildren ?? []) { + const body = accessor.type === 'accessor_declaration' ? getChildByField(accessor, 'body') : null; + if (body) bodies.push(body); + } + const value = getChildByField(node, 'value'); + if (value?.type === 'arrow_expression_clause') bodies.push(value); + return bodies; + } + /** * Extract a class field declaration (e.g. Java field_declaration, C# field_declaration). * Extracts each declarator as a 'field' kind node inside the owning class. @@ -2408,10 +2445,11 @@ export class TreeSitterExtractor { // candidates, so a lambda / method reference / anonymous class in // `private final Runnable r = () -> target();` contributed NO call // edge at all and `target` looked callerless. Keyed on the `value` - // FIELD, which only Java's `variable_declarator` carries — C#, - // VB.NET and PHP spell their initializer differently and are - // deliberately untouched here. - const valueNode = getChildByField(decl, 'value'); + // FIELD, which only Java's `variable_declarator` carries. VB.NET + // writes `= expr` (the declarator's `initializer`) or `As New T(…)` + // (inside its as_clause), so its whole declarator is walked. C# + // and PHP spell their initializer differently and are untouched. + const valueNode = this.language === 'vbnet' ? decl : getChildByField(decl, 'value'); if (valueNode) { this.nodeStack.push(fieldNode.id); this.visitFunctionBody(valueNode, fieldNode.id);