Selaa lähdekoodia

feat(extraction): add Dart to value-reference edges (static const / static final constants)

Dart's grammar cleanly separates the constant idiom: a `static_final_declaration`
is exactly a top-level or class-`static` `const`/`final`, while instance fields
and `var` use `initialized_identifier` and method-locals use
`initialized_variable_definition`. A `visitNode` rule maps `static_final_declaration`
to `constant`, so there are no instance/local leaks to guard.

The reader-scan needed a Dart-specific fix: Dart attaches a method/function body
as a NEXT SIBLING of the signature node (which is stored as the reader scope),
not as a child, so the scan saw only the signature and produced zero edges until
it was taught to also walk a `function_body` next-sibling. The shadow prune counts
all three Dart declarator nodes so a method-local `const X` drops a file `const X`.

Validated S/M/L (http / flame-engine/flame / flutter/packages): node count
identical on/off; the headline is a single `static const String iconFont` read by
every Cupertino icon definition (impact 1 -> 1790). The common Dart codegen
suffixes (.g.dart/.freezed.dart/.pb.dart) are already skipped; header-only-marked
generators (JNIGEN _bindings.dart, pigeon .gen.dart) are not, so generated FFI/JNI
bindings are noisy while real source stays clean. Coverage now 15 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 1 viikko sitten
vanhempi
sitoutus
2ec74a2c36

+ 2 - 1
CHANGELOG.md

@@ -11,10 +11,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
-- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, and Swift now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
+- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, and Dart now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.
 - C file-scope constants and globals — `static const` scalars, pointer/array lookup tables, and shared mutable globals — are now recognized as symbols in their own right. They previously weren't extracted at all, so they never appeared in search or carried any dependents; now they show up in `codegraph search` and participate in impact analysis (see above), so changing a C lookup table surfaces the same-file functions that read it.
 - Java `static final` constants, C# `const` / `static readonly` constants, Scala `object` vals, and Kotlin top-level / `object` / `companion object` `val`s are now classified as constants rather than generic fields, so they participate in the constant-reader impact analysis above — change a `public static final` table, a `const string`, a Scala `object Config { val Timeout = … }`, or a Kotlin `companion object { const val … }` and the methods that read it now show up as affected. (Per-object Java `final` / C# `readonly` / Scala & Kotlin `class` instance properties are unchanged.) Kotlin constants were previously not indexed as their own symbols at all, so they now also appear in `codegraph search`.
 - Swift top-level `let`s and `static let` constants (including those namespaced in an `enum`/`struct`, the common Swift pattern) are now indexed as constants and participate in the constant-reader impact analysis above — change a `static let defaultRetryLimit` or an `enum Constants { static let … }` and the same-file code that reads it shows up as affected. Computed properties and per-instance `let`s are not treated as constants.
+- Dart top-level `const`/`final` and class `static const`/`static final` constants are now indexed as constants and participate in the constant-reader impact analysis above. Instance fields, `var`s, and locals are not treated as constants. (Generated Dart code with the standard `.g.dart`/`.freezed.dart`/`.pb.dart` suffixes is already skipped.)
 
 ### Fixes
 

+ 48 - 0
__tests__/value-reference-edges.test.ts

@@ -597,6 +597,54 @@ describe('value-reference edges', () => {
     expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
   });
 
