Преглед изворни кода

feat(extraction): add Pascal/Delphi to value-reference edges (unit/class const → constant)

Pascal unit/class `const` sections already extracted as `constant`, so wiring was
add-to-`VALUE_REF_LANGS` + shadow prune + two Pascal-specific fixes:

**`constant`-only targets** — the Pascal extractor emits function parameters
(`const ATarget: TControl`, `var Dest: …`) and class fields as `variable` at
enclosing scope, which would collapse to noisy file-wide targets. Genuine shared
values are all `constant` (`declConst`), so targets are restricted to that kind.
(Unit `var` globals are the rare cost; the parameter/field noise dominates.)

**Sibling-body fix (same as Dart)** — Pascal attaches a proc body (`block`) as a
next sibling of the `declProc` header (the reader scope), both under `defProc`,
not as a child. Extended the existing Dart `function_body` sibling-pull to also
walk a `block` sibling so reads inside the body are found.

Shadow prune wired for `declConst`/`declVar` — a function-local `const X`
shadows a unit `const X`.

Validated S/M/L (horse / mORMot2 / castle-engine): node count identical on/off,
precision sample 100% TP (font/crypto/DB/FFI binding consts); headline is FFI
library-name constants read by hundreds of `external` declarations (impact
2→1880, 1→358). Caveats: low same-file density on app code (cross-unit reads;
horse gave 4 edges), the `constant`-only restriction, a rare tree-sitter-pascal
misparse of `const` params in complex Delphi signatures, and case-insensitivity
(exact-text scan misses a differently-cased reference — a miss, never an FP).
Coverage now 15 languages.
Colby McHenry пре 1 недеља
родитељ
комит
1601e4ad12

+ 1 - 1
CHANGELOG.md

@@ -11,7 +11,7 @@ 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, 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.
+- Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi 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.

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

@@ -645,6 +645,66 @@ describe('value-reference edges', () => {
     expect(valueRefReaders(cg, 'TIMEOUT')).toEqual([]);
   });
 
+  it('edges same-file functions to a unit-scope const (Pascal)', async () => {
+    // Pascal keeps shareable constants in a `const` section at unit (file) scope
+    // (and class scope). They already extract as `constant`. A const reference is
+    // an `identifier`; the catch is that Pascal attaches a proc body (`block`) as
+    // a sibling of the proc header (`declProc`, the reader scope), so the
+    // reader-scan pulls in that sibling.
+    fs.writeFileSync(
+      path.join(dir, 'demo.pas'),
+      [
+        'unit Demo;',
+        'interface',
+        'const',
+        '  MAX_ITEMS = 100;',
+        "  APP_NAME = 'MyApp';",
+        'implementation',
+        'function Capped(n: Integer): Integer;',
+        'begin',
+        '  if n > MAX_ITEMS then Capped := MAX_ITEMS else Capped := n;',
+        'end;',
+        'function AppLabel: string;',
+        'begin',
+        '  AppLabel := APP_NAME;',
+        'end;',
+        'end.',
+      ].join('\n'),
+    );
+    cg = index();
+    await cg.indexAll();
+
+    expect(valueRefReaders(cg, 'MAX_ITEMS')).toEqual(expect.arrayContaining(['Capped']));
+    expect(valueRefReaders(cg, 'APP_NAME')).toEqual(expect.arrayContaining(['AppLabel']));
+  });
+
+  it('does NOT edge a Pascal unit const shadowed by a function-local const of the same name', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'shadow.pas'),
+      [
+        'unit Shadow;',
+        'interface',
+        'const',
+        '  TIMEOUT = 30;',
+        'implementation',
+        'function UsesConst: Integer;',
+        'begin',
+        '  UsesConst := TIMEOUT;',
+        'end;',
+        'function Shadows: Integer;',
+        'const TIMEOUT = 5;',
+        'begin',
+        '  Shadows := TIMEOUT;',
+        'end;',
+        'end.',
+      ].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';

+ 21 - 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`, `dart`. **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`, `pascal`. **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`). |
@@ -143,6 +143,15 @@ targets** (see §3).
   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.
+- **Pascal — the genuine easy path + the Dart sibling-body fix again.** Unit/class `const` *already*
+  extracted as `constant` (`variableTypes: ['declConst', …]`), so it was add-to-`VALUE_REF_LANGS` +
+  the shadow prune (`declConst`/`declVar`; a local `const X` shadows a unit `const X`). The catch was
+  the *same* reader-scan bug as Dart: Pascal's proc body is a **`block` sibling** of the `declProc`
+  header (the reader scope), both under a `defProc` — so the same sibling-pull fix was extended to
+  `block`. Reader-scan node type already covered (refs are `identifier`). **Low yield** — Pascal reads
+  constants cross-unit more than same-file (horse: 4 edges). **Caveat:** Pascal is case-insensitive,
+  but the reader-scan matches exact text, so a differently-cased reference is missed (no FP, just a
+  miss); not worth normalizing.
 - **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.
@@ -173,6 +182,7 @@ the bottom of this section).
 | 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 |
+| Pascal / Delphi | unit/class `const` (already extracted as `constant`). Add-to-`VALUE_REF_LANGS` + shadow prune (`declConst`/`declVar`) + the **same Dart sibling-body fix** (Pascal's proc body is a `block` sibling of the `declProc` header). Low yield (cross-unit reads); case-insensitive (exact-text scan misses re-cased refs) |
 | **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
@@ -198,7 +208,6 @@ constants extracted as `field` kind, and the fix was emitting the const subset (
 
 | Language | Constant forms |
 |---|---|
-| Pascal / Delphi | `const` sections at unit (file) or class scope (mixed) |
 | Objective-C | `static const` / `extern const` / `#define` (file-ish; macros unparsed; already "partial support") |
 
 **⛔ Attempted & reverted — C++.** file-scope + class `static const`/`constexpr` (mixed). Machinery
@@ -393,6 +402,7 @@ silently does nothing for the new language and intra-file shadowing produces fal
 | 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** |
+| Pascal | `declConst` (unit/class const = the target) + `declVar` (a local `var`) | `<name>` field — catches a unit `const X` shadowed by a function-local `const X` | **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`,
@@ -504,6 +514,15 @@ 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.
+- **Some extractors emit parameters/fields as `variable` at the wrong scope — restrict to `constant`
+  (the Pascal trap).** Pascal's extractor emits function `const`/`var` parameters and class fields as
+  `variable` parented to the enclosing unit/class, so they pass the target gate and collapse to noisy
+  file-wide targets (`Dest`, `aItem` read "everywhere"). The genuine shared values were all `constant`
+  (`declConst`), so the fix is a one-line per-language restriction in `captureValueRefScope`: Pascal
+  targets `constant` only. Before trusting a new language's `variable` targets, sample them — if they're
+  parameters or instance fields rather than module/global state, restrict to `constant`. (A residual
+  tail can still leak: tree-sitter-pascal context-dependently misparses a `const` param in a complex
+  Delphi signature as a `declConst` — a small parse-fidelity FP, accepted as a documented caveat.)
 - **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

+ 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 + Dart; `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 + Pascal; `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 + Dart. 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 + Pascal. 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 / Dart
+## Validation matrix — TS / JS / Go / Python / Rust / Ruby / C / Java / C# / PHP / Scala / Kotlin / Swift / Dart / Pascal
 
 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
@@ -166,7 +166,15 @@ extracting the nodes first — see below)
 | 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
+**Pascal / Delphi** (unit/class `const` → `constant`; **`constant`-only** targets — the extractor emits params/fields as `variable`)
+
+| Repo | size | files | nodes (on=off) | +value-ref edges | precision | `impact` on→off example |
+|---|---|---|---|---|---|---|
+| HashLoad/horse | small | 74 | 2,464 (stable) | +4 (sparse — cross-unit reads) | all sampled TP | `LOG_NFACILITIES` (Syslog const) |
+| synopse/mORMot2 | medium | 539 | 66,760 (stable) | +2,240 | precision sample 100% TP (font/crypto/DB consts); a few `const`-param misparse FPs in complex Delphi sigs | `LIB_CRYPTO` 1→**358**, `DEFAULT_ECCROUNDS` 1→31 |
+| castle-engine | large | 2,430 | 93,692 (stable) | +6,983 | top targets all real FFI binding consts; 0 collisions | `LazGio2_library` 2→**1880**, `LIB_CAIRO` 1→223 |
+
+Across S/M/L in all fifteen 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.
 
@@ -374,6 +382,27 @@ but a header-only-marked generator (a JNIGEN `_bindings.dart` with hundreds of `
 isn't suffix-detected, so it collapses to the file-wide target and dominates a small repo's numbers
 (http) — real source stays clean.
 