+  it('edges readers to a top-level const and a class static const/final (Dart)', async () => {
+    // Dart's grammar uses `static_final_declaration` for exactly the top-level
+    // `const`/`final` and class `static const`/`static final` — the shared
+    // constants — so those extract as `constant`. Instance fields and `var`
+    // (`initialized_identifier`) and locals (`initialized_variable_definition`)
+    // are NOT this node, so they never become targets. Dart attaches a method
+    // body as a sibling of the signature, so the reader-scan pulls that in.
+    fs.writeFileSync(
+      path.join(dir, 'demo.dart'),
+      [
+        'const TOP_LEVEL_MAX = 100;',
+        'class Config {',
+        '  static const TIMEOUT_MS = 30;',
+        '  static final STATUS_NAMES = ["ok", "fail"];',
+        '  final int instanceField = 1;',
+        '  int capped(int n) => n > TIMEOUT_MS ? TIMEOUT_MS : n;',
+        '  String label(int i) { return STATUS_NAMES[i]; }',
+        '  int withinLimit(int n) => n < TOP_LEVEL_MAX ? n : TOP_LEVEL_MAX;',
+        '}',
+      ].join('\n'),
+    );
+    cg = index();
+    await cg.indexAll();
+
+    expect(valueRefReaders(cg, 'TIMEOUT_MS')).toEqual(expect.arrayContaining(['capped']));
+    expect(valueRefReaders(cg, 'STATUS_NAMES')).toEqual(expect.arrayContaining(['label']));
+    expect(valueRefReaders(cg, 'TOP_LEVEL_MAX')).toEqual(expect.arrayContaining(['withinLimit']));
+    // An instance field is per-object state, never a value-ref target.
+    expect(valueRefReaders(cg, 'instanceField')).toEqual([]);
+  });
+
+  it('does NOT edge a Dart const shadowed by a method-local const of the same name', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'shadow.dart'),
+      [
+        'const TIMEOUT = 30;',
+        'class C {',
+        '  int usesConst() => TIMEOUT;',
+        '  int shadows() { const TIMEOUT = 5; return TIMEOUT; }',
+        '}',
+      ].join('\n'),
+    );
+    cg = index();
+    await cg.indexAll();
+
+    expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
+  });
+
   it('emits nothing when CODEGRAPH_VALUE_REFS=0', async () => {
     const prev = process.env.CODEGRAPH_VALUE_REFS;
     process.env.CODEGRAPH_VALUE_REFS = '0';

+ 25 - 2
docs/design/value-reference-edges-playbook.md

@@ -45,7 +45,7 @@ agent read-reduction (see §4.3).
 
 | Symbol | Role |
 |---|---|
-| `VALUE_REF_LANGS` (static Set) | languages the feature runs for. Currently `typescript`, `javascript`, `tsx`, `go`, `python`, `rust`, `ruby`, `c`, `java`, `csharp`, `php`, `scala`, `kotlin`, `swift`. **Add the new language here.** |
+| `VALUE_REF_LANGS` (static Set) | languages the feature runs for. Currently `typescript`, `javascript`, `tsx`, `go`, `python`, `rust`, `ruby`, `c`, `java`, `csharp`, `php`, `scala`, `kotlin`, `swift`, `dart`. **Add the new language here.** |
 | `valueRefsEnabled` | `process.env.CODEGRAPH_VALUE_REFS !== '0'` — default ON, env opts out. |
 | `MAX_VALUE_REF_NODES` (20_000) | per-scope traversal cap (and the shadow-scan cap). |
 | `captureValueRefScope(kind, name, id, node)` | called from `createNode` on every node. Records **targets** (file-scope `const`/`var`) and **reader scopes** (`function`/`method`/`const`/`var`). |
@@ -128,6 +128,21 @@ targets** (see §3).
   `var x:Int{ … }` getter has no stored value — detect the `computed_property` child). Node creation
   slots into the *existing* Swift `property_declaration` handler (property-wrapper/type deps), leaving
   that untouched. Clean parse, no tail. Validated S/M/L (Alamofire/swift-argument-parser/swift-nio).
+- **Dart — clean grammar separation, but a sibling-body reader-scan fix.** Dart's grammar already
+  splits the cases: **`static_final_declaration`** is *exactly* a top-level/`static` `const`/`final`
+  (the shared-constant idiom), while instance fields/`var` use `initialized_identifier` and locals use
+  `initialized_variable_definition` — so extracting `static_final_declaration` → `constant` (in a
+  `visitNode` hook) has **no instance/local leaks to guard**. Reader-scan free (Dart refs are
+  `identifier`). The catch was the **reader-scan**: Dart attaches a method/function `body` as a *next
+  sibling* of the signature node (the stored scope), not a child, so the scan saw only the signature
+  and **found nothing** until it was taught to pull in a `function_body` next-sibling (Dart-only among
+  the value-ref set). Shadow prune needed `static_final_declaration` + `initialized_identifier` +
+  `initialized_variable_definition` (a local `const X` shadowing a file `const X`). Validated S/M/L
+  (http/flame/flutter-packages). **Caveat:** generated Dart files inflate the sibling-class ambiguity
+  (a JNIGEN `_bindings.dart` with hundreds of `static final _class` collapses to the file-wide target).
+  The common codegen suffixes (`.g.dart`/`.freezed.dart`/`.pb.dart`) are already filtered by
+  `isGeneratedFile`; header-only-marked generators (JNIGEN) are not, so real source is clean but
+  generated FFI/JNI bindings are noisy.
 - **Tests:** `__tests__/value-reference-edges.test.ts` — same-file readers edged; surfaced in
   impact radius; shadowed const NOT edged (verified to fail without the guard); JSX-only read
   edged (tsx); `CODEGRAPH_VALUE_REFS=0` emits nothing.
@@ -157,6 +172,7 @@ the bottom of this section).
 | Scala | top-level `val` (already `constant`) + **`object` val** (the singleton-constant idiom; re-kinded from `field` by walking to the enclosing `object_definition`). `class`/`trait`/`enum` vals stay `field`. `val_definition`/`var_definition` added to the shadow prune. Minor val/def name-collision limit |
 | Kotlin | top-level / `object` / `companion object` `val` (re-kinded from nothing — properties weren't extracted at all). Handled in `visitNode`: nested name (`variable_declaration → simple_identifier`, the C move) + scope-walk for kind (Scala move) + `simple_identifier` in the reader-scan (PHP move) + prune. `class` instance vals stay `field`. Clean — one of the best yields (companion bit-masks) |
 | Swift | top-level `let` + `static let` in `struct`/`enum`/`class`. Reused Kotlin (nested name + `simple_identifier` reader-scan). Two Swift touches: **gate widened to `struct:`/`enum:` parents** (Swift namespaces consts there), and **computed properties skipped**. `class`/instance stored props stay `field`. Slots into the existing Swift property-wrapper handler |
+| Dart | top-level `const`/`final` + class `static const`/`static final` — all the **`static_final_declaration`** node, cleanly separated by the grammar from instance/`var`/local (so no leak guard). `visitNode` → `constant`. Needed a reader-scan fix: Dart's method **body is a next sibling** of the signature, so the scan pulls in a `function_body` sibling. Generated-FFI noise (JNIGEN `_bindings.dart`) is the one caveat |
 | **Svelte, Vue, Astro** | **inherited for free** — their extractors re-parse the `<script>`/frontmatter block as `typescript`/`javascript`, which are in `VALUE_REF_LANGS` (verified: a `.svelte` `const` edges its readers). No separate work; no separate matrix row needed. |
 
 **🔜 Remaining — likely the easy path** (constants are file/module-scope, or top-level; do §5: add
@@ -170,7 +186,6 @@ column.**
 
 | Language | Constant forms | Note |
 |---|---|---|
-| Dart | top-level `const`/`final` (file) + `static const` (class) | mixed; AST is a flat `static_final_declaration_list`, not a clean property node — messier than Swift |
 | Lua / Luau | file/chunk `local X =` + globals; no `const` keyword | distinctive-name gate (needs `[A-Z_]`) catches fewer — Lua casing varies |
 | R | file-scope `X <- …` / `X = …` | |
 
@@ -377,6 +392,7 @@ silently does nothing for the new language and intra-file shadowing produces fal
 | Scala | `val_definition`, `var_definition` | `pattern` field (identifier) — catches an object/top-level val shadowed by a method-local `val` | **done** |
 | Kotlin | `property_declaration` | `variable_declaration → simple_identifier` (and `bump` accepts `simple_identifier`) — catches an object/companion const shadowed by a method-local `val` | **done** |
 | Swift | `property_declaration` | `<name> pattern → simple_identifier` (`firstSimpleIdentifier`) — the prune case resolves both Kotlin and Swift shapes; catches a static const shadowed by a method-local `let` | **done** |
+| Dart | `static_final_declaration` (target) + `initialized_identifier` (field/`var`) + `initialized_variable_definition` (local) | each has a direct `identifier` child — catches a top-level/static const shadowed by a method-local `const` | **done** |
 
 **The prune rule is `declarators > file-scope-node-count`, NOT `> 1`.** A name can be bound
 twice *at file scope* legitimately — a **conditional module def** (`try: X = a; except: X = b`,
@@ -488,6 +504,13 @@ fixed); impact delta shows the blind→real radius win; full test suite green.
   (2,956 files) gave only 86 edges vs Ruby rails's 2,255. Don't chase it: cross-file value consumers
   are out of scope for *every* language (would need import/scope resolution). Report the lower yield
   honestly in the matrix rather than treating it as a bug to fix.
+- **A zero-edge sweep with targets present can be the READER side, not just the reader-scan node type
+  (the Dart trap).** Targets extracted fine, reader scopes registered, reader-scan node type correct —
+  and still zero edges, because Dart attaches a method **body as a next *sibling*** of the signature
+  node (which is what gets stored as the reader scope), so the scan walked only the signature subtree.
+  If a language's function/method body isn't a descendant of the node you register as the reader scope,
+  the scan won't see the reads — pull in the sibling/linked body. Check this when edges are zero but
+  both the targets and the reader nodes look right.
 
 ---
 

+ 33 - 4
docs/design/value-reference-edges.md

@@ -1,6 +1,6 @@
 # Design + status: same-file value-reference edges
 
-**Status:** SHIPPED (default-on for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift; `CODEGRAPH_VALUE_REFS=0` disables). The
+**Status:** SHIPPED (default-on for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift + Dart; `CODEGRAPH_VALUE_REFS=0` disables). The
 emitter lives in `TreeSitterExtractor.flushValueRefs` (`src/extraction/tree-sitter.ts`).
 **Motivation:** close the impact-analysis hole for *value consumers*. Static
 extraction edges calls, imports, and inheritance, but never edges a constant to the
@@ -13,7 +13,7 @@ readers" class of change (the ReScript-PR false positive that motivated the work
 ## TL;DR for a new session
 
 We emit a `references` edge (`metadata: { valueRef: true }`) from a reader symbol to
-the **file/package-scope `const`/`var` it reads**, same-file only, for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift. Those edges
+the **file/package-scope `const`/`var` it reads**, same-file only, for TS/JS/tsx + Go + Python + Rust + Ruby + C + Java + C# + PHP + Scala + Kotlin + Swift + Dart. Those edges
 flow straight into `getImpactRadius` / `codegraph impact` and the impact trail in
 `codegraph_explore` / `codegraph_node` — no agent-behaviour change required.
 
@@ -46,7 +46,7 @@ The win is **impact-radius correctness**, not agent read-reduction (see "Agent A
    the content-minified bundles guard #1 misses.
 3. **Distinctive-name + same-file** as above.
 
-## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin / Swift
+## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin / Swift / Dart
 
 Method per repo: index the same tree twice (value-refs on vs `CODEGRAPH_VALUE_REFS=0`),
 diff node/edge counts, spot-check precision, and measure `codegraph impact` on a few
@@ -158,7 +158,15 @@ extracting the nodes first — see below)
 | apple/swift-argument-parser | medium | 165 | 4,435 (stable) | +36 | all sampled TP; 1 sibling-type collision (`usageString`) | `usageString` 8→**18**, `labelColumnWidth` 1→2 |
 | apple/swift-nio | large | 554 | 20,136 (stable) | +589 | all sampled TP; 0 collisions; `eventLoop` (static let) verified TP | `CONNECT_DELAYER` 1→**15**, `SINGLE_IPv4_RESULT` 1→12 |
 
-Across S/M/L in all thirteen languages: node count never moved, the precision guards held, and
+**Dart** (top-level `const`/`final` + class `static const`/`static final` = the `static_final_declaration` node → `constant`)
+
+| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
+|---|---|---|---|---|---|---|
+| dart-lang/http | small | 324 | 4,860 (stable) | +668 | real source TP; numbers skewed by a JNIGEN `_bindings.dart` (sibling-class collapse) | `Finishing` 1→**10**, `CONNECTION_PREFACE` 5→7 |
+| flame-engine/flame | medium | 1,655 | 19,608 (stable) | +465 | all sampled TP; bounded const-vs-getter collisions | `cardWidth` 4→**15**, `tileSize` 3→12 |
+| flutter/packages | large | 3,452 | 116,075 (stable) | +10,015 | real Flutter consts; some `.gen.dart` (pigeon) generated noise | `iconFont` 1→**1790**, `_channel` 6→72, `kMaxId` 1→23 |
+
+Across S/M/L in all fourteen languages: node count never moved, the precision guards held, and
 the `impact` OFF column is the bug — a const that 80–140 symbols read reports "1 affected"
 without value-refs.
 
@@ -345,6 +353,27 @@ constants (`defaultRetryLimit`, swift-nio's `CONNECT_DELAYER`/`SINGLE_IPv4_RESUL
 shared `static let eventLoop` read by 37 methods), computed properties skipped, 0–1 collisions per
 repo (the same sibling-type name-overlap bound as Kotlin/Ruby).
 
+**Dart — the grammar did the scope separation; the catch was a sibling body.** Dart's tree-sitter
+grammar is unusually helpful here: a **`static_final_declaration`** node is *exactly* a top-level or
+class-`static` `const`/`final` — the shared-constant idiom — while instance fields and `var` use
+`initialized_identifier` and method-locals use `initialized_variable_definition`. So a single
+`visitNode` rule (`static_final_declaration` → `constant`, named by its `identifier` child) captures
+all and only the constants, with **no instance/local leaks to guard** and no scope-walk needed (the
+node stack gives `file:` for top-level, `class:` for a static member). The reader-scan was already
+covered (Dart references are plain `identifier`). The non-obvious bug: **Dart attaches a method/function
+`body` as a next *sibling* of the signature node** — and the signature is what gets stored as the
+reader scope — so the scan walked only the signature and produced *zero* edges until it was taught to
+also pull in a `function_body` next-sibling (Dart is the only value-ref language that structures bodies
+this way, so the check is inert elsewhere). The shadow prune counts all three Dart declarator nodes so
+a method-local `const X` correctly drops a file-scope `const X`. Validated S/M/L (http /
+flame-engine/flame / flutter/packages): node count identical on/off, genuine static consts on real
+source (flame's `cardWidth` 4→15, `tileSize` 3→12; HTTP/2's `Finishing` 1→10), the same bounded
+const-vs-getter name overlap as Kotlin/Scala. **The one caveat is generated code:** the common Dart
+codegen suffixes (`.g.dart` / `.freezed.dart` / `.pb.dart`) are already skipped by `isGeneratedFile`,
+but a header-only-marked generator (a JNIGEN `_bindings.dart` with hundreds of `static final _class`)
+isn't suffix-detected, so it collapses to the file-wide target and dominates a small repo's numbers
+(http) — real source stays clean.
+
 **C++ was attempted and reverted** — the machinery (file/namespace-scope + class `field_declaration`
 extraction) is correct on clean C++, but tree-sitter-cpp's parse fidelity on real template/macro-heavy
 code (and the `.h`→C-grammar routing) leaks class members and parameters to file scope as bogus

+ 22 - 0
src/extraction/languages/dart.ts

@@ -133,6 +133,28 @@ export const dartExtractor: LanguageExtractor = {
   callTypes: [],  // Dart calls use identifier+selector, handled via extractBareCall
   variableTypes: [],
   extraClassNodeTypes: ['mixin_declaration', 'extension_declaration'],
+  // A Dart `static_final_declaration` is exactly a top-level or class-`static`
+  // `const`/`final` — the shared-constant idiom — so extract it as `constant`
+  // for value-reference edges. Instance fields, `var`, and typed declarations
+  // use `initialized_identifier`, and method-locals use
+  // `initialized_variable_definition`; neither is this node, so there are no
+  // instance/local leaks to guard. The name is the first `identifier`; its
+  // parent scope (`file:` top-level / `class:` static member) comes from the
+  // node stack, both of which the value-reference target gate accepts.
+  visitNode: (node, ctx) => {
+    if (node.type === 'static_final_declaration') {
+      const nameNode = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
+      if (nameNode) {
+        const valueNode = nameNode.nextNamedSibling;
+        const initValue = valueNode ? getNodeText(valueNode, ctx.source).slice(0, 100) : undefined;
+        ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
+          signature: initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined,
+        });
+      }
+      return true;
+    }
+    return false;
+  },
   resolveBody: (node, bodyField) => {
     // Dart: function_body is a next sibling of function_signature/method_signature
     if (node.type === 'function_signature' || node.type === 'method_signature') {

+ 15 - 1
src/extraction/tree-sitter.ts

@@ -302,7 +302,7 @@ export class TreeSitterExtractor {
   // Value-reference edges (default ON; set CODEGRAPH_VALUE_REFS=0 to disable; see flushValueRefs).
   // Same-file reads of file-scope const/var symbols → `references` edges so impact analysis catches
   // value consumers ("change this constant/table, affect its readers").
-  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift']);
+  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart']);
   private static readonly MAX_VALUE_REF_NODES = 20_000;
   private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private fileScopeValues = new Map<string, string>();
@@ -710,6 +710,13 @@ export class TreeSitterExtractor {
             if (pat?.type === 'identifier') bump(pat);
             break;
           }
+          case 'static_final_declaration':         // Dart  top-level/`static` `const`/`final` (the target itself)
+          case 'initialized_identifier':           // Dart  instance field / `var`
+          case 'initialized_variable_definition': { // Dart  a method-local `const`/`final`/`var` that shadows a const
+            const id = n.namedChildren.find((c) => c.type === 'identifier');
+            if (id) bump(id);
+            break;
+          }
           case 'property_declaration': { // Kotlin / Swift  `val`/`let X = …` (object/static const AND a method-local that shadows it)
             // Kotlin: variable_declaration → simple_identifier; Swift: a `pattern`
             // (`<name>` field) → simple_identifier. Resolve either shape.
@@ -737,6 +744,13 @@ export class TreeSitterExtractor {
     for (const scope of scopes) {
       const seen = new Set<string>();
       const stack: SyntaxNode[] = [scope.node];
+      // Dart attaches a method/function BODY as a *next sibling* of the
+      // signature node (`method_signature` ← stored as the scope ← `function_body`),
+      // not as a child — so the scope subtree is just the signature and the
+      // reads live in the sibling. Pull it in. (`function_body` is a next sibling
+      // only in Dart among the value-ref languages, so this is safe elsewhere.)
+      const sib = scope.node.nextNamedSibling;
+      if (sib && sib.type === 'function_body') stack.push(sib);
       let visited = 0;
       while (stack.length > 0 && visited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {
         const n = stack.pop()!;