+**Pascal / Delphi — the easy path plus the Dart sibling-body fix and a `constant`-only restriction.**
+Pascal keeps shared constants in a `const` section at unit (file) or class scope, and those *already*
+extracted as `constant` (`variableTypes: ['declConst', …]`), so wiring was add-to-`VALUE_REF_LANGS` +
+the shadow prune (`declConst`/`declVar` — a function-local `const X` shadows a unit `const X`). It hit
+the **same reader-scan bug as Dart**: Pascal attaches a proc body (`block`) as a *next sibling* of the
+`declProc` header (the reader scope), both under a `defProc`, so the same sibling-pull fix was extended
+to `block`. The Pascal-specific wrinkle is precision: the Pascal extractor emits function **parameters**
+(`const ATarget: TControl`, `var Dest: …`) and class **fields** as `variable` at the enclosing scope,
+which collapse to noisy file-wide targets — so **Pascal value-ref targets are restricted to
+`constant`** (genuine shared values are `const`; the cost is the rare unit-level `var` global). That
+cleaned the bulk (`var`-param/field FPs gone). A residual minority remains — tree-sitter-pascal
+*context-dependently* misparses a `const` parameter in a complex multi-line Delphi method signature as
+a `declConst` (the `ATarget` case; not reproducible in isolation), a parse-fidelity tail like C++ but
+far smaller. After the fix: a random precision sample on mORMot was 100% TP (font/crypto/DB constants
+referencing each other), castle's top targets are all real FFI binding consts with 0 collisions, and
+the headline is FFI library-name constants — `LazGio2_library = 'libgio-2.0…'` read by **1880**
+`external` declarations (2→1880), mORMot's `LIB_CRYPTO` 1→358. **Caveats:** low same-file density on
+app code (cross-unit reads; horse gave 4 edges), the `const`-only restriction, the rare const-param
+misparse, and Pascal's case-insensitivity (the exact-text reader-scan misses a differently-cased
+reference — a miss, never an FP).
+
 **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

+ 24 - 8
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', 'dart']);
+  private static readonly VALUE_REF_LANGS = new Set<string>(['typescript', 'javascript', 'tsx', 'go', 'python', 'rust', 'ruby', 'c', 'java', 'csharp', 'php', 'scala', 'kotlin', 'swift', 'dart', 'pascal']);
   private static readonly MAX_VALUE_REF_NODES = 20_000;
   private readonly valueRefsEnabled = process.env.CODEGRAPH_VALUE_REFS !== '0';
   private fileScopeValues = new Map<string, string>();
@@ -610,7 +610,15 @@ export class TreeSitterExtractor {
    * scopes whose bodies flushValueRefs scans.
    */
   private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
-    if ((kind === 'constant' || kind === 'variable') && name.length >= 3 && /[A-Z_]/.test(name)) {
+    // Pascal targets `constant` only: its extractor emits function PARAMETERS
+    // (`Dest: TBufferWriter`) and class fields (`declField`) as `variable` at the
+    // enclosing scope, which would otherwise become noisy targets (a param name
+    // shared across many procs collapses to one file-wide target). Genuine
+    // Pascal shared values are `const` (`constant`), so restrict to that. (Unit
+    // `var` globals are the rare cost; the parameter/field noise dominates.)
+    const targetKindOk =
+      this.language === 'pascal' ? kind === 'constant' : kind === 'constant' || kind === 'variable';
+    if (targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
       const parentId = this.nodeStack[this.nodeStack.length - 1];
       // file-scope OR class/module/struct/enum-scope constants are targets.
       // Class/module scope matters for languages (Ruby) that keep nearly all
@@ -717,6 +725,11 @@ export class TreeSitterExtractor {
             if (id) bump(id);
             break;
           }
+          case 'declConst':  // Pascal  unit/class `const` (the target itself) AND a function-local `const` that shadows it
+          case 'declVar': {  // Pascal  a function-local `var` that shadows a const
+            bump(getChildByField(n, 'name'));
+            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.
@@ -744,13 +757,16 @@ 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.)
+      // Dart and Pascal attach a function/method BODY as a *next sibling* of the
+      // signature node that is stored as the reader scope (Dart `method_signature`
+      // ← `function_body`; Pascal `declProc` ← `block`, both under a `defProc`),
+      // not as a child — so the scope subtree is just the signature and the reads
+      // live in the sibling. Pull it in. (A body as a next sibling of the scope
+      // node is unique to Dart/Pascal among the value-ref languages — every other
+      // grammar nests the body inside the function node — so this is inert
+      // elsewhere.)
       const sib = scope.node.nextNamedSibling;
-      if (sib && sib.type === 'function_body') stack.push(sib);
+      if (sib && (sib.type === 'function_body' || sib.type === 'block')) stack.push(sib);
       let visited = 0;
       while (stack.length > 0 && visited < TreeSitterExtractor.MAX_VALUE_REF_NODES) {
         const n = stack.pop()!;