Browse Source

Merge remote-tracking branch 'origin/main' into HEAD

Colby McHenry 1 week ago
parent
commit
9c85cac40f
42 changed files with 2265 additions and 249 deletions
  1. 8 0
      CHANGELOG.md
  2. 3 1
      README.md
  3. 108 0
      __tests__/cli-install-init.test.ts
  4. 187 0
      __tests__/extraction.test.ts
  5. 75 0
      __tests__/fixtures/kernel-parity/torture.rs
  6. 281 0
      __tests__/kernel-deep-nesting.test.ts
  7. 2 2
      __tests__/kernel-rustlang-parity.test.ts
  8. 287 0
      __tests__/resolution.test.ts
  9. 29 0
      __tests__/sync.test.ts
  10. 128 0
      __tests__/watcher.test.ts
  11. 1 0
      codegraph-kernel/Cargo.lock
  12. 6 0
      codegraph-kernel/Cargo.toml
  13. 12 0
      codegraph-kernel/src/ccpp/mod.rs
  14. 13 0
      codegraph-kernel/src/csharp.rs
  15. 8 0
      codegraph-kernel/src/dart.rs
  16. 9 0
      codegraph-kernel/src/go.rs
  17. 11 0
      codegraph-kernel/src/java.rs
  18. 10 0
      codegraph-kernel/src/kotlin.rs
  19. 37 17
      codegraph-kernel/src/lib.rs
  20. 6 0
      codegraph-kernel/src/lua.rs
  21. 12 0
      codegraph-kernel/src/php.rs
  22. 7 0
      codegraph-kernel/src/python.rs
  23. 6 0
      codegraph-kernel/src/rlang.rs
  24. 9 0
      codegraph-kernel/src/ruby.rs
  25. 80 60
      codegraph-kernel/src/rustlang.rs
  26. 12 0
      codegraph-kernel/src/scala.rs
  27. 273 0
      codegraph-kernel/src/stack.rs
  28. 15 0
      codegraph-kernel/src/swift.rs
  29. 5 0
      codegraph-kernel/src/tsjs/extractors.rs
  30. 3 0
      codegraph-kernel/src/tsjs/mod.rs
  31. 13 3
      docs/design/rust-kernel-migration-plan.md
  32. 25 17
      docs/design/rust-lang-kernel-port-checklist.md
  33. 110 79
      src/bin/codegraph.ts
  34. 41 2
      src/extraction/grammars.ts
  35. 54 2
      src/extraction/index.ts
  36. 43 26
      src/extraction/languages/rust.ts
  37. 6 0
      src/extraction/parse-pool.ts
  38. 35 32
      src/extraction/tree-sitter.ts
  39. 3 2
      src/installer/index.ts
  40. 15 0
      src/resolution/import-resolver.ts
  41. 224 0
      src/resolution/name-matcher.ts
  42. 53 6
      src/sync/watcher.ts

+ 8 - 0
CHANGELOG.md

@@ -23,6 +23,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - A new `deprioritize` setting in `codegraph.json` keeps the paths you name from outranking your product code in search and `codegraph_explore` answers, without removing anything from the index. It takes gitignore-style patterns just like `exclude`, but is ranking-only: helper-script trees, generated output, or optional add-on directories whose generic symbol names (`usage`, `run`, `status`) would otherwise crowd out the code that actually answers a query stay fully indexed and findable — and a query that genuinely targets such a tree still returns it. Thanks @maxmilian. (#982)
 
+- `codegraph install --init` wires up your agents and builds the current project's index in one command, and `codegraph init --yes` runs without any prompts — so a fresh container or CI job can bootstrap CodeGraph with a single non-interactive line (`codegraph install --yes --init`). The installer still never indexes anything unless you ask for it with the flag, and the usual safety refusal for a home directory or filesystem root applies. (#1578)
+
 ### Fixes
 
 - The MCP server now finds your project when it's launched from a workspace folder above it: if the launch directory has no index of its own but exactly one indexed project sits below it (a repo container, an agent workspace, a monorepo root), that project becomes the session's default — live file watching and the shared daemon included — instead of every tool call failing until a `projectPath` or `--path` is supplied. Thanks @nakisen. (#1606)
@@ -75,6 +77,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Files skipped because they are too large or repeatedly fail to parse are now recorded with the reason, so unchanged rejected files are no longer rediscovered and retried on every status check and sync — and a later successful parse of such a file replaces the record with its real symbols. Thanks @netbrah for the exceptional failure analysis behind this batch, and @danusha2345 for the fixes. (#1557)
 - C/C++ function-pointer analysis now bounds its compiled-pattern caches, so very large repositories can no longer exhaust the JavaScript engine's regular-expression code space during indexing. (#1559)
 - JSX rendering analysis now runs only on JavaScript-family files, so JSX-looking strings in C/C++ (or any other language) no longer create impossible call edges — in pure-C projects and in mixed-language monorepos alike. (#1560)
+- A C++ `.h` header whose only C++ construct is a plain derived type — `struct Derived : Base` with no export macro, `class` keyword, or access section — is now recognized as C++ (previously only the export-macro form was). Such a header was read as C, so the derived struct vanished from the index and a phantom function named after the base type appeared in its place. The check now also covers the whole file rather than its first few kilobytes, so a long C-compatible preamble no longer hides the signal. Re-index after upgrading to pick up affected headers. Thanks @Jaysenpeng. (#1592)
+- Editing `codegraph.json`'s `exclude` or `include` (or a `.gitignore`) while the MCP server is running now takes effect immediately. Previously the running file watcher kept the scope it had when it started, so a newly excluded file was removed by `codegraph sync` and then quietly re-added by the watcher seconds later — which looked like `exclude` not working at all — until the server was restarted. A scope change now refreshes the watcher and triggers a full reconcile, and a changed file the watcher hands to sync is re-checked against the current scope first, so the CLI and the live server can no longer disagree about what belongs in the index. Thanks @K1nG11. (#1590)
+- Indexing no longer crashes the whole process — a segmentation fault with no message and no partial index — on a C/C++ (or any other) file with extremely deep nesting, such as the parser stress-test fixtures in the clang and gcc test suites or a fuzzer corpus. Such a file is now handed to the fallback parser and recorded with a parse warning while the rest of the repository indexes normally. Thanks @apollo600 for the exact diagnosis. (#1581)
+- Calls to the methods of an exported object-literal constant — `export const api = { call() { … } }` used as a module's namespace, a common way to organize a TypeScript API surface — now resolve to the method, both in the defining file and through imports. Previously such a call linked to nothing (or to the constant itself), so `codegraph callers` and impact analysis reported zero callers for methods that are called from everywhere. Re-index after upgrading to pick up the edges. Thanks @IAliceBobI for the precise report and root-cause. (#1573)
+- Methods implemented in a generic or lifetime-parameterized `impl` block (`impl<T> Source for BufSource<T>`, `impl<'a> Iterator for Parents<'a>`) are now recorded under the implementing type instead of the trait. Previously such a method could not be found by its type — "who calls `BufSource::read`" had no answer — and it collided with the trait's own declaration, which could even invent a call-graph edge out of an impl body that contains no call at all. Impls on a reference (`impl Trait for &Foo`) and on a module-qualified type (`impl Trait for m::Foo`) are attributed to their type too. Re-index after upgrading. Thanks @Dshuishui. (#1588) (Rust)
+- A method call on a struct field — `self.inner.run()` with `inner: Inner` — now resolves to the method on the field's declared type. Previously the call was reduced to the bare method name and matched whichever same-named method was nearest, which was often the calling method itself, recording recursion that isn't in the source (a few hundred such self-edges in ripgrep alone), or a method of an unrelated type. References and `Box`/`Rc`/`Arc` fields are looked through, as Rust's own method calls are; a field whose type is external (a std or third-party type), a generic parameter, or a container like `Option`/`Vec` is left unresolved rather than guessed. Re-index after upgrading. Thanks @Dshuishui. (#1585) (Rust)
 
 ## [1.5.0] - 2026-07-21
 

+ 3 - 1
README.md

@@ -388,6 +388,7 @@ The installer **wires up your agents only — it does not index your code.** Aft
 
 ```bash
 codegraph install --yes                              # auto-detect agents, install global
+codegraph install --yes --init                       # same, then build the current project's index (one-shot bootstrap)
 codegraph install --target=cursor,claude --yes       # explicit target list
 codegraph install --target=auto --location=local     # detected agents, project-local
 codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes  # GitHub Copilot everywhere
@@ -400,6 +401,7 @@ codegraph install --print-config copilot-vscode      # same, for Copilot in VS C
 | `--target` | `auto`, `all`, `none`, or csv (`claude,cursor,...`) | prompt |
 | `--location` | `global`, `local` | prompt |
 | `--yes` | (boolean) | prompt every step |
+| `--init` | (boolean) run `codegraph init` in the current directory after wiring agents | — |
 | `--no-permissions` | (boolean) skip Claude auto-allow list | permissions on |
 | `--print-config <id>` | dump snippet for one agent and exit | — |
 
@@ -414,7 +416,7 @@ cd your-project
 codegraph init
 ```
 
-Builds the per-project knowledge graph index, which then auto-syncs on every file change. A single global `codegraph install` works in every project you open — no need to re-run the installer per project.
+Builds the per-project knowledge graph index, which then auto-syncs on every file change. A single global `codegraph install` works in every project you open — no need to re-run the installer per project. Add `--yes` to skip every prompt (scripts / CI / container bootstraps).
 
 That's it — your agent will use CodeGraph tools automatically when a `.codegraph/` directory exists.
 

+ 108 - 0
__tests__/cli-install-init.test.ts

@@ -0,0 +1,108 @@
+/**
+ * `codegraph install --init` and `codegraph init --yes` (#1578): the one-shot,
+ * non-interactive "wire agents + build this project's index" bootstrap a fresh
+ * container / CI job needs.
+ *
+ * Exercised end-to-end against the built binary so the CLI wiring (the shared
+ * `runInit` flow, the flag plumbing, exit codes) is what's covered. Every run
+ * uses `--target none`, so the installer touches no agent config on the
+ * machine running the suite; the only side effect is the temp project's
+ * `.codegraph/`.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { execFileSync } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+interface RunResult {
+  status: number;
+  stdout: string;
+  stderr: string;
+}
+
+/** Run the CLI with stdin closed — a prompt that blocks would hang / fail here. */
+function runCodegraph(args: string[], cwd: string): RunResult {
+  try {
+    const stdout = execFileSync(process.execPath, [BIN, ...args], {
+      cwd,
+      encoding: 'utf-8',
+      env: {
+        ...process.env,
+        CODEGRAPH_NO_DAEMON: '1',
+        CODEGRAPH_TELEMETRY: '0',
+        DO_NOT_TRACK: '1',
+        NO_COLOR: '1',
+      },
+      stdio: ['ignore', 'pipe', 'pipe'],
+      timeout: 120_000,
+    });
+    return { status: 0, stdout, stderr: '' };
+  } catch (err) {
+    const e = err as { status?: number | null; stdout?: string | Buffer; stderr?: string | Buffer };
+    return {
+      status: e.status ?? -1,
+      stdout: String(e.stdout ?? ''),
+      stderr: String(e.stderr ?? ''),
+    };
+  }
+}
+
+describe('codegraph install --init / init --yes (#1578)', () => {
+  let tempDir: string;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-install-init-'));
+    fs.writeFileSync(
+      path.join(tempDir, 'a.ts'),
+      `export function greet(name: string) { return hello(name); }\n` +
+        `export function hello(n: string) { return 'hi ' + n; }\n`,
+    );
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('install --yes --target none --init builds the current project\'s index in one command', () => {
+    const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], tempDir);
+    expect(r.status, r.stdout + r.stderr).toBe(0);
+    // The installer ran (and had nothing to wire) …
+    expect(r.stdout).toContain('No agent targets selected');
+    // … and the init ran afterwards, in cwd.
+    expect(r.stdout).toContain(`Initialized in ${fs.realpathSync(tempDir)}`);
+    expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true);
+  });
+
+  it('install --init on an already-initialized project reports that and still exits 0', () => {
+    expect(runCodegraph(['init', '--yes'], tempDir).status).toBe(0);
+    const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], tempDir);
+    expect(r.status, r.stdout + r.stderr).toBe(0);
+    expect(r.stdout).toContain('Already initialized');
+  });
+
+  it('install --init refuses an unsafe root (filesystem root) with exit code 1, like init does', () => {
+    // `/` (or the drive root on Windows) is the canonical unsafe root: the
+    // refusal fires before anything is created, so nothing is written there.
+    const root = path.parse(process.cwd()).root;
+    const r = runCodegraph(['install', '--yes', '--target', 'none', '--init'], root);
+    expect(r.status).toBe(1);
+    expect(r.stdout).toContain('Refusing to initialize');
+    expect(fs.existsSync(path.join(root, '.codegraph'))).toBe(false);
+  });
+
+  it('init --yes runs non-interactively with stdin closed and builds the index', () => {
+    const r = runCodegraph(['init', '--yes'], tempDir);
+    expect(r.status, r.stdout + r.stderr).toBe(0);
+    expect(r.stdout).toContain('Initialized in');
+    expect(fs.existsSync(path.join(tempDir, '.codegraph', 'codegraph.db'))).toBe(true);
+  });
+
+  it('documents the new flags in --help', () => {
+    expect(runCodegraph(['init', '--help'], tempDir).stdout).toMatch(/-y, --yes\b/);
+    expect(runCodegraph(['install', '--help'], tempDir).stdout).toMatch(/-i, --init\b/);
+  });
+});

+ 187 - 0
__tests__/extraction.test.ts

@@ -175,6 +175,53 @@ class ENGINE_API UNetConnectionRepControl : public UObject
     expect(detectLanguage('cfoo.h', '#ifndef CFOO_H\nstruct Point { int x; int y; };\nvoid f(struct Point p);\n#endif\n')).toBe('c');
   });
 
+  it('should detect a .h whose only C++ signal is a plain base clause as cpp (#1592)', () => {
+    // No export macro, no `class` keyword, no access section, no `virtual`:
+    // the derived struct's base clause is the only C++ construct, and the
+    // #1159 branch only knows the macro-annotated form. Misdetected as C, the
+    // C extractor drops `Derived` and mints a phantom `function Base`.
+    expect(detectLanguage('min.h', 'struct Base {};\nstruct Derived : Base {};\n')).toBe('cpp');
+    expect(detectLanguage('pub.h', 'struct Derived : public Base {};\n')).toBe('cpp');
+    expect(detectLanguage('scoped.h', 'struct Derived : ns::Base {};\n')).toBe('cpp');
+    expect(detectLanguage('tmpl.h', 'struct Derived : Base<int, Foo<T>> {};\n')).toBe('cpp');
+    expect(detectLanguage('final.h', 'struct Derived final : Base {};\n')).toBe('cpp');
+    expect(detectLanguage('multi.h', 'class Derived : public A, private B\n{\n};\n')).toBe('cpp');
+    expect(detectLanguage('virt.h', 'struct Derived : virtual Base {};\n')).toBe('cpp');
+
+    // The base clause sits PAST the 8 KB sample, behind a long C-compatible
+    // preamble (guards, defines, plain typedefs) — the second pass must scan
+    // the whole file, not just the sample.
+    const preamble = '#ifndef BIG_H\n#define BIG_H\n' + '#define VALUE_0 0\n'.repeat(700);
+    expect(preamble.length).toBeGreaterThan(8192);
+    expect(detectLanguage('big.h', `${preamble}struct Base {};\nstruct Derived : Base {};\n#endif\n`)).toBe('cpp');
+
+    // Controls — all genuine C, none may flip to C++:
+    // a bit-field (`:` after a member name inside the body),
+    expect(detectLanguage('bits.h', 'struct S { unsigned int a : 3; unsigned int b : 5; };\n')).toBe('c');
+    // a ternary whose `:` follows a `sizeof(struct …)` / cast,
+    expect(detectLanguage('tern.h', 'static inline int sz(int x) { return x ? sizeof(struct foo) : 0; }\n#define P(a,b) ((a) ? (struct foo *)(a) : (b))\n')).toBe('c');
+    // a label / identifier that merely starts with `struct`,
+    expect(detectLanguage('label.h', 'static void g(void) {\nstruct_end:\n  return;\n}\nint struct_a, struct_b;\n')).toBe('c');
+    // a doc comment whose prose reads like a base clause,
+    expect(detectLanguage('doc.h', '/* struct timeval: seconds, microseconds */\nstruct timeval { long tv_sec; long tv_usec; };\n// struct foo: x, y\n')).toBe('c');
+    // and the two existing controls.
+    expect(detectLanguage('cfoo.h', '#ifndef CFOO_H\nstruct Point { int x; int y; };\nvoid f(struct Point p);\n#endif\n')).toBe('c');
+    expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
+  });
+
+  it('should extract a derived struct from a plain base-clause .h, with no phantom function (#1592)', () => {
+    const result = extractFromSource('src/min.h', 'struct Base {};\nstruct Derived : Base {};\n');
+    const derived = result.nodes.find((n) => n.name === 'Derived');
+    expect(derived).toBeDefined();
+    expect(derived?.kind).toBe('struct');
+    expect(derived?.language).toBe('cpp');
+    // The C mis-route read `Derived : Base {}` as a K&R-ish function `Base`
+    // returning `Derived` — that phantom must be gone.
+    expect(result.nodes.some((n) => n.name === 'Base' && n.kind === 'function')).toBe(false);
+    expect(result.nodes.filter((n) => n.name === 'Base')).toHaveLength(1);
+    expect(result.nodes.find((n) => n.name === 'Base')?.kind).toBe('struct');
+  });
+
   it('should return unknown for unsupported extensions', () => {
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
@@ -1131,6 +1178,146 @@ impl Cache for MyCache {
     expect(implRef?.fromNodeId).toBe(myCacheNode?.id);
   });
 
+  it('qualifies methods of a generic or lifetime impl by the implementing type, not the trait (#1588)', () => {
+    const code = `
+pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource { pub n: usize }
+impl Source for FileSource {
+    fn read(&mut self) -> usize { self.n }
+}
+
+pub struct BufSource<T> { pub inner: T }
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize { 0 }
+}
+
+pub struct Parents<'a> { cur: &'a u32 }
+impl<'a> Iterator for Parents<'a> {
+    type Item = u32;
+    fn next(&mut self) -> Option<u32> { None }
+}
+
+pub struct Wrapper { pub n: usize }
+impl Source for &Wrapper {
+    fn read(&mut self) -> usize { 1 }
+}
+
+pub mod m { pub struct Scoped { pub n: usize } }
+impl Source for m::Scoped {
+    fn read(&mut self) -> usize { 2 }
+}
+
+pub struct Own { pub n: usize }
+impl From<u32> for Own {
+    fn from(n: u32) -> Self { Own { n: n as usize } }
+}
+`;
+    const result = extractFromSource('src.rs', code);
+
+    // Every impl method is qualified by the IMPLEMENTING type. Before, a
+    // parameterized implementing type (`BufSource<T>`, `Parents<'a>`, `&Wrapper`)
+    // left the trait's identifier as the only bare type_identifier child of the
+    // impl, so those methods were recorded as `Source::read` / `Iterator::next`.
+    const methodQns = result.nodes
+      .filter((n) => n.kind === 'method')
+      .map((n) => n.qualifiedName)
+      .sort();
+    expect(methodQns).toEqual([
+      'BufSource::read',
+      'FileSource::read',
+      'Own::from',
+      'Parents::next',
+      'Scoped::read',
+      'Source::read',
+      'Wrapper::read',
+    ]);
+    // The trait's qualified name now names exactly one node: its declaration.
+    const traitRead = result.nodes.filter((n) => n.qualifiedName === 'Source::read');
+    expect(traitRead).toHaveLength(1);
+    expect(traitRead[0]!.startLine).toBe(3);
+
+    // The implements back-reference comes FROM the implementing type's node
+    // for every impl shape, named by the trait's full text.
+    const implementsFrom = (typeName: string): string[] => {
+      const typeNode = result.nodes.find((n) => n.name === typeName && n.kind === 'struct');
+      expect(typeNode, typeName).toBeDefined();
+      return result.unresolvedReferences
+        .filter((r) => r.referenceKind === 'implements' && r.fromNodeId === typeNode!.id)
+        .map((r) => r.referenceName);
+    };
+    expect(implementsFrom('FileSource')).toEqual(['Source']);
+    expect(implementsFrom('BufSource')).toEqual(['Source']);
+    expect(implementsFrom('Parents')).toEqual(['Iterator']);
+    expect(implementsFrom('Wrapper')).toEqual(['Source']);
+    expect(implementsFrom('Scoped')).toEqual(['Source']);
+    expect(implementsFrom('Own')).toEqual(['From<u32>']);
+
+    // …and the owner `contains` edge lands on the implementing type too.
+    const buf = result.nodes.find((n) => n.name === 'BufSource' && n.kind === 'struct')!;
+    const bufRead = result.nodes.find((n) => n.qualifiedName === 'BufSource::read')!;
+    expect(
+      result.edges.some((e) => e.kind === 'contains' && e.source === buf.id && e.target === bufRead.id)
+    ).toBe(true);
+  });
+
+  it('keeps the owner-field shape for `self.<field>.<method>()` and collapses every other receiver (#1585)', () => {
+    const code = `
+pub struct Outer { pub inner: Inner, pub deep: Deep }
+impl Outer {
+    pub fn run(&mut self) {
+        self.inner.run();
+        self.deep.inner.run();
+        self.make().run();
+        (self.inner).run();
+        self.run();
+        let local = Inner { n: 0 };
+        local.run();
+    }
+}
+`;
+    const result = extractFromSource('outer.rs', code);
+    const calls = result.unresolvedReferences
+      .filter((r) => r.referenceKind === 'calls')
+      .map((r) => r.referenceName);
+    // Exactly one call keeps the `self.<field>` prefix — the single-hop field
+    // receiver whose type the resolver can read off the owner struct.
+    expect(calls.filter((c) => c.startsWith('self.'))).toEqual(['self.inner.run']);
+    // A local receiver keeps its name as before…
+    expect(calls).toContain('local.run');
+    // …and the deeper chain, the call receiver, the parenthesized receiver and
+    // the bare `self` receiver all still collapse to the method name.
+    expect(calls.filter((c) => c === 'run')).toHaveLength(4);
+    expect(calls).toContain('make');
+    const outerRun = result.nodes.find((n) => n.qualifiedName === 'Outer::run');
+    expect(outerRun).toBeDefined();
+    const fieldRef = result.unresolvedReferences.find((r) => r.referenceName === 'self.inner.run');
+    expect(fieldRef?.fromNodeId).toBe(outerRun!.id);
+    expect(fieldRef?.line).toBe(5);
+  });
+
+  it('gives no receiver to an impl whose target names no single type', () => {
+    // A tuple / `dyn Trait` / primitive implementing type has no struct to
+    // hang the methods off, so they are extracted as plain functions — the
+    // pre-#1588 behavior for these shapes, minus the trait mis-qualification.
+    const code = `
+pub trait Base { fn id(&self) -> u32; }
+impl Base for (u32, u32) {
+    fn id(&self) -> u32 { 0 }
+}
+impl Base for dyn Base {
+    fn id(&self) -> u32 { 1 }
+}
+`;
+    const result = extractFromSource('src.rs', code);
+    const ids = result.nodes.filter((n) => n.name === 'id');
+    expect(ids.map((n) => n.qualifiedName).sort()).toEqual(['Base::id', 'id', 'id']);
+    expect(ids.filter((n) => n.kind === 'function')).toHaveLength(2);
+    expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'implements')).toHaveLength(0);
+  });
+
   it('should extract trait supertraits as extends references', () => {
     const code = `
 pub trait Display {}

+ 75 - 0
__tests__/fixtures/kernel-parity/torture.rs

@@ -72,6 +72,16 @@ impl Widget {
         self.n * mul()
     }
 
+    /// Receiver shapes (#1585): only `self.<field>.<method>()` keeps the
+    /// owner-field prefix; deeper / parenthesized / call / bare-self collapse.
+    fn via_field(&self) -> u32 {
+        self.field.deep_call();
+        self.field.z.clone();
+        self.method_a().chain_b();
+        (self.field).deep_call();
+        self.area()
+    }
+
     fn clone_self(&self) -> Self {
         Self::assoc();
         Widget {
@@ -107,6 +117,71 @@ impl Render for Container<u32> {
     fn render(&self) {}
 }
 
+/// Receiver = the impl_item's `type` field (#1588): generic, lifetime,
+/// reference, scoped, and generic-trait impls all qualify by the TYPE.
+pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource {
+    pub n: usize,
+}
+
+impl Source for FileSource {
+    fn read(&mut self) -> usize {
+        self.n
+    }
+}
+
+pub struct BufSource<T> {
+    pub inner: T,
+}
+
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize {
+        0
+    }
+}
+
+pub struct Parents<'a> {
+    cur: &'a u32,
+}
+
+impl<'a> Iterator for Parents<'a> {
+    type Item = u32;
+    fn next(&mut self) -> Option<u32> {
+        None
+    }
+}
+
+impl<T: Clone> Container<T> {
+    fn dup(&self) -> T {
+        self.item.clone()
+    }
+}
+
+impl Base for &Widget {}
+
+impl<T> Render for &mut BufSource<T> {
+    fn render(&self) {}
+}
+
+impl Base for self::Deep {}
+
+impl From<u32> for FileSource {
+    fn from(n: u32) -> Self {
+        FileSource { n: n as usize }
+    }
+}
+
+impl Base for (u32, u32) {}
+
+impl Render for dyn Base {
+    fn render(&self) {}
+}
+
+impl Base for u32 {}
+
 impl Later {
     fn touch(&self) {}
 }

+ 281 - 0
__tests__/kernel-deep-nesting.test.ts

@@ -0,0 +1,281 @@
+/**
+ * Deep-nesting safety for the native kernel (#1581).
+ *
+ * The kernel's per-language walkers recurse once per AST level. tree-sitter's
+ * parser is iterative, so a pathologically nested file — clang's
+ * `clang/test/Parser/parser_overflow.c` nests 16,384 `{`; fuzzer corpora go
+ * deeper — parses fine and then overflowed the WALKER's native stack. A native
+ * overflow is uncatchable: the parse worker is a thread of the `codegraph`
+ * process, so the SIGSEGV killed the whole indexer with no message, no partial
+ * index, no per-file fallback. Worker threads get Node's 4 MiB default stack;
+ * the 8 MiB main thread only moved the cliff (100k levels still died).
+ *
+ * The kernel now guards its recursion against the calling thread's real stack
+ * bounds (codegraph-kernel/src/stack.rs) and turns an imminent overflow into
+ * its `defer:` routing signal, so the file takes the wasm path — whose walker
+ * catches its own JS `RangeError` per file and stores a partial result with a
+ * `parse_error`. These tests pin that contract on every default-routed
+ * language, on the main thread AND inside a default-sized worker, and
+ * end-to-end through the built CLI.
+ *
+ * Like the other kernel suites: skipped without a staged .node; CI that
+ * builds the kernel sets CODEGRAPH_KERNEL_EXPECT=1 so a missing binary FAILS.
+ */
+
+import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { execFileSync } from 'child_process';
+import { Worker } from 'worker_threads';
+import { extractFromSource } from '../src/extraction';
+import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
+import { kernelRoutes, resetKernelForTests } from '../src/extraction/kernel';
+import type { Language } from '../src/types';
+
+const REPO = path.resolve(__dirname, '..');
+const KERNEL_PATH = path.join(
+  REPO,
+  'codegraph-kernel',
+  'prebuilds',
+  `${process.platform}-${process.arch}`,
+  'codegraph-kernel.node'
+);
+const kernelBuilt = fs.existsSync(KERNEL_PATH);
+const expectKernel = process.env.CODEGRAPH_KERNEL_EXPECT === '1';
+const BIN = path.join(REPO, 'dist', 'bin', 'codegraph.js');
+const DIST_KERNEL = path.join(REPO, 'dist', 'extraction', 'kernel');
+const distBuilt = fs.existsSync(BIN) && fs.existsSync(path.join(DIST_KERNEL, 'index.js'));
+
+/** Deep enough to overflow an 8 MiB main-thread stack on every walker. */
+const PARENS_DEPTH = 60_000;
+/** The reporter's exact shape: clang's parser_overflow.c nests 16,384 `{`. */
+const BRACES_DEPTH = 16_384;
+
+const CANDIDATES: Language[] = [
+  'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'c', 'cpp',
+  'rust', 'csharp', 'ruby', 'php', 'swift', 'kotlin', 'r', 'lua', 'luau', 'scala', 'dart',
+];
+
+const EXT: Record<string, string> = {
+  typescript: 'ts', tsx: 'tsx', javascript: 'js', jsx: 'jsx', java: 'java', python: 'py',
+  go: 'go', c: 'c', cpp: 'cpp', rust: 'rs', csharp: 'cs', ruby: 'rb', php: 'php',
+  swift: 'swift', kotlin: 'kt', r: 'R', lua: 'lua', luau: 'luau', scala: 'scala', dart: 'dart',
+};
+
+/** A function `f` whose body is a `depth`-deep parenthesized expression. */
+function deepParens(language: Language, depth: number): string {
+  const open = '('.repeat(depth);
+  const close = ')'.repeat(depth);
+  switch (language) {
+    case 'typescript': case 'tsx': case 'javascript': case 'jsx':
+      return `function f() { return ${open}1${close}; }\n`;
+    case 'java':
+      return `class A {\n  int f() { return ${open}1${close}; }\n}\n`;
+    case 'python':
+      return `def f():\n    return ${open}1${close}\n`;
+    case 'go':
+      return `package p\n\nfunc f() int { return ${open}1${close} }\n`;
+    case 'c':
+      return `int f(void) { return ${open}1${close}; }\n`;
+    case 'cpp':
+      return `int f() { return ${open}1${close}; }\n`;
+    case 'rust':
+      return `fn f() -> i32 { ${open}1${close} }\n`;
+    case 'csharp':
+      return `class A {\n  int f() { return ${open}1${close}; }\n}\n`;
+    case 'ruby':
+      return `def f\n  ${open}1${close}\nend\n`;
+    case 'php':
+      return `<?php\nfunction f() { return ${open}1${close}; }\n`;
+    case 'swift':
+      return `func f() -> Int { return ${open}1${close} }\n`;
+    case 'kotlin':
+      return `fun f(): Int { return ${open}1${close} }\n`;
+    case 'r':
+      return `f <- function() {\n  ${open}1${close}\n}\n`;
+    case 'lua': case 'luau':
+      return `local function f()\n  return ${open}1${close}\nend\n`;
+    case 'scala':
+      return `object A {\n  def f(): Int = ${open}1${close}\n}\n`;
+    case 'dart':
+      return `int f() { return ${open}1${close}; }\n`;
+    default:
+      throw new Error(`no deep fixture for ${language}`);
+  }
+}
+
+/** The reporter's repro: a C function body of `depth` nested blocks. */
+function deepBraces(depth: number): string {
+  return `void foo(void) {\n${'{'.repeat(depth)}${'}'.repeat(depth)}\n}\n`;
+}
+
+const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS', 'CODEGRAPH_KERNEL_PATH'] as const;
+let savedEnv: Record<string, string | undefined>;
+
+describe.skipIf(!kernelBuilt)('kernel deep-nesting guard (#1581)', () => {
+  let routed: Language[] = [];
+
+  beforeAll(async () => {
+    resetKernelForTests();
+    routed = CANDIDATES.filter((l) => kernelRoutes(l));
+    expect(routed.length).toBeGreaterThan(0);
+    await initGrammars();
+    await loadGrammarsForLanguages(routed);
+  });
+
+  beforeEach(() => {
+    savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
+    resetKernelForTests();
+  });
+
+  afterEach(() => {
+    for (const k of ENV_KEYS) {
+      if (savedEnv[k] === undefined) delete process.env[k];
+      else process.env[k] = savedEnv[k];
+    }
+    resetKernelForTests();
+  });
+
+  it('every default-routed language survives a 60k-deep expression on the main thread', () => {
+    const failures: string[] = [];
+    for (const language of routed) {
+      const file = `deep.${EXT[language]}`;
+      const source = deepParens(language, PARENS_DEPTH);
+      // The ONLY acceptable outcomes: a clean result (the thread's stack was
+      // big enough for the walk), or the wasm fallback's partial result with
+      // its parse_error. A native overflow would have killed this process.
+      const result = extractFromSource(file, source, language);
+      const fn = result.nodes.find((n) => n.name === 'f' && (n.kind === 'function' || n.kind === 'method'));
+      // R's wasm walker mints `f <- function()` only after walking the
+      // assignment's value, so its partial result for a file this deep holds
+      // just the file node — the same shape main's wasm-only path produces
+      // (verified with CODEGRAPH_KERNEL=0). Pre-existing and out of scope
+      // here; what this test pins for R is that the process survives.
+      if (!fn && language !== 'r') failures.push(`${language}: no function node 'f' (nodes=${result.nodes.map((n) => `${n.kind}:${n.name}`).join(',')})`);
+      for (const e of result.errors) {
+        if (!/Maximum call stack|parse_error|Parse error/.test(`${e.code} ${e.message}`)) {
+          failures.push(`${language}: unexpected error ${e.message}`);
+        }
+      }
+    }
+    expect(failures).toEqual([]);
+  }, 120_000);
+
+  it("the reporter's 16,384-brace C file is indexed (partial) instead of killing the process", () => {
+    const result = extractFromSource('deep.c', deepBraces(BRACES_DEPTH), 'c');
+    expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'foo')).toBe(true);
+  }, 60_000);
+
+  it('shallow files still take the kernel path (the guard never trips on normal code)', () => {
+    // Sanity for the perf-neutral claim: a 200-deep expression is far inside
+    // any thread's stack, so it must come back clean with no parse_error.
+    for (const language of routed) {
+      const result = extractFromSource(`ok.${EXT[language]}`, deepParens(language, 200), language);
+      expect(result.errors, language).toEqual([]);
+      expect(result.nodes.some((n) => n.name === 'f'), language).toBe(true);
+    }
+  }, 60_000);
+
+  describe.skipIf(!distBuilt)('inside a default-sized (4 MiB) parse worker, through dist/', () => {
+    let tmp: string;
+    beforeEach(() => {
+      tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deep-'));
+    });
+    afterEach(() => {
+      fs.rmSync(tmp, { recursive: true, force: true });
+    });
+
+    /**
+     * Run the kernel's raw extraction for `file` inside a Worker with Node's
+     * DEFAULT resourceLimits — exactly how ParseWorkerPool runs it. Resolves
+     * with the worker's exit code and what it reported; a native overflow
+     * would SIGSEGV/SIGILL this whole vitest process instead.
+     */
+    function runInWorker(file: string, source: string, language: Language): Promise<{ exitCode: number; outcome: string }> {
+      const script = path.join(tmp, 'worker.cjs');
+      fs.writeFileSync(
+        script,
+        [
+          `const { parentPort, workerData } = require('worker_threads');`,
+          `const { tryKernelExtractRaw } = require(${JSON.stringify(DIST_KERNEL)});`,
+          `const raw = tryKernelExtractRaw(workerData.file, workerData.source, workerData.language);`,
+          `parentPort.postMessage(raw ? 'kernel:' + raw.counts.nodes : 'deferred');`,
+        ].join('\n')
+      );
+      return new Promise((resolve, reject) => {
+        let outcome = 'no message';
+        const w = new Worker(script, { workerData: { file, source, language } });
+        w.on('message', (m: string) => { outcome = m; });
+        w.on('error', reject);
+        w.on('exit', (exitCode) => resolve({ exitCode, outcome }));
+      });
+    }
+
+    it("defers the reporter's deep.c instead of crashing the worker", async () => {
+      const r = await runInWorker('deep.c', deepBraces(BRACES_DEPTH), 'c');
+      expect(r.exitCode).toBe(0);
+      expect(r.outcome).toBe('deferred');
+    }, 60_000);
+
+    it('defers a 60k-deep expression in every default-routed language', async () => {
+      for (const language of routed) {
+        const r = await runInWorker(`deep.${EXT[language]}`, deepParens(language, PARENS_DEPTH), language);
+        expect(r.exitCode, language).toBe(0);
+        // Either the guard tripped (deferred) or the walk fit — never a crash.
+        expect(['deferred', 'kernel'].some((p) => r.outcome.startsWith(p)), `${language}: ${r.outcome}`).toBe(true);
+      }
+    }, 180_000);
+
+    it('still extracts a normal file natively in the worker', async () => {
+      const r = await runInWorker('ok.c', 'int add(int a, int b) { return a + b; }\n', 'c');
+      expect(r.exitCode).toBe(0);
+      expect(r.outcome).toMatch(/^kernel:/);
+    }, 30_000);
+  });
+
+  describe.skipIf(!distBuilt)('end-to-end: codegraph init on a repo holding the deep file', () => {
+    let tmp: string;
+    beforeEach(() => {
+      tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deep-cli-'));
+    });
+    afterEach(() => {
+      fs.rmSync(tmp, { recursive: true, force: true });
+    });
+
+    it('exits 0 and records deep.c alongside the normal files', () => {
+      fs.writeFileSync(path.join(tmp, 'deep.c'), deepBraces(BRACES_DEPTH));
+      fs.writeFileSync(path.join(tmp, 'ok.c'), 'int add(int a, int b) { return a + b; }\n');
+      execFileSync(process.execPath, [BIN, 'init', '.'], {
+        cwd: tmp,
+        encoding: 'utf-8',
+        stdio: ['ignore', 'pipe', 'pipe'],
+        timeout: 120_000,
+        env: {
+          ...process.env,
+          CODEGRAPH_NO_DAEMON: '1',
+          CODEGRAPH_WASM_RELAUNCHED: '1',
+          CODEGRAPH_TELEMETRY: '0',
+          DO_NOT_TRACK: '1',
+          CODEGRAPH_NO_PROMPT_HOOK: '1',
+        },
+      });
+      const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite');
+      const db = new DatabaseSync(path.join(tmp, '.codegraph', 'codegraph.db'), { readOnly: true });
+      try {
+        const files = (db.prepare('SELECT path FROM files ORDER BY path').all() as Array<{ path: string }>).map((r) => r.path);
+        expect(files).toEqual(['deep.c', 'ok.c']);
+        const fns = (db.prepare("SELECT name FROM nodes WHERE kind = 'function' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name);
+        expect(fns).toEqual(['add', 'foo']);
+      } finally {
+        db.close();
+      }
+    }, 180_000);
+  });
+});
+
+describe.skipIf(!expectKernel)('kernel presence (CODEGRAPH_KERNEL_EXPECT=1)', () => {
+  it('the staged .node exists so the deep-nesting suite actually ran', () => {
+    expect(kernelBuilt).toBe(true);
+  });
+});

+ 2 - 2
__tests__/kernel-rustlang-parity.test.ts

@@ -4,8 +4,8 @@
  * Asserts the native walker (codegraph-kernel/src/rustlang.rs) produces the
  * SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
  * unresolved refs compared as canonicalized multisets — over the checked-in
- * torture fixture (torture.rs: impl/trait quirks incl. the
- * `impl Trait for Generic<T>` trait-receiver bug, unit-struct skip, phantom
+ * torture fixture (torture.rs: impl/trait quirks incl. generic / lifetime /
+ * reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom
  * const identifiers, use-binding refs incl. nested groups + wildcard-emits-
  * nothing, chained-call re-encode, turbofish, Rocket route macros body-only,
  * fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code

+ 287 - 0
__tests__/resolution.test.ts

@@ -1119,6 +1119,168 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } }
       ).toBe('interface-impl');
     });
 
+    it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => {
+      // `impl<T> Source for BufSource<T>`: the implementing type parses as a
+      // generic_type, so the old positional receiver scan picked the TRAIT.
+      // The impl's `read` was recorded as `Source::read` — unaddressable as
+      // `BufSource::read` — and, carrying the trait's name, the interface-impl
+      // synthesizer treated its body (`{ 0 }`, no call at all) as a second
+      // declaration and gave it a dispatch edge to FileSource's implementation.
+      fs.writeFileSync(
+        path.join(tempDir, 'lib.rs'),
+        `pub trait Source {
+    fn read(&mut self) -> usize;
+}
+
+pub struct FileSource { pub n: usize }
+impl Source for FileSource {
+    fn read(&mut self) -> usize { self.n }
+}
+
+pub struct BufSource<T> { pub inner: T }
+impl<T> Source for BufSource<T> {
+    fn read(&mut self) -> usize { 0 }
+}
+`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      const methods = cg.getNodesByKind('method');
+      const traitDecls = methods.filter((n) => n.qualifiedName === 'Source::read');
+      expect(traitDecls, 'only the declaration carries the trait-qualified name').toHaveLength(1);
+      const traitMethod = traitDecls[0]!;
+      expect(traitMethod.startLine).toBe(2);
+      const fileImpl = methods.find((n) => n.qualifiedName === 'FileSource::read');
+      const bufImpl = methods.find((n) => n.qualifiedName === 'BufSource::read');
+      expect(fileImpl).toBeDefined();
+      expect(bufImpl, 'the generic impl is addressable by its type').toBeDefined();
+
+      const synth = (id: string) =>
+        cg.getOutgoingEdges(id).filter((e) => e.kind === 'calls' && e.provenance === 'heuristic');
+      // Dispatch fans out from the declaration to BOTH implementations…
+      const fromTrait = synth(traitMethod.id);
+      expect(new Set(fromTrait.map((e) => e.target))).toEqual(new Set([fileImpl!.id, bufImpl!.id]));
+      for (const e of fromTrait) {
+        expect(
+          (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy
+        ).toBe('interface-impl');
+        expect(e.line, 'registered at the declaration, never at an impl body').toBe(2);
+      }
+      // …and neither implementation body sprouts a synthesized call of its own.
+      expect(synth(fileImpl!.id)).toHaveLength(0);
+      expect(synth(bufImpl!.id)).toHaveLength(0);
+    });
+
+    // ── Rust `self.<field>.<method>()` receivers (#1585) ───────────────────
+    // A Cargo layout (Cargo.toml + src/) so `use crate::…` paths resolve.
+    function writeRustCrate(root: string, files: Record<string, string>): void {
+      fs.writeFileSync(
+        path.join(root, 'Cargo.toml'),
+        '[package]\nname = "repro"\nversion = "0.1.0"\nedition = "2021"\n'
+      );
+      fs.mkdirSync(path.join(root, 'src'), { recursive: true });
+      for (const [rel, content] of Object.entries(files)) {
+        fs.writeFileSync(path.join(root, 'src', rel), content);
+      }
+    }
+    const callsFrom = (qualifiedName: string) => {
+      const from = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
+      expect(from, qualifiedName).toBeDefined();
+      return cg
+        .getOutgoingEdges(from!.id)
+        .filter((e) => e.kind === 'calls')
+        .map((e) => ({
+          target: cg.getNode(e.target)?.qualifiedName,
+          resolvedBy: (e.metadata as { resolvedBy?: string } | undefined)?.resolvedBy,
+          provenance: e.provenance ?? undefined, // a resolved (non-synthesized) edge stores NULL
+        }));
+    };
+
+    it("resolves `self.field.method()` to the method on the field's declared type, never to the caller itself (#1585)", async () => {
+      // The issue's repro: `Outer::run` forwards to `Inner::run` through the
+      // typed field `inner`. The call used to collapse to the bare name `run`
+      // and exact-match the nearest same-named method — the calling method —
+      // recording recursion the source does not contain.
+      writeRustCrate(tempDir, {
+        'lib.rs': 'pub mod inner;\npub mod outer;\n',
+        'inner.rs': 'pub struct Inner {\n    pub n: usize,\n}\n\nimpl Inner {\n    pub fn run(&mut self) {\n        self.n += 1;\n    }\n}\n',
+        'outer.rs': 'use crate::inner::Inner;\n\npub struct Outer {\n    pub inner: Inner,\n}\n\nimpl Outer {\n    pub fn run(&mut self) {\n        self.inner.run();\n    }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callsFrom('Outer::run')).toEqual([
+        { target: 'Inner::run', resolvedBy: 'instance-method', provenance: undefined },
+      ]);
+    });
+
+    it('leaves a `self.field.method()` call unresolved when the field type is external, instead of guessing a same-named local method', async () => {
+      // `its` is a std type with no project node. Before, `self.its.next()`
+      // became the bare `next`, which exact-matched a local `next` — the
+      // calling method (self-edge) or the unrelated `Other::next` decoy.
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub struct Scanner {\n    its: std::vec::IntoIter<u8>,\n}\n\nimpl Scanner {\n    pub fn next(&mut self) -> Option<u8> {\n        self.its.next()\n    }\n}\n\n' +
+          'pub struct Other { pub n: u8 }\nimpl Other {\n    pub fn next(&mut self) -> Option<u8> {\n        None\n    }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callsFrom('Scanner::next')).toEqual([]);
+    });
+
+    it('looks through references and owning smart pointers, but not through containers (#1585)', async () => {
+      // Method-call auto-deref reaches the pointee of `Box`/`&mut`, so those
+      // fields resolve to `Inner::run`. `Option<Inner>` does not auto-deref —
+      // `self.inner.take()` is Option's method, so it must NOT become
+      // `Inner::take` even though Inner declares a `take` too.
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub struct Inner { pub n: usize }\nimpl Inner {\n    pub fn run(&mut self) { self.n += 1; }\n    pub fn take(&mut self) {}\n}\n\n' +
+          'pub struct Boxed { inner: Box<Inner> }\nimpl Boxed {\n    pub fn go(&mut self) { self.inner.run(); }\n}\n\n' +
+          "pub struct Borrowed<'a> { inner: &'a mut Inner }\nimpl<'a> Borrowed<'a> {\n    pub fn go(&mut self) { self.inner.run(); }\n}\n\n" +
+          'pub struct Optional { inner: Option<Inner> }\nimpl Optional {\n    pub fn go(&mut self) { self.inner.take(); }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callsFrom('Boxed::go').map((c) => c.target)).toEqual(['Inner::run']);
+      expect(callsFrom('Borrowed::go').map((c) => c.target)).toEqual(['Inner::run']);
+      expect(callsFrom('Optional::go')).toEqual([]);
+    });
+
+    it('leaves a call through a generic-typed field unresolved, and keeps genuine `self.method()` recursion (#1585)', async () => {
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub struct Inner { pub n: usize }\nimpl Inner {\n    pub fn run(&mut self) {}\n}\n\n' +
+          'pub struct Holder<T> { item: T }\nimpl<T> Holder<T> {\n    pub fn go(&mut self) { self.item.run(); }\n}\n\n' +
+          'pub struct Countdown { pub n: usize }\nimpl Countdown {\n    pub fn run(&mut self) {\n        if self.n > 0 {\n            self.n -= 1;\n            self.run();\n        }\n    }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+      // `T` names no project type: no edge, and in particular not `Inner::run`.
+      expect(callsFrom('Holder::go')).toEqual([]);
+      // A bare `self` receiver is untouched — real recursion stays a self-edge.
+      expect(callsFrom('Countdown::run').map((c) => c.target)).toEqual(['Countdown::run']);
+    });
+
+    it('resolves a trait-object field to the trait method and typed fields to the right implementation (#1585, #1588)', async () => {
+      // The #1588 repro's second half: `UsesFile::go` / `UsesBuf::go` each
+      // forward through a typed field, and a `Box<dyn Source>` field lands on
+      // the trait's declaration — from which the interface-impl synthesizer
+      // fans out to every implementation.
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub trait Source {\n    fn read(&mut self) -> usize;\n}\n\n' +
+          'pub struct FileSource { pub n: usize }\nimpl Source for FileSource {\n    fn read(&mut self) -> usize { self.n }\n}\n\n' +
+          'pub struct BufSource<T> { pub inner: T }\nimpl<T> Source for BufSource<T> {\n    fn read(&mut self) -> usize { 0 }\n}\n\n' +
+          'pub struct UsesFile { pub src: FileSource }\nimpl UsesFile {\n    pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' +
+          'pub struct UsesBuf { pub src: BufSource<u8> }\nimpl UsesBuf {\n    pub fn go(&mut self) -> usize { self.src.read() }\n}\n\n' +
+          'pub struct UsesDyn { pub src: Box<dyn Source> }\nimpl UsesDyn {\n    pub fn go(&mut self) -> usize { self.src.read() }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+      expect(callsFrom('UsesFile::go').map((c) => c.target)).toEqual(['FileSource::read']);
+      expect(callsFrom('UsesBuf::go').map((c) => c.target)).toEqual(['BufSource::read']);
+      expect(callsFrom('UsesDyn::go').map((c) => c.target)).toEqual(['Source::read']);
+      // …and dispatch continues from the trait declaration to both impls.
+      const fanOut = callsFrom('Source::read').filter((c) => c.provenance === 'heuristic').map((c) => c.target).sort();
+      expect(fanOut).toEqual(['BufSource::read', 'FileSource::read']);
+    });
+
     it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => {
       // `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init)
       // carry the constructor args directly on the declarator — there's no
@@ -2913,6 +3075,131 @@ export function callFromImportedFile(): void {
     }, 30000);
   });
 
+  describe('Object-literal namespace members (#1573)', () => {
+    // `export const api = { call() {…}, get: () => {…} }` used as the module's
+    // API surface: the members are plain functions with bare names inside the
+    // constant's extent, so `api.call()` resolved to nothing in the defining
+    // file and to the CONSTANT through an import — zero callers everywhere.
+    const setup = (files: Record<string, string>) => {
+      const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1573-'));
+      for (const [name, content] of Object.entries(files)) {
+        fs.mkdirSync(path.dirname(path.join(tmpDir, name)), { recursive: true });
+        fs.writeFileSync(path.join(tmpDir, name), content);
+      }
+      return tmpDir;
+    };
+    const callersOf = async (cg: CodeGraph, name: string, kind: string, filePath?: string) => {
+      const target = (await cg.searchNodes(name, { limit: 20 })).find(
+        (r) => r.node.kind === kind && r.node.name === name && (!filePath || r.node.filePath === filePath)
+      );
+      expect(target).toBeDefined();
+      return (await cg.getCallers(target!.node.id)).map((c) => c.node.name).sort();
+    };
+
+    it('resolves same-file and imported calls to the literal member, never to the constant (#1573)', async () => {
+      const tmpDir = setup({
+        'a.ts': `export const obj = { m() { return 1; } };
+export class C { static s() { return 2; } }
+export function sameFileCallers() { return obj.m() + C.s(); }
+`,
+        'b.ts': `import { obj, C } from "./a";
+export function crossFileCaller() { return obj.m() + C.s(); }
+`,
+        // A same-named top-level function elsewhere must never be chosen.
+        'decoy.ts': `export function m() { return 'decoy'; }
+`,
+      });
+      try {
+        const cg = CodeGraph.initSync(tmpDir);
+        await cg.indexAll();
+
+        expect(await callersOf(cg, 'm', 'function', 'a.ts')).toEqual(['crossFileCaller', 'sameFileCallers']);
+        expect(await callersOf(cg, 'm', 'function', 'decoy.ts')).toEqual([]);
+        // The class static next to it resolves exactly as before (#825).
+        expect(await callersOf(cg, 's', 'method')).toEqual(['crossFileCaller', 'sameFileCallers']);
+
+        // The import edge no longer lands on the constant itself.
+        const obj = (await cg.searchNodes('obj', { limit: 5 })).find((r) => r.node.kind === 'constant');
+        expect(obj).toBeDefined();
+        const caller = (await cg.searchNodes('crossFileCaller', { limit: 5 })).find((r) => r.node.kind === 'function');
+        const toConstant = cg
+          .getOutgoingEdges(caller!.node.id)
+          .filter((e) => e.kind === 'calls' && e.target === obj!.node.id);
+        expect(toConstant).toHaveLength(0);
+        cg.close();
+      } finally {
+        fs.rmSync(tmpDir, { recursive: true, force: true });
+      }
+    }, 30000);
+
+    it('covers method and arrow-property members, and skips a declaration nested in a member body', async () => {
+      const tmpDir = setup({
+        'src/api.ts': `export const api = {
+  call: () => { return 1; },
+  get() {
+    function call() { return 'nested in get, not a member'; }
+    return call();
+  },
+};
+`,
+        'src/use.ts': `import { api } from './api';
+export function useCall() { return api.call(); }
+export function useGet() { return api.get(); }
+`,
+      });
+      try {
+        const cg = CodeGraph.initSync(tmpDir);
+        await cg.indexAll();
+
+        const calls = (await cg.searchNodes('call', { limit: 20 }))
+          .map((r) => r.node)
+          .filter((n) => n.name === 'call' && n.filePath === 'src/api.ts' && (n.kind === 'function' || n.kind === 'method'));
+        // The member is the arrow on line 2; the nested declaration sits
+        // inside `get`'s body on line 4 and must never be taken for it.
+        const member = calls.find((n) => n.startLine === 2);
+        const nested = calls.find((n) => n.startLine === 4);
+        expect(member).toBeDefined();
+        expect(nested).toBeDefined();
+        expect((await cg.getCallers(member!.id)).map((c) => c.node.name)).toContain('useCall');
+        expect((await cg.getCallers(nested!.id)).map((c) => c.node.name)).not.toContain('useCall');
+        expect(await callersOf(cg, 'get', 'function')).toEqual(['useGet']);
+        cg.close();
+      } finally {
+        fs.rmSync(tmpDir, { recursive: true, force: true });
+      }
+    }, 30000);
+
+    it('leaves a non-literal value receiver on its existing path', async () => {
+      const tmpDir = setup({
+        'src/mk.ts': `export function m() { return 'top-level, unrelated to obj'; }
+export const obj = makeObj();
+export function makeObj(): { m(): number } { return { m: () => 1 } as { m(): number }; }
+export function localUse() { return obj.m(); }
+`,
+        'src/use.ts': `import { obj } from './mk';
+export function remoteUse() { return obj.m(); }
+`,
+      });
+      try {
+        const cg = CodeGraph.initSync(tmpDir);
+        await cg.indexAll();
+        // `obj` holds a call result, not a literal: the same-named top-level
+        // `m` lies outside its declaration, so containment finds nothing and
+        // both calls keep today's behavior (unresolved in the defining file;
+        // the constant edge through the import) rather than guessing.
+        expect(await callersOf(cg, 'm', 'function')).toEqual([]);
+        const obj = (await cg.searchNodes('obj', { limit: 5 })).find((r) => r.node.kind === 'constant');
+        const remote = (await cg.searchNodes('remoteUse', { limit: 5 })).find((r) => r.node.kind === 'function');
+        expect(
+          cg.getOutgoingEdges(remote!.node.id).some((e) => e.kind === 'calls' && e.target === obj!.node.id)
+        ).toBe(true);
+        cg.close();
+      } finally {
+        fs.rmSync(tmpDir, { recursive: true, force: true });
+      }
+    }, 30000);
+  });
+
   describe('C++ namespace-qualified static method calls to out-of-line definitions (#1291)', () => {
     // The issue's exact shape: nested types + out-of-line static method
     // definition inside `namespace simulator { }` in the .cpp, called via the

+ 29 - 0
__tests__/sync.test.ts

@@ -851,4 +851,33 @@ describe('Scoped sync parity (#watcher-scoped)', () => {
     // b.ts untouched and still present
     expect(cg.searchNodes('beta').length).toBeGreaterThan(0);
   });
+
+  it('a scoped path that codegraph.json now excludes is removed, never re-parsed (#1590)', async () => {
+    // The daemon's watcher hands sync the exact edited path. If the project's
+    // scope changed underneath it, that path must be treated the way the full
+    // scan treats it — out of scope, hence gone — never parsed on trust.
+    const cfg = path.join(testDir, 'codegraph.json');
+    fs.writeFileSync(cfg, JSON.stringify({ exclude: ['src/b.ts'] }));
+    fs.writeFileSync(path.join(testDir, 'src', 'b.ts'), `export function beta() { return 2; }\nexport function gamma() { return 3; }`);
+    const scoped = await cg.sync({ paths: ['src/b.ts'] });
+    expect(scoped.filesRemoved).toBe(1);
+    expect(scoped.filesModified).toBe(0);
+    expect(scoped.filesAdded).toBe(0);
+    expect(cg.searchNodes('gamma').length).toBe(0);
+    expect(cg.searchNodes('beta').filter((r) => r.node.filePath === 'src/b.ts').length).toBe(0);
+    // Idempotent: the file stays out on a repeat scoped sync.
+    const again = await cg.sync({ paths: ['src/b.ts'] });
+    expect(again.filesRemoved).toBe(0);
+    expect(again.filesAdded).toBe(0);
+
+    // Dropping the exclude readmits it through the same scoped path. The
+    // scope matcher is mtime-keyed, so give the rewrite a distinct mtime even
+    // on a coarse-timestamp filesystem.
+    fs.writeFileSync(cfg, JSON.stringify({}));
+    const later = new Date(Date.now() + 5000);
+    fs.utimesSync(cfg, later, later);
+    const readmitted = await cg.sync({ paths: ['src/b.ts'] });
+    expect(readmitted.filesAdded).toBe(1);
+    expect(cg.searchNodes('gamma').length).toBe(1);
+  });
 });

+ 128 - 0
__tests__/watcher.test.ts

@@ -545,6 +545,134 @@ describe('FileWatcher', () => {
     });
   });
 
+  describe('scope config refresh (#1590)', () => {
+    // The matcher used to be built once in start() and kept for the watcher's
+    // lifetime, so a `codegraph.json` written AFTER the daemon started was
+    // invisible to the live watcher while `codegraph sync` honoured it: the
+    // CLI removed a newly excluded file and the watcher re-added it.
+    it('a codegraph.json edit rebuilds the matcher and forces a full sync', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      // Scope the project after the watcher is already running.
+      fs.mkdirSync(path.join(testDir, 'skipme'));
+      fs.writeFileSync(path.join(testDir, 'skipme', 'b.ts'), 'export const b = 1;\n');
+      fs.writeFileSync(path.join(testDir, 'codegraph.json'), JSON.stringify({ exclude: ['skipme/'] }));
+      __emitWatchEventForTests(testDir, 'codegraph.json');
+
+      // The config edit schedules a FULL sync (no scoped path list): only the
+      // scan-diff can find the files the new scope drops or admits.
+      await waitFor(() => syncFn.mock.calls.length > 0);
+      expect(syncFn.mock.calls.length).toBe(1);
+      expect(syncFn.mock.calls[0]![0]).toBeUndefined();
+      expect(watcher.getPendingFiles()).toEqual([]);
+      await new Promise((r) => setTimeout(r, 50)); // let runSync settle
+
+      // An edit inside the newly excluded tree is dropped by the LIVE matcher:
+      // not pending, and no sync scheduled for it.
+      __emitWatchEventForTests(testDir, 'skipme/b.ts');
+      expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('skipme/b.ts');
+      await new Promise((r) => setTimeout(r, 300)); // > debounce
+      expect(syncFn.mock.calls.length).toBe(1);
+
+      // In-scope edits still sync, scoped to the edited path as before.
+      __emitWatchEventForTests(testDir, 'src/index.ts');
+      await waitFor(() => syncFn.mock.calls.length > 1);
+      expect(syncFn.mock.calls[1]![0]).toEqual(['src/index.ts']);
+
+      watcher.stop();
+    });
+
+    it('a root .gitignore edit is a scope change too', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      fs.mkdirSync(path.join(testDir, 'gen'));
+      fs.writeFileSync(path.join(testDir, 'gen', 'out.ts'), 'export const g = 1;\n');
+      fs.writeFileSync(path.join(testDir, '.gitignore'), 'gen/\n');
+      __emitWatchEventForTests(testDir, '.gitignore');
+
+      await waitFor(() => syncFn.mock.calls.length > 0);
+      expect(syncFn.mock.calls[0]![0]).toBeUndefined();
+      await new Promise((r) => setTimeout(r, 50));
+
+      __emitWatchEventForTests(testDir, 'gen/out.ts');
+      expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('gen/out.ts');
+      await new Promise((r) => setTimeout(r, 300));
+      expect(syncFn.mock.calls.length).toBe(1);
+
+      watcher.stop();
+    });
+
+    it('a nested .gitignore inside the scope forces a full sync', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      fs.mkdirSync(path.join(testDir, 'sub'));
+      fs.writeFileSync(path.join(testDir, 'sub', '.gitignore'), 'build/\n');
+      __emitWatchEventForTests(testDir, 'sub/.gitignore');
+
+      await waitFor(() => syncFn.mock.calls.length > 0);
+      expect(syncFn.mock.calls[0]![0]).toBeUndefined();
+
+      watcher.stop();
+    });
+
+    it('a .gitignore under an ignored tree (npm install churn) schedules nothing', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      fs.mkdirSync(path.join(testDir, 'node_modules', 'pkg'), { recursive: true });
+      fs.writeFileSync(path.join(testDir, 'node_modules', 'pkg', '.gitignore'), 'lib/\n');
+      __emitWatchEventForTests(testDir, 'node_modules/pkg/.gitignore');
+
+      await new Promise((r) => setTimeout(r, 300));
+      expect(syncFn).not.toHaveBeenCalled();
+
+      watcher.stop();
+    });
+
+    it('removing the exclude again readmits the tree', async () => {
+      const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
+      const watcher = newWatcher(syncFn, { debounceMs: 100 });
+      watcher.start();
+      await watcher.waitUntilReady();
+
+      fs.mkdirSync(path.join(testDir, 'skipme'));
+      fs.writeFileSync(path.join(testDir, 'skipme', 'b.ts'), 'export const b = 1;\n');
+      const cfg = path.join(testDir, 'codegraph.json');
+      fs.writeFileSync(cfg, JSON.stringify({ exclude: ['skipme/'] }));
+      __emitWatchEventForTests(testDir, 'codegraph.json');
+      await waitFor(() => syncFn.mock.calls.length > 0);
+      await new Promise((r) => setTimeout(r, 50));
+      __emitWatchEventForTests(testDir, 'skipme/b.ts');
+      expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain('skipme/b.ts');
+
+      // Drop the exclude. The loader is mtime-keyed, so make sure the second
+      // write carries a distinct mtime even on a coarse-timestamp filesystem.
+      fs.writeFileSync(cfg, JSON.stringify({}));
+      const later = new Date(Date.now() + 5000);
+      fs.utimesSync(cfg, later, later);
+      __emitWatchEventForTests(testDir, 'codegraph.json');
+      await waitFor(() => syncFn.mock.calls.length > 1);
+      expect(syncFn.mock.calls[1]![0]).toBeUndefined();
+      await new Promise((r) => setTimeout(r, 50));
+
+      __emitWatchEventForTests(testDir, 'skipme/b.ts');
+      expect(watcher.getPendingFiles().map((p) => p.path)).toContain('skipme/b.ts');
+
+      watcher.stop();
+    });
+  });
+
   describe('pending file tracking (#403)', () => {
     it('should expose edited paths via getPendingFiles before sync fires', async () => {
       // Slow debounce — pending entries are visible until the debounce fires.

+ 1 - 0
codegraph-kernel/Cargo.lock

@@ -47,6 +47,7 @@ name = "codegraph-kernel"
 version = "0.1.0"
 dependencies = [
  "cc",
+ "libc",
  "napi",
  "napi-build",
  "napi-derive",

+ 6 - 0
codegraph-kernel/Cargo.toml

@@ -62,6 +62,12 @@ tree-sitter-luau = "=1.2.0"
 # kotlin grammar C (see build.rs — no kotlin crate dep is possible).
 tree-sitter-language = "0.1"
 
+# Stack-bounds queries for the walker stack guard (src/stack.rs, #1581):
+# pthread_getattr_np / pthread_get_stackaddr_np. Already in the lock file
+# transitively; Windows uses a hand-declared kernel32 extern instead.
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
 [build-dependencies]
 napi-build = "2"
 cc = "1"

+ 12 - 0
codegraph-kernel/src/ccpp/mod.rs

@@ -776,6 +776,7 @@ impl<'t> Walker<'t> {
     // --- visitNode -----------------------------------------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -852,6 +853,7 @@ impl<'t> Walker<'t> {
     // --- extractors ----------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // Receiver present (out-of-line `Cls::method` def) → method instead.
         if self.variant == Variant::Cpp && self.receiver_type_of(node).is_some() {
             self.extract_method(node);
@@ -892,6 +894,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let receiver_type = if self.variant == Variant::Cpp { self.receiver_type_of(node) } else { None };
 
         if !self.inside_class_like() && receiver_type.is_none() {
@@ -956,6 +959,7 @@ impl<'t> Walker<'t> {
 
     /// extractClass for cpp class_specifier (skipBodilessClass, #1093).
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -976,6 +980,7 @@ impl<'t> Walker<'t> {
 
     /// Extract a struct-like declaration while preserving its semantic kind.
     fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -995,6 +1000,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -1028,6 +1034,7 @@ impl<'t> Walker<'t> {
     /// extractTypeAlias for type_definition / alias_declaration. Returns true
     /// when children were consumed (typedef struct/enum bodies).
     fn extract_type_alias(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             return false;
@@ -1502,10 +1509,12 @@ impl<'t> Walker<'t> {
     // --- function bodies -----------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -1591,6 +1600,7 @@ impl<'t> Walker<'t> {
     /// grammars: base_class_clause (#1043), the field_declaration Go-embedding
     /// shape, and the field_declaration_list recursion that reaches it.
     fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        stack_guard!();
         let extends_kind = edge_kind_index("extends").unwrap();
         for i in 0..node.named_child_count() {
             let Some(child) = node.named_child(i) else { continue };
@@ -1703,6 +1713,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue for cFamilySpec: bare identifiers, and the
     /// pointer_expression unwrap (`&fn`; `&Cls::m` keeps the qualified name).
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, mode: Mode, explicit_ref: bool, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1749,6 +1760,7 @@ impl<'t> Walker<'t> {
     /// scanFnRefSubtree: capture-only walk of subtrees the main walkers skip
     /// (variable-declaration initializers). Halts at nested functions/lambdas.
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 13 - 0
codegraph-kernel/src/csharp.rs

@@ -500,6 +500,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, C#-relevant branches) -----------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -571,10 +572,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody ------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -626,6 +629,7 @@ impl<'t> Walker<'t> {
     // --- extractors --------------------------------------------------------------
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         // skipBodilessClass unset: a bodiless `record Empty;` still mints a node.
         let name = self.extract_name(node);
         let extra = Extra {
@@ -656,6 +660,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_struct(&mut self, node: Node<'t>) {
+        stack_guard!();
         // Body gate — EXCEPT C# positional records (`record struct M(…);`,
         // node type record_declaration), complete definitions with no body.
         // A bodiless `struct Fwd;` mints NO node. (#831)
@@ -685,6 +690,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -703,6 +709,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -890,6 +897,7 @@ impl<'t> Walker<'t> {
     /// extractMethod (1737) — method_declaration + constructor_declaration.
     /// Signature is ALWAYS undefined (no getSignature hook); isAsync is real.
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         if !self.inside_class_like() {
             // Unreachable on non-erroring C# (top-level `void M(){}` parses as
             // local_function_statement; erroring files defer) — mirror the TS
@@ -924,6 +932,7 @@ impl<'t> Walker<'t> {
     /// extractFunction — only reachable for a method outside any class
     /// (unreachable on non-erroring C#; kept faithful to the generic tail).
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -1087,6 +1096,7 @@ impl<'t> Walker<'t> {
     /// (object initializers are initializer_expression), so this is
     /// unreachable — mirrored from the shared TS path like java.rs.
     fn extract_anonymous_class(&mut self, node: Node<'t>, body: Node<'t>) {
+        stack_guard!();
         let type_node = node
             .child_by_field_name("constructor")
             .or_else(|| node.child_by_field_name("type"))
@@ -1243,6 +1253,7 @@ impl<'t> Walker<'t> {
 
     /// walkCsharpTypePosition (5955).
     fn walk_type_position(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         match node.kind() {
             "predefined_type" => {}
             "identifier" => {
@@ -1362,6 +1373,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue (function-ref.ts:525) for CSHARP_SPEC: bare identifiers,
     /// the transparent `argument` layer, and the `this.Member` special.
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1412,6 +1424,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 8 - 0
codegraph-kernel/src/dart.rs

@@ -596,6 +596,7 @@ impl<'t> Walker<'t> {
     // --- the main walk (visitNode, tree-sitter.ts:936-1303) ---------------
 
     fn visit(&mut self, node: Node<'t>) {
+        stack_guard!();
         // The visitNode hook (dart.ts:144-157) — the constants branch.
         if node.kind() == "static_final_declaration" {
             let mut cursor = node.walk();
@@ -669,6 +670,7 @@ impl<'t> Walker<'t> {
     // --- extractFunction / extractMethod (:1517 / :1737) ------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // No receiver hook. Name first (resolveName inside extract_name).
         let name = self.extract_name(node);
         if name == "<anonymous>" {
@@ -770,6 +772,7 @@ impl<'t> Walker<'t> {
     // --- extractClass (:1679) — classes, mixins, extensions ---------------
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let resolved_body = self.resolve_body(node);
         // No skipBodilessClass. Anonymous `extension on String` → the name
         // fallback finds the ON type's type_identifier — a class named after
@@ -800,6 +803,7 @@ impl<'t> Walker<'t> {
     // --- extractEnum (:1914) ----------------------------------------------
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let body = match self.resolve_body(node) {
             Some(b) => b,
             None => return,
@@ -1221,6 +1225,7 @@ impl<'t> Walker<'t> {
     }
 
     fn type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let name = self.text(node);
             if !name.is_empty() && !is_builtin_type(name) {
@@ -1239,6 +1244,7 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody (:5129-5286) — dart rows -----------------------
 
     fn visit_body(&mut self, node: Node<'t>) {
+        stack_guard!();
         self.maybe_capture_fn_refs(node);
 
         let kind = node.kind();
@@ -1348,6 +1354,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue with DART_SPEC's one layer (`argument` → fan out).
     /// Named arguments are NOT captured (named_argument is not a layer).
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1378,6 +1385,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 9 - 0
codegraph-kernel/src/go.rs

@@ -380,6 +380,7 @@ impl<'t> Walker<'t> {
     // --- visitNode ------------------------------------------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -417,10 +418,12 @@ impl<'t> Walker<'t> {
     }
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -448,6 +451,7 @@ impl<'t> Walker<'t> {
     // --- extractors --------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // (getReceiverType only matches method_declaration's receiver field —
         // function_declaration has none, so no reroute happens here)
         let name = self.extract_name(node);
@@ -524,6 +528,7 @@ impl<'t> Walker<'t> {
 
     /// extractTypeAlias for Go: type_spec → struct / interface / plain alias.
     fn extract_type_alias(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             return false;
@@ -842,6 +847,7 @@ impl<'t> Walker<'t> {
     /// (constraint_elem) and struct embedding (field_declaration without a
     /// field_identifier), plus the field_declaration_list recursion.
     fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        stack_guard!();
         let extends_kind = edge_kind_index("extends").unwrap();
         for i in 0..node.named_child_count() {
             let Some(child) = node.named_child(i) else { continue };
@@ -894,6 +900,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let type_name = self.text(node).to_string();
             if !type_name.is_empty() && !is_builtin_type(&type_name) {
@@ -980,6 +987,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue with GO_SPEC's transparent layers (literal_element,
     /// expression_list — both fan out to named children).
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1010,6 +1018,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 11 - 0
codegraph-kernel/src/java.rs

@@ -485,6 +485,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, Java-relevant branches) -----------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -534,10 +535,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody ----------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -577,6 +580,7 @@ impl<'t> Walker<'t> {
     // --- extractors --------------------------------------------------------------
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -600,6 +604,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         if !self.inside_class_like() {
             // (object-literal parents don't exist in Java; a stray top-level
             // method extracts as a function, mirroring extractMethod's tail)
@@ -627,6 +632,7 @@ impl<'t> Walker<'t> {
 
     /// extractFunction — only reachable for a method outside any class.
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -653,6 +659,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -671,6 +678,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -902,6 +910,7 @@ impl<'t> Walker<'t> {
 
     /// extractAnonymousClass — `new T() { ... }`.
     fn extract_anonymous_class(&mut self, node: Node<'t>, body: Node<'t>) {
+        stack_guard!();
         let type_node = node
             .child_by_field_name("constructor")
             .or_else(|| node.child_by_field_name("type"))
@@ -1101,6 +1110,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let type_name = self.text(node).to_string();
             if !type_name.is_empty() && !is_builtin_type(&type_name) {
@@ -1193,6 +1203,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 10 - 0
codegraph-kernel/src/kotlin.rs

@@ -648,6 +648,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, Kotlin-relevant branches) -----------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         if self.try_visit_hook(node) {
             self.scan_fn_ref_subtree(node, 0);
             return;
@@ -719,10 +720,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody ----------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -777,6 +780,7 @@ impl<'t> Walker<'t> {
     // --- extractors ------------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // getReceiverType short-circuit (1522) — extension fns at any scope.
         if self.receiver_type_of(node).is_some() {
             self.extract_method(node);
@@ -810,6 +814,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let receiver = self.receiver_type_of(node);
         let name = self.extract_name(node);
         let qualified_override = receiver.as_ref().map(|r| format!("{r}::{name}"));
@@ -861,6 +866,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let resolved_body = self.resolve_body(node);
         let name = self.extract_name(node);
         let extra = Extra {
@@ -887,6 +893,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -905,6 +912,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = self.resolve_body(node) else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -1283,6 +1291,7 @@ impl<'t> Walker<'t> {
     }
 
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1362,6 +1371,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 37 - 17
codegraph-kernel/src/lib.rs

@@ -16,6 +16,20 @@
 
 #![deny(clippy::all)]
 
+/// First statement of every recursive walker function (see stack.rs, #1581):
+/// once the stack pointer is inside the red zone, stop descending — the
+/// latched flag makes `stack::run_guarded` discard the walk and defer the
+/// file to wasm. `Default::default()` covers every walker return type in use
+/// (`()`, `bool`, `Option<_>`, `String`); a hook returning `false` just sends
+/// its caller down the generic child walk, whose own guard returns at once.
+macro_rules! stack_guard {
+    () => {
+        if $crate::stack::exhausted() {
+            return ::core::default::Default::default();
+        }
+    };
+}
+
 mod buffers;
 mod ccpp;
 mod cfnptr;
@@ -33,6 +47,7 @@ mod rlang;
 mod ruby;
 mod rustlang;
 mod scala;
+mod stack;
 mod swift;
 mod textutil;
 mod python;
@@ -216,23 +231,28 @@ pub fn cfnptr_strip_c(text: String) -> String {
 
 #[napi]
 pub fn extract_file(file_path: String, content: String, language: String) -> Result<ExtractBuffers> {
-    let out = match language.as_str() {
-        "java" => java::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "python" => python::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "go" => go::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "c" | "cpp" => ccpp::extract(&file_path, &content, &language).map_err(Error::from_reason)?,
-        "rust" => rustlang::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "csharp" => csharp::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "ruby" => ruby::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "php" => php::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "swift" => swift::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "kotlin" => kotlin::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "r" => rlang::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "lua" | "luau" => lua::extract(&file_path, &content, &language).map_err(Error::from_reason)?,
-        "scala" => scala::extract(&file_path, &content).map_err(Error::from_reason)?,
-        "dart" => dart::extract(&file_path, &content).map_err(Error::from_reason)?,
-        _ => tsjs::extract(&file_path, &content, &language).map_err(Error::from_reason)?,
-    };
+    // The whole walk runs under the stack guard (stack.rs, #1581): a file
+    // nested deeply enough to overflow this thread's stack comes back as a
+    // `defer:` error — the TS side's routine "take the wasm path" signal —
+    // instead of a SIGSEGV that kills the entire indexer process.
+    let out = stack::run_guarded(|| match language.as_str() {
+        "java" => java::extract(&file_path, &content),
+        "python" => python::extract(&file_path, &content),
+        "go" => go::extract(&file_path, &content),
+        "c" | "cpp" => ccpp::extract(&file_path, &content, &language),
+        "rust" => rustlang::extract(&file_path, &content),
+        "csharp" => csharp::extract(&file_path, &content),
+        "ruby" => ruby::extract(&file_path, &content),
+        "php" => php::extract(&file_path, &content),
+        "swift" => swift::extract(&file_path, &content),
+        "kotlin" => kotlin::extract(&file_path, &content),
+        "r" => rlang::extract(&file_path, &content),
+        "lua" | "luau" => lua::extract(&file_path, &content, &language),
+        "scala" => scala::extract(&file_path, &content),
+        "dart" => dart::extract(&file_path, &content),
+        _ => tsjs::extract(&file_path, &content, &language),
+    })
+    .map_err(Error::from_reason)?;
     Ok(ExtractBuffers {
         meta: out.meta.into(),
         nodes: out.nodes.into(),

+ 6 - 0
codegraph-kernel/src/lua.rs

@@ -418,6 +418,7 @@ impl<'t> Walker<'t> {
     // --- the main walk (visitNode, tree-sitter.ts:936-1303) ---------------
 
     fn visit(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
 
         // The visitNode hook (lua.ts:105-151) runs FIRST.
@@ -487,6 +488,7 @@ impl<'t> Walker<'t> {
     // --- extractFunction / extractMethod (1517 / 1737) --------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // :1522 receiver short-circuit IS the method routing.
         if let Some(receiver) = self.receiver_type(node) {
             let receiver = receiver.to_string();
@@ -522,6 +524,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>, receiver: String) {
+        stack_guard!();
         let name = self.extract_name(node);
         let docstring = preceding_docstring(node, self.src);
         let signature = self.signature_of(node);
@@ -654,6 +657,7 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody (5129-5286) — the hook-free body walk ----------
 
     fn visit_body(&mut self, node: Node<'t>) {
+        stack_guard!();
         // maybeCaptureFnRefs (5137) fires in the body walker too.
         self.maybe_capture_fn_refs(node);
 
@@ -750,6 +754,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue with LUA_SPEC's one transparent layer (expression_list
     /// fans out to named children).
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -780,6 +785,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 12 - 0
codegraph-kernel/src/php.rs

@@ -530,6 +530,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, PHP-relevant branches) ------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         if self.try_visit_hook(node) {
             self.scan_fn_ref_subtree(node, 0);
             return;
@@ -609,10 +610,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody --------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -671,6 +674,7 @@ impl<'t> Walker<'t> {
     // --- extractors ----------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -696,6 +700,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -715,6 +720,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_class(&mut self, node: Node<'t>, kind: &'static str) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -736,6 +742,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -754,6 +761,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -1080,6 +1088,7 @@ impl<'t> Walker<'t> {
     /// nests inside `anonymous_class`, so findAnonymousClassBody finds no
     /// DIRECT child) — mirrored from the shared TS path for shape.
     fn extract_anonymous_class(&mut self, node: Node<'t>, body: Node<'t>) {
+        stack_guard!();
         let type_node = node
             .child_by_field_name("constructor")
             .or_else(|| node.child_by_field_name("type"))
@@ -1206,6 +1215,7 @@ impl<'t> Walker<'t> {
     }
 
     fn walk_php_type_position(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         match node.kind() {
             "primitive_type" => {}
             "name" => {
@@ -1249,6 +1259,7 @@ impl<'t> Walker<'t> {
     }
 
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1337,6 +1348,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 7 - 0
codegraph-kernel/src/python.rs

@@ -320,6 +320,7 @@ impl<'t> Walker<'t> {
     // --- visitNode ------------------------------------------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -356,10 +357,12 @@ impl<'t> Walker<'t> {
     }
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -390,6 +393,7 @@ impl<'t> Walker<'t> {
     // --- extractors --------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -414,6 +418,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -431,6 +436,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -789,6 +795,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 6 - 0
codegraph-kernel/src/rlang.rs

@@ -298,6 +298,7 @@ impl<'t> Walker<'t> {
     // else plain recursion over namedChildren in order.
 
     fn visit(&mut self, node: Node<'t>) {
+        stack_guard!();
         if self.hook(node) {
             return;
         }
@@ -313,6 +314,7 @@ impl<'t> Walker<'t> {
 
     /// The visitNode hook (r.ts:180-309). Returns true when consumed.
     fn hook(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         match node.kind() {
             "call" => self.hook_call(node),
             "binary_operator" => self.hook_binary_operator(node),
@@ -321,6 +323,7 @@ impl<'t> Walker<'t> {
     }
 
     fn hook_call(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let fname = match self.callee_name(node) {
             Some(f) => f,
             None => return false,
@@ -405,6 +408,7 @@ impl<'t> Walker<'t> {
     }
 
     fn hook_binary_operator(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let op = match node.child_by_field_name("operator") {
             Some(o) => self.text(o),
             None => return false,
@@ -477,6 +481,7 @@ impl<'t> Walker<'t> {
     /// `list(…)` entries become methods. Non-method argument subtrees are
     /// NEVER visited (`representation(…)`, `signature(…)` invisible).
     fn extract_class_members(&mut self, class_call: Node<'t>, class_row: u32) {
+        stack_guard!();
         let args = match class_call.child_by_field_name("arguments") {
             Some(a) => a,
             None => return,
@@ -543,6 +548,7 @@ impl<'t> Walker<'t> {
     /// `method` node positioned at the ARGUMENT node, signature from the raw
     /// parameters text, body walked hook-aware inside the method scope.
     fn emit_method_arg(&mut self, entry: Node<'t>) {
+        stack_guard!();
         let entry_name = match entry.child_by_field_name("name") {
             Some(n) => n,
             None => return,

+ 9 - 0
codegraph-kernel/src/ruby.rs

@@ -383,6 +383,7 @@ impl<'t> Walker<'t> {
     /// the source of the module multiply-capture quirk (scan runs with the
     /// module already POPPED, so candidates re-attribute to the outer scope).
     fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let kind = node.kind();
         if kind == "call" && node.child_by_field_name("receiver").is_none() {
             if let Some(method) = node.child_by_field_name("method") {
@@ -451,6 +452,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, Ruby-relevant branches) ----------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         // Language hook FIRST (tree-sitter.ts:943) — a handled subtree is
         // scanned for fn-ref candidates and never reaches the ladder (or the
         // maybeCaptureFnRefs call below).
@@ -522,10 +524,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody ------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -596,6 +600,7 @@ impl<'t> Walker<'t> {
     // --- extractors --------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -619,6 +624,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -634,6 +640,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -872,6 +879,7 @@ impl<'t> Walker<'t> {
     /// qualify); `block_argument` is a transparent layer; specials are the
     /// `method(:sym)` call form and hook-DSL `simple_symbol`s.
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -938,6 +946,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 80 - 60
codegraph-kernel/src/rustlang.rs

@@ -11,10 +11,11 @@
 //! - impl blocks push NO scope: members re-dispatch at file scope, so an impl
 //!   associated `const` becomes a FILE-level `variable`, and the method↔owner
 //!   `contains` edge is a source-order name scan (an impl ABOVE its struct
-//!   gets no edge). `impl Trait for Generic<T>`'s receiver resolves to the
-//!   TRAIT (the only direct type_identifier), and methods get QN
-//!   `Trait::method` — preserve, never "fix" via the grammar's trait:/type:
-//!   fields.
+//!   gets no edge). The receiver (method QN prefix, `contains` owner,
+//!   `implements` source) is the impl_item's `type` field via
+//!   impl_type_name — both sides moved to the grammar's trait:/type: fields
+//!   together in #1588 (the earlier positional scan qualified every
+//!   parameterized impl's methods by the TRAIT).
 //! - `const_item`/`static_item` ride the generic extractVariable fallback:
 //!   kind is always `variable`, no signature, and EVERY direct `identifier`
 //!   child mints a node (`const MAX: u32 = OTHER;` → two nodes, `MAX` + the
@@ -22,10 +23,12 @@
 //! - Unit structs (`struct Unit;`, no body field) mint NO node; `mod_item`
 //!   mints no module node and adds no QN prefix.
 //! - Chained-call re-encode is scoped_identifier-gated (`Foo::new().bar()` →
-//!   `Foo::new().bar`); instance chains, parens, `.await`, 2-hop fields, and
-//!   `self` receivers all collapse to the bare method name (`self` is node
-//!   kind `self`, not `identifier`, so it dodges SKIP_RECEIVERS by falling
-//!   through). Turbofish callees keep the raw `helper::<T>` text.
+//!   `Foo::new().bar`); a call through a field of the enclosing type keeps
+//!   the owner-field shape (`self.inner.run()` → `self.inner.run`, #1585);
+//!   instance chains, parens, `.await`, deeper/non-self field chains, and
+//!   bare `self` receivers all collapse to the bare method name (`self` is
+//!   node kind `self`, not `identifier`, so it dodges SKIP_RECEIVERS by
+//!   falling through). Turbofish callees keep the raw `helper::<T>` text.
 //! - `use` emits an import node named by the ROOT module (`crate`/`self`/…),
 //!   one root `imports` ref, then one FULL-path `imports` ref per binding;
 //!   `use x::*` (use_wildcard) emits nothing at all.
@@ -399,33 +402,35 @@ impl<'t> Walker<'t> {
         Some(if last == "Self" { "self".to_string() } else { last.to_string() })
     }
 
-    /// rustExtractor.getReceiverType: parent-walk to the nearest impl_item;
-    /// LAST direct type_identifier child wins (for `impl Trait for Generic<T>`
-    /// that's the TRAIT — bug preserved); else the first generic_type's inner
-    /// type_identifier.
+    /// rustImplTypeName (languages/rust.ts) — the implementing type's simple
+    /// name for an impl block, from the grammar's `type` field (#1588):
+    /// `impl<T> Tr for G<T>` / `impl<'a> Iterator for Parents<'a>` /
+    /// `impl Tr for &Foo` / `impl Tr for m::Foo` → `G` / `Parents` / `Foo` /
+    /// `Foo`. Shapes naming no single type (tuple, `dyn Tr`, pointer,
+    /// primitive, fn type…) → None. Mirrored byte-for-byte — change both.
+    fn impl_type_name(&self, ty: Option<Node>) -> Option<String> {
+        let ty = ty?;
+        match ty.kind() {
+            "type_identifier" | "identifier" => Some(self.text(ty).to_string()),
+            "generic_type" => self.impl_type_name(ty.child_by_field_name("type")),
+            "scoped_type_identifier" | "scoped_identifier" => {
+                self.impl_type_name(ty.child_by_field_name("name"))
+            }
+            "reference_type" => self.impl_type_name(ty.child_by_field_name("type")),
+            _ => None,
+        }
+    }
+
+    /// rustExtractor.getReceiverType: parent-walk to the nearest impl_item and
+    /// read its `type` field (impl_type_name). The pre-#1588 rule took the
+    /// LAST direct type_identifier child, which for `impl Trait for Generic<T>`
+    /// was the TRAIT — so every parameterized impl's methods were qualified by
+    /// the trait.
     fn receiver_type_of(&self, node: Node) -> Option<String> {
         let mut parent = node.parent();
         while let Some(p) = parent {
             if p.kind() == "impl_item" {
-                let type_idents: Vec<Node> = (0..p.named_child_count())
-                    .filter_map(|i| p.named_child(i))
-                    .filter(|c| c.kind() == "type_identifier")
-                    .collect();
-                if let Some(last) = type_idents.last() {
-                    return Some(self.text(*last).to_string());
-                }
-                let generic = (0..p.named_child_count())
-                    .filter_map(|i| p.named_child(i))
-                    .find(|c| c.kind() == "generic_type");
-                if let Some(g) = generic {
-                    let inner = (0..g.named_child_count())
-                        .filter_map(|i| g.named_child(i))
-                        .find(|c| c.kind() == "type_identifier");
-                    if let Some(inner) = inner {
-                        return Some(self.text(inner).to_string());
-                    }
-                }
-                return None;
+                return self.impl_type_name(p.child_by_field_name("type"));
             }
             parent = p.parent();
         }
@@ -435,6 +440,7 @@ impl<'t> Walker<'t> {
     // --- visitNode ------------------------------------------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -498,6 +504,7 @@ impl<'t> Walker<'t> {
     /// impl method's body, whose parent walk passes through the outer fn) or
     /// the stack top is class-like (trait members).
     fn extract_fn_or_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let receiver = self.receiver_type_of(node);
         let as_method = receiver.is_some() || self.inside_class_like();
 
@@ -564,6 +571,7 @@ impl<'t> Walker<'t> {
     /// extractInterface — kind `trait` (interfaceKind), inheritance from
     /// trait_bounds, body children visited with the trait pushed.
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -584,6 +592,7 @@ impl<'t> Walker<'t> {
 
     /// Extract a Rust struct or union with a body; unit structs remain skipped.
     fn extract_aggregate(&mut self, node: Node<'t>, kind: &'static str) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -606,6 +615,7 @@ impl<'t> Walker<'t> {
     /// extractEnum — body required; enum_variant children → enum_member nodes
     /// (name field only, payloads never walked); other children re-dispatched.
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -700,6 +710,7 @@ impl<'t> Walker<'t> {
 
     /// getRootModule (languages/rust.ts:124).
     fn root_module(&self, n: Node) -> String {
+        stack_guard!();
         let Some(first) = n.named_child(0) else {
             return self.text(n).to_string();
         };
@@ -719,6 +730,7 @@ impl<'t> Walker<'t> {
             if prefix.is_empty() { seg.to_string() } else { format!("{prefix}::{seg}") }
         }
         fn collect<'t>(w: &Walker<'t>, n: Node<'t>, prefix: &str, paths: &mut Vec<(String, Node<'t>)>) {
+            stack_guard!();
             match n.kind() {
                 "identifier" => paths.push((join(prefix, w.text(n)), n)),
                 "scoped_identifier" => {
@@ -835,9 +847,29 @@ impl<'t> Walker<'t> {
                                     callee_name = method_name.to_string();
                                 }
                             }
+                            "field_expression" => {
+                                // `self.<field>.<method>()` — a call through a
+                                // field of the enclosing type (#1585): keep the
+                                // `self.` prefix so the resolver can type the
+                                // field from the owner struct's declaration
+                                // (or leave it unresolved). Any other
+                                // field_expression receiver — a deeper chain,
+                                // a non-self base — keeps the bare name.
+                                let base = r.child_by_field_name("value");
+                                let field = r.child_by_field_name("field");
+                                match (base, field) {
+                                    (Some(b), Some(f))
+                                        if b.kind() == "self" && f.kind() == "field_identifier" =>
+                                    {
+                                        let field_name = self.text(f);
+                                        callee_name = format!("self.{field_name}.{method_name}");
+                                    }
+                                    _ => callee_name = method_name.to_string(),
+                                }
+                            }
                             _ => {
-                                // field_expression 2-hop, parenthesized,
-                                // await_expression, `self` — bare method name.
+                                // parenthesized, await_expression, `self` —
+                                // bare method name.
                                 callee_name = method_name.to_string();
                             }
                         }
@@ -966,6 +998,7 @@ impl<'t> Walker<'t> {
     /// every field has a field_identifier), and the field_declaration_list
     /// recursion that reaches it.
     fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        stack_guard!();
         let extends_kind = edge_kind_index("extends").unwrap();
         for i in 0..node.named_child_count() {
             let Some(child) = node.named_child(i) else { continue };
@@ -1024,36 +1057,19 @@ impl<'t> Walker<'t> {
         }
     }
 
-    /// extractRustImplItem — `impl Trait for Type` back-reference: positional
-    /// type-node filter (NEVER the grammar's trait:/type: fields), ≥2 needed,
-    /// target found by FIRST earlier node of kind struct/enum/class (never
-    /// trait); ref FROM the type's node, named by the trait's full text.
+    /// extractRustImplItem — `impl Trait for Type` back-reference from the
+    /// grammar's `trait` / `type` fields (#1588; an inherent impl has no
+    /// `trait` field and emits nothing). Target = FIRST earlier node of kind
+    /// struct/union/enum/class (never trait) named by impl_type_name; ref FROM
+    /// the type's node, named by the trait's full text (scoped path / generic
+    /// args kept), at the trait node's position.
     fn extract_rust_impl_item(&mut self, node: Node<'t>) {
-        let has_for = (0..node.child_count())
-            .filter_map(|i| node.child(i))
-            .any(|c| c.kind() == "for" && !c.is_named());
-        if !has_for {
+        let Some(trait_node) = node.child_by_field_name("trait") else {
             return;
-        }
-        let type_idents: Vec<Node> = (0..node.named_child_count())
-            .filter_map(|i| node.named_child(i))
-            .filter(|c| matches!(c.kind(), "type_identifier" | "generic_type" | "scoped_type_identifier"))
-            .collect();
-        if type_idents.len() < 2 {
-            return;
-        }
-        let trait_node = type_idents[0];
-        let type_node = type_idents[type_idents.len() - 1];
-
+        };
         let trait_name = self.text(trait_node).to_string();
-        let type_name = if type_node.kind() == "generic_type" {
-            (0..type_node.named_child_count())
-                .filter_map(|i| type_node.named_child(i))
-                .find(|c| c.kind() == "type_identifier")
-                .map(|c| self.text(c).to_string())
-                .unwrap_or_else(|| self.text(type_node).to_string())
-        } else {
-            self.text(type_node).to_string()
+        let Some(type_name) = self.impl_type_name(node.child_by_field_name("type")) else {
+            return;
         };
 
         let target_row = self
@@ -1086,6 +1102,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let type_name = self.text(node).to_string();
             if !type_name.is_empty() && !is_builtin_type(&type_name) {
@@ -1103,10 +1120,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody -----------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -1262,6 +1281,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 12 - 0
codegraph-kernel/src/scala.rs

@@ -471,6 +471,7 @@ impl<'t> Walker<'t> {
 
     /// scalaBaseTypeName (tree-sitter.ts:201-224).
     fn scala_base_type_name(&self, node: Option<Node<'t>>) -> Option<String> {
+        stack_guard!();
         let node = node?;
         match node.kind() {
             "type_identifier" | "identifier" => Some(self.text(node).to_string()),
@@ -495,6 +496,7 @@ impl<'t> Walker<'t> {
 
     /// emitScalaTypeRefs (scala.ts:27-45) — the hook's own builtin set.
     fn emit_scala_type_refs(&mut self, type_node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if type_node.kind() == "type_identifier" {
             let name = self.text(type_node);
             if !name.is_empty() && !is_scala_builtin(name) {
@@ -529,6 +531,7 @@ impl<'t> Walker<'t> {
     // --- the main walk (visitNode, tree-sitter.ts:936-1303) ---------------
 
     fn visit(&mut self, node: Node<'t>) {
+        stack_guard!();
         // The visitNode hook (scala.ts:131-198) runs FIRST.
         if self.hook(node) {
             self.scan_fn_ref_subtree(node, 0);
@@ -591,6 +594,7 @@ impl<'t> Walker<'t> {
 
     /// The visitNode hook (scala.ts:131-198). Returns true when consumed.
     fn hook(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         match node.kind() {
             "val_definition" | "var_definition" => {
                 let is_val = node.kind() == "val_definition";
@@ -691,6 +695,7 @@ impl<'t> Walker<'t> {
     // --- extractMethod → extractFunction routing (:1737 / :1517) ----------
 
     fn extract_method_or_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         // No receiver hook, no methodsAreTopLevel: inside class-like → method,
         // else → function (the object/object_expression parent check never
         // matches scala node kinds).
@@ -735,6 +740,7 @@ impl<'t> Walker<'t> {
     // --- extractClass (:1679) — classes, objects, traits ------------------
 
     fn extract_class(&mut self, node: Node<'t>, kind: &'static str) {
+        stack_guard!();
         let resolved_body = node.child_by_field_name("body"); // template_body
         // No skipBodilessClass — bodiless mints (scala-complete).
         let name = self.extract_name(node);
@@ -765,6 +771,7 @@ impl<'t> Walker<'t> {
     // --- extractEnum (:1914) ----------------------------------------------
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let body = match node.child_by_field_name("body") {
             Some(b) => b,
             None => return, // bodiless enum mints nothing
@@ -812,6 +819,7 @@ impl<'t> Walker<'t> {
     // --- extractImport (:3170-3236) ---------------------------------------
 
     fn extract_import(&mut self, node: Node<'t>) {
+        stack_guard!();
         let import_text = self.text(node).trim();
         // extractImport hook (scala.ts:200-211): `path` field is FIRST-MATCH-
         // WINS → the FIRST dotted segment names the import.
@@ -1133,6 +1141,7 @@ impl<'t> Walker<'t> {
     }
 
     fn type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let name = self.text(node);
             if !name.is_empty() && !is_builtin_type(name) {
@@ -1151,6 +1160,7 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody (:5129-5286) — scala rows ----------------------
 
     fn visit_body(&mut self, node: Node<'t>) {
+        stack_guard!();
         self.maybe_capture_fn_refs(node);
 
         let kind = node.kind();
@@ -1266,6 +1276,7 @@ impl<'t> Walker<'t> {
     /// normalizeValue with SCALA_SPEC's unwrap (postfix_expression → first
     /// named child — eta-expansion `handler _`). No layers.
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1294,6 +1305,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 273 - 0
codegraph-kernel/src/stack.rs

@@ -0,0 +1,273 @@
+//! Stack-budget guard for the recursive walkers (#1581).
+//!
+//! Every language walker recurses per AST level (`visit_node` →
+//! `visit_for_calls_and_structure` → …). tree-sitter's own parser is
+//! iterative, so a pathologically nested file — clang's
+//! `parser_overflow.c` nests 16,384 `{`, fuzzer corpora go deeper — parses
+//! fine and then overflows the WALKER's native stack. A native overflow is
+//! uncatchable: the parse worker is a thread of the `codegraph` process, so
+//! the SIGSEGV takes the whole indexer down with no message, no partial
+//! index and no per-file fallback. Worker threads get Node's 4 MiB default
+//! stack; the main thread's 8 MiB only moves the cliff (100k levels still
+//! kill it).
+//!
+//! The guard turns "about to overflow" into the kernel's existing `defer:`
+//! routing signal: `exhausted()` is checked at the top of every recursive
+//! walker function (the `stack_guard!` macro in lib.rs), returns `true` once
+//! the stack pointer is within `RED_ZONE` of the thread's stack limit, and
+//! latches a per-thread flag. `run_guarded` wraps a whole extraction: when
+//! the flag is set afterwards the result is discarded and replaced by a
+//! `defer:` error, which the TS side (`src/extraction/kernel/index.ts`)
+//! already treats as "this file takes the wasm path" — and the wasm walker
+//! catches its own JS `RangeError` per file, so the file lands as a partial
+//! result with a recorded parse error instead of a dead process.
+//!
+//! The per-thread stack bounds come from the OS (glibc/musl
+//! `pthread_getattr_np`, macOS `pthread_get_stackaddr_np`, Win32
+//! `GetCurrentThreadStackLimits`), computed once per thread and cached, so
+//! the guard is exact on the 4 MiB worker, the 8 MiB main thread and any
+//! `resourceLimits.stackSizeMb` alike. Where the bounds are unavailable the
+//! guard falls back to a fixed descent budget measured from the entry stack
+//! pointer. Hot path: one thread-local load and one compare.
+
+use std::cell::Cell;
+
+/// Headroom kept free below the deepest walker frame: the napi return path,
+/// tree-sitter's node accessors and the error formatting all still need to
+/// run after the guard trips, and the frames BETWEEN two guard checks (an
+/// `extract_class` between two `visit_node`s) are never more than a few KiB.
+const RED_ZONE: usize = 256 * 1024;
+
+/// Descent budget when the OS can't report the thread's stack bounds — safe
+/// on anything from Node's 4 MiB worker default upwards.
+const FALLBACK_BUDGET: usize = 1024 * 1024;
+
+thread_local! {
+    /// Lowest stack-pointer value the walker may reach before the guard
+    /// trips. `0` = not computed yet for this thread.
+    static THRESHOLD: Cell<usize> = const { Cell::new(0) };
+    /// `true` when the thread's threshold came from real OS bounds (fixed
+    /// for the thread's lifetime) rather than the per-call fallback budget.
+    static THRESHOLD_IS_OS: Cell<bool> = const { Cell::new(false) };
+    /// Latched by `exhausted()`; read by `run_guarded` after the walk.
+    static OVERFLOWED: Cell<bool> = const { Cell::new(false) };
+}
+
+/// Approximate current stack pointer: the address of a local. Stacks grow
+/// downward on every target the kernel ships for (x86_64 / aarch64).
+#[inline(always)]
+fn current_sp() -> usize {
+    let marker = 0u8;
+    std::hint::black_box(&marker) as *const u8 as usize
+}
+
+/// Low (deepest) address of the calling thread's stack, from the OS.
+#[cfg(target_os = "linux")]
+fn os_stack_low() -> Option<usize> {
+    // SAFETY: plain pthread queries on the calling thread; `attr` is
+    // initialised by pthread_getattr_np and destroyed before returning.
+    unsafe {
+        let mut attr: libc::pthread_attr_t = std::mem::zeroed();
+        if libc::pthread_getattr_np(libc::pthread_self(), &mut attr) != 0 {
+            return None;
+        }
+        let mut addr: *mut libc::c_void = std::ptr::null_mut();
+        let mut size: libc::size_t = 0;
+        let rc = libc::pthread_attr_getstack(&attr, &mut addr, &mut size);
+        libc::pthread_attr_destroy(&mut attr);
+        if rc != 0 || addr.is_null() || size == 0 {
+            return None;
+        }
+        Some(addr as usize)
+    }
+}
+
+#[cfg(target_os = "macos")]
+fn os_stack_low() -> Option<usize> {
+    // SAFETY: plain pthread queries on the calling thread.
+    unsafe {
+        let me = libc::pthread_self();
+        // pthread_get_stackaddr_np returns the HIGH end (the stack base).
+        let high = libc::pthread_get_stackaddr_np(me) as usize;
+        let size = libc::pthread_get_stacksize_np(me);
+        if high == 0 || size == 0 || size > high {
+            return None;
+        }
+        Some(high - size)
+    }
+}
+
+#[cfg(windows)]
+fn os_stack_low() -> Option<usize> {
+    #[link(name = "kernel32")]
+    extern "system" {
+        // Win8+ (the bundled Node runtime needs Win10 anyway). Reports the
+        // full RESERVED range; Windows commits pages on demand down to it.
+        fn GetCurrentThreadStackLimits(low_limit: *mut usize, high_limit: *mut usize);
+    }
+    let mut low: usize = 0;
+    let mut high: usize = 0;
+    // SAFETY: both out-pointers are valid for the duration of the call.
+    unsafe { GetCurrentThreadStackLimits(&mut low, &mut high) };
+    if low == 0 || high <= low {
+        return None;
+    }
+    Some(low)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
+fn os_stack_low() -> Option<usize> {
+    None
+}
+
+/// Arm the guard for one extraction on the calling thread: clear the latch
+/// and (re)compute the threshold. OS bounds are computed once per thread;
+/// the fallback budget is re-anchored at every call's entry stack pointer.
+pub fn begin() {
+    OVERFLOWED.with(|o| o.set(false));
+    let cached = THRESHOLD.with(|t| t.get());
+    if cached != 0 && THRESHOLD_IS_OS.with(|f| f.get()) {
+        return;
+    }
+    match os_stack_low() {
+        Some(low) => {
+            THRESHOLD.with(|t| t.set(low.saturating_add(RED_ZONE)));
+            THRESHOLD_IS_OS.with(|f| f.set(true));
+        }
+        None => {
+            THRESHOLD.with(|t| t.set(current_sp().saturating_sub(FALLBACK_BUDGET).max(1)));
+            THRESHOLD_IS_OS.with(|f| f.set(false));
+        }
+    }
+}
+
+/// `true` once the walker has descended to within `RED_ZONE` of the stack
+/// limit. Latches `OVERFLOWED` so `run_guarded` can discard the result. A
+/// thread that never called `begin()` (a direct unit-test call) arms itself
+/// lazily from the current position.
+#[inline(always)]
+pub fn exhausted() -> bool {
+    let threshold = THRESHOLD.with(|t| t.get());
+    if threshold == 0 {
+        begin();
+        return exhausted();
+    }
+    if current_sp() < threshold {
+        OVERFLOWED.with(|o| o.set(true));
+        true
+    } else {
+        false
+    }
+}
+
+/// Whether the guard tripped since the last `begin()`.
+pub fn overflowed() -> bool {
+    OVERFLOWED.with(|o| o.get())
+}
+
+/// Run one extraction under the guard. A walk that tripped the guard returns
+/// a `defer:` error — the TS side's routine "take the wasm path" signal —
+/// regardless of what the truncated walk produced.
+pub fn run_guarded<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
+    begin();
+    let out = f();
+    if overflowed() {
+        return Err(
+            "defer: nesting too deep for the native walker — wasm recovery handles it".to_string(),
+        );
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// 1 MiB is a quarter of Node's worker default; a guard that holds here
+    /// holds on every real thread. Without the guard these walks SIGSEGV the
+    /// test process instead of failing an assertion.
+    const SMALL_STACK: usize = 1 << 20;
+    const DEPTH: usize = 30_000;
+
+    fn on_small_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
+        std::thread::Builder::new()
+            .stack_size(SMALL_STACK)
+            .spawn(f)
+            .expect("spawn")
+            .join()
+            .expect("walker thread panicked")
+    }
+
+    fn nested_parens(prefix: &str, suffix: &str) -> String {
+        format!("{prefix}{}1{}{suffix}", "(".repeat(DEPTH), ")".repeat(DEPTH))
+    }
+
+    #[test]
+    fn os_bounds_are_sane_on_this_platform() {
+        // Every shipped target has an OS implementation; the fallback budget
+        // is only for platforms the kernel is not built for.
+        let low = os_stack_low().expect("OS stack bounds available");
+        let sp = current_sp();
+        assert!(low < sp, "stack low {low:#x} must be below the current sp {sp:#x}");
+        assert!(sp - low < 1 << 31, "implausible stack size {}", sp - low);
+    }
+
+    #[test]
+    fn small_stack_reports_its_own_bounds() {
+        on_small_stack(|| {
+            let low = os_stack_low().expect("OS stack bounds available");
+            let used = current_sp() - low;
+            // std/the OS round the requested size up a little (macOS reports
+            // 1,060,864 for a 1 MiB request); the point is that the bounds
+            // describe THIS thread's small stack, not the main thread's.
+            assert!(
+                used <= SMALL_STACK + 128 * 1024,
+                "used {used} is not within the {SMALL_STACK}-byte stack"
+            );
+        });
+    }
+
+    #[test]
+    fn deep_braces_c_defer_instead_of_crashing() {
+        let src = format!("void foo(void) {{\n{}{}\n}}\n", "{".repeat(DEPTH), "}".repeat(DEPTH));
+        let r = on_small_stack(move || run_guarded(|| crate::ccpp::extract("deep.c", &src, "c")));
+        let err = r.err().expect("deep nesting must defer");
+        assert!(err.starts_with("defer:"), "unexpected error: {err}");
+    }
+
+    type Extract = fn(&str) -> Result<crate::buffers::EmitOut, String>;
+
+    #[test]
+    fn deep_parens_cpp_rust_ts_python_defer_instead_of_crashing() {
+        let cases: [(&str, Extract, String); 4] = [
+            ("deep.cpp", |s| crate::ccpp::extract("deep.cpp", s, "cpp"), nested_parens("int f() { return ", "; }\n")),
+            ("deep.rs", |s| crate::rustlang::extract("deep.rs", s), nested_parens("fn f() -> i32 { ", " }\n")),
+            ("deep.ts", |s| crate::tsjs::extract("deep.ts", s, "typescript"), nested_parens("function f() { return ", "; }\n")),
+            ("deep.py", |s| crate::python::extract("deep.py", s), nested_parens("def f():\n    return ", "\n")),
+        ];
+        for (name, extract, src) in cases {
+            let r = on_small_stack(move || run_guarded(|| extract(&src)));
+            let err = r.err().unwrap_or_else(|| panic!("{name}: deep nesting must defer"));
+            assert!(err.starts_with("defer:"), "{name}: unexpected error: {err}");
+        }
+    }
+
+    #[test]
+    fn normal_files_are_untouched_by_the_guard() {
+        let src = "int add(int a, int b) { return a + b; }\nint main(void) { return add(1, 2); }\n";
+        let r = on_small_stack(move || run_guarded(|| crate::ccpp::extract("ok.c", src, "c")));
+        assert!(r.is_ok(), "a shallow file must not defer: {:?}", r.err());
+        assert!(!overflowed());
+    }
+
+    #[test]
+    fn latch_resets_between_runs() {
+        let deep = format!("void foo(void) {{\n{}{}\n}}\n", "{".repeat(DEPTH), "}".repeat(DEPTH));
+        on_small_stack(move || {
+            assert!(run_guarded(|| crate::ccpp::extract("deep.c", &deep, "c")).is_err());
+            // The latch from the deep file must not poison the next, shallow one.
+            let ok = run_guarded(|| crate::ccpp::extract("ok.c", "int x;\n", "c"));
+            assert!(ok.is_ok(), "latch leaked into the next run: {:?}", ok.err());
+        });
+    }
+}

+ 15 - 0
codegraph-kernel/src/swift.rs

@@ -264,6 +264,7 @@ fn first_simple_identifier<'t>(node: Option<Node<'t>>) -> Option<Node<'t>> {
 /// lastNamedOfType (function-ref.ts:600): rightmost matching DESCENDANT in
 /// document order (deeper matches override).
 fn last_simple_identifier<'t>(node: Node<'t>) -> Option<Node<'t>> {
+    stack_guard!();
     let mut found: Option<Node<'t>> = None;
     for i in 0..node.named_child_count() {
         let Some(child) = node.named_child(i) else { continue };
@@ -560,6 +561,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode, Swift-relevant branches) -----------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -632,6 +634,7 @@ impl<'t> Walker<'t> {
     /// THE DEDICATED PROPERTY BRANCH (tree-sitter.ts:1113-1193, #1020).
     /// Returns skipChildren.
     fn dedicated_property_branch(&mut self, node: Node<'t>) -> bool {
+        stack_guard!();
         let owner_row = self.top_row();
         let info = self.swift_property_info(node);
         let mut computed_prop: Option<(u32, String)> = None;
@@ -707,6 +710,7 @@ impl<'t> Walker<'t> {
     }
 
     fn walk_attr_args(&mut self, n: Node<'t>) {
+        stack_guard!();
         self.extract_static_member_ref(n);
         for i in 0..n.named_child_count() {
             if let Some(c) = n.named_child(i) {
@@ -718,10 +722,12 @@ impl<'t> Walker<'t> {
     // --- visitFunctionBody ---------------------------------------------------------
 
     fn visit_function_body(&mut self, body: Node<'t>) {
+        stack_guard!();
         self.visit_for_calls_and_structure(body);
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 
@@ -775,6 +781,7 @@ impl<'t> Walker<'t> {
     // --- extractors -----------------------------------------------------------------
 
     fn extract_function(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         if name == "<anonymous>" {
             if let Some(body) = node.child_by_field_name("body") {
@@ -802,6 +809,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_method(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -823,6 +831,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_class(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -845,6 +854,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_struct(&mut self, node: Node<'t>) {
+        stack_guard!();
         // Body gate (:1876) — bodiless mints nothing (record exemption is C#).
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
@@ -866,6 +876,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_enum(&mut self, node: Node<'t>) {
+        stack_guard!();
         let Some(body) = node.child_by_field_name("body") else { return };
         let name = self.extract_name(node);
         let extra = Extra {
@@ -900,6 +911,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_interface(&mut self, node: Node<'t>) {
+        stack_guard!();
         let name = self.extract_name(node);
         let extra = Extra {
             docstring: preceding_docstring(node, self.src),
@@ -1151,6 +1163,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let type_name = self.text(node).to_string();
             if !type_name.is_empty() && !is_builtin_type(&type_name) {
@@ -1317,6 +1330,7 @@ impl<'t> Walker<'t> {
     }
 
     fn normalize_fn_ref_value(&mut self, v: Node<'t>, from: u32, depth: u32) {
+        stack_guard!();
         if depth > 4 {
             return;
         }
@@ -1381,6 +1395,7 @@ impl<'t> Walker<'t> {
     }
 
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }

+ 5 - 0
codegraph-kernel/src/tsjs/extractors.rs

@@ -478,6 +478,7 @@ impl<'t> Walker<'t> {
     }
 
     fn find_initializer_returned_object(&self, call: Node<'t>, depth: u32) -> Option<Node<'t>> {
+        stack_guard!();
         if depth > 4 {
             return None;
         }
@@ -499,6 +500,7 @@ impl<'t> Walker<'t> {
 
     fn function_returned_object(&self, fn_node: Node<'t>) -> Option<Node<'t>> {
         fn as_object<'t>(n: Node<'t>) -> Option<Node<'t>> {
+            stack_guard!();
             match n.kind() {
                 "object" | "object_expression" => Some(n),
                 "parenthesized_expression" => {
@@ -873,6 +875,7 @@ impl<'t> Walker<'t> {
     fn extract_ts_tuple_contract_names(&mut self, value: Node<'t>, alias_row: u32, alias_name: &str) {
         let mut tuples: Vec<Node> = Vec::new();
         fn collect<'t>(n: Node<'t>, depth: u32, out: &mut Vec<Node<'t>>) {
+            stack_guard!();
             if depth > 6 {
                 return;
             }
@@ -1230,6 +1233,7 @@ impl<'t> Walker<'t> {
     // --- extractInheritance (TS/JS clauses) ---------------------------------------------------
 
     pub(super) fn extract_inheritance(&mut self, node: Node<'t>, class_row: u32) {
+        stack_guard!();
         let extends_kind = edge_kind_index("extends").unwrap();
         let implements_kind = edge_kind_index("implements").unwrap();
         for i in 0..node.named_child_count() {
@@ -1298,6 +1302,7 @@ impl<'t> Walker<'t> {
     }
 
     fn extract_type_refs_from_subtree(&mut self, node: Node<'t>, from_row: u32) {
+        stack_guard!();
         if node.kind() == "type_identifier" {
             let type_name = self.text(node).to_string();
             if !type_name.is_empty() && !is_builtin_type(&type_name) {

+ 3 - 0
codegraph-kernel/src/tsjs/mod.rs

@@ -550,6 +550,7 @@ impl<'t> Walker<'t> {
 
     /// scanFnRefSubtree: capture-only walk of subtrees the main walkers skip.
     fn scan_fn_ref_subtree(&mut self, node: Node<'t>, depth: u32) {
+        stack_guard!();
         if depth > 12 {
             return;
         }
@@ -607,6 +608,7 @@ impl<'t> Walker<'t> {
     // --- the dispatcher (visitNode) --------------------------------------------
 
     fn visit_node(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         let mut skip_children = false;
 
@@ -686,6 +688,7 @@ impl<'t> Walker<'t> {
     }
 
     fn visit_for_calls_and_structure(&mut self, node: Node<'t>) {
+        stack_guard!();
         let kind = node.kind();
         self.maybe_capture_fn_refs(node);
 

+ 13 - 3
docs/design/rust-kernel-migration-plan.md

@@ -148,9 +148,11 @@ them are the ORIGINAL plan and carry expectations that measurement later correct
       tokio node sections IDENTICAL, small precision-positive edge churn only,
       full suite green), walker `codegraph-kernel/src/rustlang.rs` (survey
       artifact: rust-lang-kernel-port-checklist.md — isAsync dead-code,
-      impl-pushes-no-scope, trait-receiver bug on `impl Trait for Generic<T>`,
-      phantom const identifiers, use-binding triple emission, all preserved
-      bug-for-bug). Gates: parity sweeps **0 diffs** on ripgrep (101/101,
+      impl-pushes-no-scope, trait-receiver bug on `impl Trait for Generic<T>`
+      (fixed on both sides together in #1588 — receiver now comes from the
+      impl_item's `type` field), phantom const identifiers, use-binding
+      triple emission, all preserved bug-for-bug). Gates: parity sweeps
+      **0 diffs** on ripgrep (101/101,
       0 deferred) / tokio (790/790, 0 deferred) / rust-analyzer (1217/1488,
       0 diffs; 271 deferrals are token-macro-table sources — `T![~]`, `[$]` —
       that error on BOTH arms, grammar-inherent like fmt's C++ 42%); full-init
@@ -410,6 +412,14 @@ in a different emission order would shift rowids and change resolution — and
    file whose tree `has_error()` to the wasm extractor** (`defer:` signal, silent,
    per-file) — parity by construction on erroring files, 99.6%+ keep the fast path,
    and the harness fails if deferrals exceed 10% (a broken kernel can't hide).
+   The same signal carries a second, rarer case (#1581): the walkers recurse per
+   AST level, and a pathologically nested file (clang's 16,384-brace
+   `parser_overflow.c`, fuzzer corpora) overflowed the native stack — a SIGSEGV
+   that killed the whole indexer, uncatchable from JS. `src/stack.rs` now
+   checks the thread's real stack bounds at every recursive entry
+   (`stack_guard!`) and `run_guarded` turns a tripped walk into `defer:`, so
+   the file lands on the wasm path (which catches its own `RangeError` per
+   file) while the process lives. Pinned by `__tests__/kernel-deep-nesting.test.ts`.
 3. **Retrieval invariants:** kernel-indexed excalidraw — `mutateElement →
    renderStaticScene` connects end-to-end via explore (callback + react-render +
    jsx hops shown); synthesized-edge families present (408 jsx-render / 46

+ 25 - 17
docs/design/rust-lang-kernel-port-checklist.md

@@ -89,18 +89,21 @@ Hooks PRESENT (port each exactly):
 - **getVisibility (rust.ts:74)** — direct child of type `visibility_modifier`:
   text `.includes('pub')` → `'public'` else `'private'`; no modifier →
   `'private'` (so `pub(crate)`/`pub(super)` are all `'public'`).
-- **getReceiverType (rust.ts:83)** — walk PARENT chain to the nearest
-  `impl_item`; there: filter DIRECT namedChildren of type `type_identifier`;
-  if ≥1, return the LAST one's source text (`source.substring(startIndex,
-  endIndex)` — UTF-16 units). If none, find the first `generic_type` child and
-  return its inner `type_identifier` text; else undefined. Never an impl parent
-  → undefined. QUIRK/BUG, PRESERVE: for `impl Trait for Generic<T>` the only
-  direct type_identifier is the TRAIT (probe: `impl Render for Container<T>` →
-  typeIdents=[`Render`] → receiver = **`Render`**, the trait name — methods get
-  qualifiedName `Render::render` and a contains edge from the trait node if one
-  exists in-file). `impl fmt::Display for Fields` is fine
-  (scoped_type_identifier isn't type_identifier → [Fields]). `impl<T>
-  Container<T>` → no direct type_identifiers → generic branch → `Container`.
+- **getReceiverType (rust.ts)** — walk PARENT chain to the nearest
+  `impl_item`; there, read the grammar's `type` field through
+  `rustImplTypeName` (kernel: `impl_type_name`): `type_identifier`/`identifier`
+  → text; `generic_type` → its `type` field (bare name, never the args);
+  `scoped_type_identifier`/`scoped_identifier` → its `name` field (last
+  segment); `reference_type` → its `type` field; anything else (tuple, `dyn`,
+  pointer, primitive, fn type) → undefined. Never an impl parent → undefined.
+  **Changed in #1588 on both sides together**: the original rule took the LAST
+  direct `type_identifier` child, so for `impl Trait for Generic<T>` /
+  `Parents<'a>` / `&Foo` the only bare identifier was the TRAIT's (probe:
+  `impl Render for Container<T>` → receiver **`Render`** → methods
+  `Render::render`, colliding with the trait declaration and feeding the
+  interface-impl synthesizer a phantom declaration). Now `Container`.
+  `impl fmt::Display for Fields` → `Fields`; `impl<T> Container<T>` →
+  `Container`; `impl Tr for m::Foo` → `Foo` (was: no receiver).
   Note `<T>` type_parameters is its own child, its inner T is NOT a direct
   impl child.
 - **extractImport (rust.ts:120)** — signature = trimmed full `use …;` text.
@@ -187,8 +190,9 @@ undefined; **no isConst means `const_item`/`static_item` extract as kind
   present AND not class-like — finds the FIRST node in `this.nodes` with
   `name === receiverType && filePath === this.filePath && kind ∈
   {struct,class,enum,trait}`. Source-order dependent: an impl ABOVE its struct
-  gets no contains edge. `impl Trait for Generic<T>` (receiver=trait bug) links
-  to the TRAIT node if it's in-file.** Then type annotations, decorators
+  gets no contains edge. Since #1588 `impl Trait for Generic<T>` links to the
+  implementing TYPE's node (it used to link to the TRAIT node, the receiver
+  bug).** Then type annotations, decorators
   (no-op), body walk with the method pushed.
 - **Nested `fn` inside an impl-method's body**: visitFunctionBody:5245 →
   named → extractFunction → getReceiverType walks parents THROUGH the outer fn
@@ -222,9 +226,13 @@ Generic else-branch (4312+), `func = childForFieldName('function') ?? namedChild
      (4455) → `Foo::new().bar()` → ref `Foo::new().bar`; an instance chain
      `x.foo().bar()` (innerFn field_expression) → bare `bar`. When not
      re-encoding, calleeName = bare methodName.
-   - receiver anything else (`field_expression` 2-hop `v.field.method()`,
-     `parenthesized_expression`, `await_expression`, `self`) → bare
-     methodName (probed all four).
+   - receiver `field_expression` whose `value` is `self` and whose `field` is
+     a `field_identifier` (`self.inner.run()`) → `self.inner.run` — the
+     owner-field shape the resolver types from the struct declaration
+     (#1585, both sides together).
+   - receiver anything else (`field_expression` with a non-self base
+     `v.field.method()` / deeper `self.a.b.m()`, `parenthesized_expression`,
+     `await_expression`, `self`) → bare methodName (probed all four).
 2. `func.type === 'scoped_identifier'` (4499) → calleeName = FULL text
    (`Foo::new`, `m::helper2`, `std::mem::swap` — whatever the source spells,
    whitespace included).

+ 110 - 79
src/bin/codegraph.ts

@@ -607,94 +607,111 @@ async function recordIndexTelemetry(
 // =============================================================================
 
 /**
- * codegraph init [path]
+ * The `init` flow — shared by `codegraph init` and `codegraph install --init`
+ * (#1578): refuse an unsafe root, create `.codegraph/`, build the initial
+ * index under supervision, then the post-index offers. `yes` makes every
+ * offer non-interactive (defaults only), so a container / CI bootstrap never
+ * blocks on a prompt. An unsafe root sets `process.exitCode = 1` and returns
+ * (no `--force` is implied by any caller); an index failure exits 1.
  */
-program
-  .command('init [path]')
-  .description('Initialize CodeGraph in a project directory and build the initial index')
-  .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility')
-  .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root')
-  .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
-  .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean }) => {
-    const projectPath = path.resolve(pathArg || process.cwd());
-    const clack = await importESM('@clack/prompts');
-
-    clack.intro('Initializing CodeGraph');
-
-    try {
-      // Refuse to index your home directory / a filesystem root — it pulls in
-      // caches, other projects, and your whole tree (a multi-GB index + watcher
-      // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845).
-      const unsafe = unsafeIndexRootReason(projectPath);
-      if (unsafe && !options.force) {
-        clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`);
-        clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.');
-        clack.outro('');
-        process.exitCode = 1;
-        return;
-      }
-
-      if (isInitialized(projectPath)) {
-        clack.log.warn(`Already initialized in ${projectPath}`);
-        clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
-        try {
-          const { offerWatchFallback } = await import('../installer');
-          await offerWatchFallback(clack, projectPath);
-        } catch { /* non-fatal */ }
-        clack.outro('');
-        return;
-      }
+async function runInit(
+  projectPath: string,
+  options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean },
+): Promise<void> {
+  const clack = await importESM('@clack/prompts');
 
-      const { default: CodeGraph, getDatabasePath } = await loadCodeGraph();
-      const cg = await CodeGraph.init(projectPath, { index: false });
-      clack.log.success(`Initialized in ${projectPath}`);
+  clack.intro('Initializing CodeGraph');
 
-      // Indexing runs by default now. The legacy -i/--index flag is still
-      // accepted (so existing muscle memory and scripts don't break) but is a
-      // no-op — initializing always builds the initial index.
-      // Supervise the index: self-terminate if orphaned or wedged (#999).
-      // The DB + WAL paths let the liveness watchdog tell a slow store on
-      // degraded storage from a true wedge (#1231).
-      // A closure so we can re-run the exact same supervised, progress-rendered
-      // index if the user opts gitignored child repos in below (#1156).
-      const dbPath = getDatabasePath(projectPath);
-      const runIndex = async (): Promise<IndexResult> => {
-        const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] });
-        try {
-          if (options.verbose) {
-            return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true });
-          }
-          process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
-          const progress = createShimmerProgress();
-          const r = await cg.indexAll({ onProgress: progress.onProgress });
-          await progress.stop();
-          return r;
-        } finally {
-          supervision.stop();
-        }
-      };
-      const result = await runIndex();
-      printIndexResult(clack, result, projectPath);
-      await recordIndexTelemetry(cg, result);
-
-      // An empty graph at a git super-repo usually means `.gitignore` excludes
-      // the child repos that hold the code — surface them and offer to opt in
-      // rather than leaving the user with a silent 0-node "Done". (#1156)
-      if (result.nodesCreated === 0) {
-        await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true });
-      }
+  try {
+    // Refuse to index your home directory / a filesystem root — it pulls in
+    // caches, other projects, and your whole tree (a multi-GB index + watcher
+    // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845).
+    const unsafe = unsafeIndexRootReason(projectPath);
+    if (unsafe && !options.force) {
+      clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`);
+      clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.');
+      clack.outro('');
+      process.exitCode = 1;
+      return;
+    }
 
+    if (isInitialized(projectPath)) {
+      clack.log.warn(`Already initialized in ${projectPath}`);
+      clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
       try {
         const { offerWatchFallback } = await import('../installer');
-        await offerWatchFallback(clack, projectPath);
+        await offerWatchFallback(clack, projectPath, { yes: options.yes });
       } catch { /* non-fatal */ }
+      clack.outro('');
+      return;
+    }
 
-      clack.outro('Done');
-      cg.destroy();
-    } catch (err) {
-      clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
-      process.exit(1);
+    const { default: CodeGraph, getDatabasePath } = await loadCodeGraph();
+    const cg = await CodeGraph.init(projectPath, { index: false });
+    clack.log.success(`Initialized in ${projectPath}`);
+
+    // Indexing runs by default now. The legacy -i/--index flag is still
+    // accepted (so existing muscle memory and scripts don't break) but is a
+    // no-op — initializing always builds the initial index.
+    // Supervise the index: self-terminate if orphaned or wedged (#999).
+    // The DB + WAL paths let the liveness watchdog tell a slow store on
+    // degraded storage from a true wedge (#1231).
+    // A closure so we can re-run the exact same supervised, progress-rendered
+    // index if the user opts gitignored child repos in below (#1156).
+    const dbPath = getDatabasePath(projectPath);
+    const runIndex = async (): Promise<IndexResult> => {
+      const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] });
+      try {
+        if (options.verbose) {
+          return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true });
+        }
+        process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
+        const progress = createShimmerProgress();
+        const r = await cg.indexAll({ onProgress: progress.onProgress });
+        await progress.stop();
+        return r;
+      } finally {
+        supervision.stop();
+      }
+    };
+    const result = await runIndex();
+    printIndexResult(clack, result, projectPath);
+    await recordIndexTelemetry(cg, result);
+
+    // An empty graph at a git super-repo usually means `.gitignore` excludes
+    // the child repos that hold the code — surface them and offer to opt in
+    // rather than leaving the user with a silent 0-node "Done". (#1156)
+    // Under --yes the offer prints its one-line opt-in snippet instead of
+    // prompting (same as a non-TTY run).
+    if (result.nodesCreated === 0) {
+      await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: !options.yes });
     }
+
+    try {
+      const { offerWatchFallback } = await import('../installer');
+      await offerWatchFallback(clack, projectPath, { yes: options.yes });
+    } catch { /* non-fatal */ }
+
+    clack.outro('Done');
+    cg.destroy();
+  } catch (err) {
+    clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
+    process.exit(1);
+  }
+}
+
+/**
+ * codegraph init [path]
+ */
+program
+  .command('init [path]')
+  .description('Initialize CodeGraph in a project directory and build the initial index')
+  .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility')
+  .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root')
+  .option('-v, --verbose', 'Show detailed worker lifecycle and memory info')
+  .option('-y, --yes', 'Non-interactive: skip every prompt and take the defaults (for scripts / CI / container bootstraps)')
+  .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean }) => {
+    await runInit(path.resolve(pathArg || process.cwd()), options);
   });
 
 /**
@@ -2268,6 +2285,7 @@ program
   .option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
   .option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
   .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
+  .option('-i, --init', 'After wiring agents, also run `codegraph init` in the current directory — builds this project’s index, so install + index is one command (combine with --yes for an unattended bootstrap)')
   .option('--no-permissions', 'Skip writing the auto-allow permissions list (Claude Code only)')
   .option('--print-config <id>', 'Print MCP config snippet for the named agent and exit (no file writes)')
   .option('--refresh', 'Rewrite what previous installs configured, for already-configured agents only (never adds new ones). Run automatically by `codegraph upgrade`')
@@ -2275,6 +2293,7 @@ program
     target?: string;
     location?: string;
     yes?: boolean;
+    init?: boolean;
     permissions?: boolean;
     printConfig?: string;
     refresh?: boolean;
@@ -2352,6 +2371,18 @@ program
       error(err instanceof Error ? err.message : String(err));
       process.exit(1);
     }
+
+    // --init: the one-shot "wire agents AND build this project's index"
+    // bootstrap (#1578). The installer itself never indexes implicitly (a
+    // surprise index of $HOME is the thing we refuse) — an explicit flag is
+    // the user choosing. Runs after a successful install, including the
+    // `--target none` / nothing-detected case (the installer returns normally
+    // there), and shares every guard with `codegraph init`: an unsafe root
+    // is refused (exit 1, no implied --force), an already-initialized
+    // project just says so. `--yes` flows through so no offer prompts.
+    if (opts.init) {
+      await runInit(process.cwd(), { yes: opts.yes });
+    }
   });
 
 /**

+ 41 - 2
src/extraction/grammars.ts

@@ -496,9 +496,40 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re
   return lang;
 }
 
+/**
+ * A class/struct BASE CLAUSE — `struct Derived : Base {`, `class Foo final :
+ * public Bar, private Baz {`, `struct D : ns::B<T> {` — which is never valid
+ * C. In C the only thing that can follow `struct <tag>` is `{`, `;`, `*`, an
+ * identifier (declarator), or a closing `)`: a bit-field's `:` sits after a
+ * member NAME inside the body (`unsigned a : 3;`), a ternary's `:` is
+ * separated from the tag by `)` / `*` / a declarator (`sizeof(struct foo) :
+ * 0`), and a label such as `struct_end:` has no whitespace after `struct`. An
+ * optional access specifier / `virtual` after the colon and an optional
+ * `final` before it cover the spelled-out forms; the base may be scoped
+ * (`ns::Base`) and carry template arguments, and must be followed by the
+ * body's `{` or a `,` introducing the next base — prose like
+ * `struct timeval: seconds and microseconds` inside a string never has that
+ * terminator. Comments are stripped before the scan (see `looksLikeCpp`).
+ */
+const CPP_BASE_CLAUSE_RE =
+  /\b(?:class|struct)\s+\w+\s*(?:final\s*)?:\s*(?:(?:public|protected|private|virtual)\s+)*[A-Za-z_][\w:]*(?:\s*<[^{};]*>)?\s*[{,]/;
+
+/** Block and line comments, for a code-only scan. Lazy block match → linear. */
+const C_COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g;
+
 /**
  * Heuristic: does a .h file contain C++ constructs?
- * Checks the first ~8KB for patterns that are unique to C++ and never valid C.
+ *
+ * Two passes. The first checks the first ~8KB for patterns that are unique to
+ * C++ and never valid C. The second scans the FULL source for a class/struct
+ * base clause (`CPP_BASE_CLAUSE_RE`): a large header with a long C-compatible
+ * preamble — include guards, `#define`s, plain C typedefs — can put its only
+ * C++ signal past the sample, and the cost of that miss is the C extractor
+ * (classTypes: []) dropping the derived type entirely and minting a phantom
+ * `function Base` from the base clause instead (#1592). The base-clause regex
+ * is anchored on a `struct`/`class` keyword followed by a tag and a colon, a
+ * shape with no C reading, so widening it to the whole file cannot drag a C
+ * header over to C++.
  */
 function looksLikeCpp(source: string): boolean {
   const sample = source.substring(0, 8192);
@@ -511,7 +542,15 @@ function looksLikeCpp(source: string): boolean {
   // routed through the C extractor (which extracts no classes), and its class
   // definition silently vanishes. The two-token shape (`<KW> <MACRO> <Name>`
   // before a `[:{]`) never occurs in valid C, so this can't misclassify C headers.
-  return /\bnamespace\b|\bclass\s+\w+\s*[:{]|\b(?:class|struct)\s+[A-Z][A-Z0-9_]+\s+\w+\s*(?:final\s*)?[:{]|\btemplate\s*<|\b(?:public|private|protected)\s*:|\bvirtual\b|\busing\s+(?:namespace\b|\w+\s*=)/.test(sample);
+  if (/\bnamespace\b|\bclass\s+\w+\s*[:{]|\b(?:class|struct)\s+[A-Z][A-Z0-9_]+\s+\w+\s*(?:final\s*)?[:{]|\btemplate\s*<|\b(?:public|private|protected)\s*:|\bvirtual\b|\busing\s+(?:namespace\b|\w+\s*=)/.test(sample)) {
+    return true;
+  }
+  // Plain `struct Derived : Base` (no export macro, no `class` keyword, no
+  // explicit access section) — the #1159 branch above only recognizes the
+  // macro-annotated form. Scanned over the whole file, not the sample, with
+  // comments removed so a doc comment's prose (`struct foo: x, y`) can't
+  // flip a C header.
+  return CPP_BASE_CLAUSE_RE.test(source.replace(C_COMMENT_RE, ' '));
 }
 
 /**

+ 54 - 2
src/extraction/index.ts

@@ -27,7 +27,7 @@ import { StoreWriter, StoreBundle, finalizeStoreBundle } from './store-writer';
 import { materializeKernelResult } from './kernel';
 import { detectGeneratedFile } from './generated-detection';
 import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
-import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config';
+import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns, PROJECT_CONFIG_FILENAME } from '../project-config';
 import { isCodeGraphDataDir } from '../directory';
 import { logDebug, logWarn } from '../errors';
 import { validatePathWithinRoot, normalizePath } from '../utils';
@@ -1448,12 +1448,48 @@ export class ExtractionOrchestrator {
    * hasn't run yet so single-file re-index paths can detect on the spot.
    */
   private detectedFrameworkNames: string[] | null = null;
+  /**
+   * Scope matcher for SCOPED syncs, memoized on the mtimes of the two root
+   * files it is derived from (`codegraph.json`, `.gitignore`). See
+   * {@link scopedSyncMatcher}.
+   */
+  private scopedMatcher: { key: string; matcher: ScopeIgnore } | null = null;
 
   constructor(rootDir: string, queries: QueryBuilder) {
     this.rootDir = rootDir;
     this.queries = queries;
   }
 
+  /**
+   * The scope matcher a scoped sync applies to the paths it was handed — the
+   * same `buildScopeIgnore` the full scan uses, so an explicitly-passed path
+   * that is OUT of scope (a user `exclude` in `codegraph.json`, a `.gitignore`
+   * rule, a built-in default) is treated exactly as the full walk would treat
+   * it: absent, hence removed if tracked, never parsed (#1590).
+   *
+   * Memoized on the root config + root `.gitignore` mtimes: building the
+   * matcher runs embedded-repo discovery (`git ls-files`), which would defeat
+   * the scoped path's whole point (skipping O(repo) work) if paid per sync.
+   * Two `stat`s per sync while nothing changed. An embedded repo created
+   * between config edits joins the scoped matcher on the next full sync, the
+   * same lifecycle the watcher's own matcher already has.
+   */
+  private scopedSyncMatcher(): ScopeIgnore {
+    const key = [PROJECT_CONFIG_FILENAME, '.gitignore']
+      .map((name) => {
+        try {
+          return String(fs.statSync(path.join(this.rootDir, name)).mtimeMs);
+        } catch {
+          return '-';
+        }
+      })
+      .join('|');
+    if (this.scopedMatcher && this.scopedMatcher.key === key) return this.scopedMatcher.matcher;
+    const matcher = buildScopeIgnore(this.rootDir);
+    this.scopedMatcher = { key, matcher };
+    return matcher;
+  }
+
   /**
    * Build a filesystem-backed ResolutionContext sufficient for framework
    * detection. Graph-query methods (getNodesByName etc.) return empty because
@@ -2700,7 +2736,23 @@ export class ExtractionOrchestrator {
       // reads `filesChecked === 0 && durationMs === 0` as the
       // lock-unavailable signature (#449).
       const unique = [...new Set(scopedPaths)];
-      currentFiles = unique.filter((p) => fs.existsSync(path.join(this.rootDir, p)));
+      // A scoped path is "present" only if it exists AND is in scope — the
+      // same two gates the full walk applies (source extension, scope
+      // matcher). Without the scope gate a caller's stale view of scope
+      // leaked straight into the index: the watcher re-parsed a file the
+      // user had just excluded in `codegraph.json` while `codegraph sync`
+      // removed it (#1590). Out-of-scope paths fall out of `currentFiles`,
+      // so a tracked one takes the removal branch below, exactly as a full
+      // sync would treat it. (`include`-forced paths pass: ScopeIgnore
+      // applies the include precedence itself.)
+      const scope = this.scopedSyncMatcher();
+      const overrides = loadExtensionOverrides(this.rootDir);
+      currentFiles = unique.filter(
+        (p) =>
+          isSourceFile(p, overrides) &&
+          !scope.ignores(p) &&
+          fs.existsSync(path.join(this.rootDir, p))
+      );
       trackedFiles = [];
       for (const p of unique) {
         const rec = this.queries.getFileByPath(p);

+ 43 - 26
src/extraction/languages/rust.ts

@@ -32,6 +32,45 @@ function extractRustReturnType(node: SyntaxNode, source: string): string | undef
   return last === 'Self' ? 'self' : last;
 }
 
+/**
+ * The implementing type's simple name for an `impl` block, read from the
+ * grammar's `type` field (#1588). Mirrored byte-for-byte by the native
+ * kernel's `impl_type_name` (codegraph-kernel/src/rustlang.rs) — change both.
+ *
+ * `impl<T> Source for BufSource<T>`, `impl<'a> Iterator for Parents<'a>`,
+ * `impl Trait for &Foo`, `impl Trait for m::Foo` all yield the implementing
+ * TYPE (`BufSource`, `Parents`, `Foo`, `Foo`). The previous rule took the last
+ * bare `type_identifier` child of the `impl_item`; once the implementing type
+ * carries parameters it parses as a `generic_type`, so the only bare
+ * identifier left was the TRAIT's — every parameterized impl's methods were
+ * qualified by the trait (`Source::read`), unaddressable by their type and
+ * colliding with the trait's own declaration.
+ *
+ * Shapes that name no single type (tuples, `dyn Trait`, pointers, primitives,
+ * function types…) yield undefined: no receiver, and the fn is extracted
+ * exactly as before.
+ */
+export function rustImplTypeName(typeNode: SyntaxNode | null, source: string): string | undefined {
+  if (!typeNode) return undefined;
+  switch (typeNode.type) {
+    case 'type_identifier':
+    case 'identifier':
+      return getNodeText(typeNode, source);
+    // `Foo<T>` — the `type` field is the bare (or scoped) name, never the args.
+    case 'generic_type':
+      return rustImplTypeName(getChildByField(typeNode, 'type'), source);
+    // `m::Foo` — the last segment is the type's name.
+    case 'scoped_type_identifier':
+    case 'scoped_identifier':
+      return rustImplTypeName(getChildByField(typeNode, 'name'), source);
+    // `&Foo` / `&'a mut Foo` — the referenced type.
+    case 'reference_type':
+      return rustImplTypeName(getChildByField(typeNode, 'type'), source);
+    default:
+      return undefined;
+  }
+}
+
 export const rustExtractor: LanguageExtractor = {
   // `function_signature_item` is a trait method DECLARATION (`fn render(&self);`,
   // no body). Extracting it makes a trait's method set first-class, which
@@ -88,32 +127,10 @@ export const rustExtractor: LanguageExtractor = {
     let parent = node.parent;
     while (parent) {
       if (parent.type === 'impl_item') {
-        // For `impl Type { ... }` — the type is a direct type_identifier child
-        // For `impl Trait for Type { ... }` — the type is the LAST type_identifier
-        // (the first is part of the trait path)
-        const children = parent.namedChildren;
-        // Find all direct type_identifier children (not nested in scoped paths)
-        const typeIdents = children.filter(
-          (c: SyntaxNode) => c.type === 'type_identifier'
-        );
-        if (typeIdents.length > 0) {
-          // Last type_identifier is always the implementing type
-          const typeNode = typeIdents[typeIdents.length - 1]!;
-          return source.substring(typeNode.startIndex, typeNode.endIndex);
-        }
-        // Handle generic types: impl<T> MyStruct<T> { ... }
-        const genericType = children.find(
-          (c: SyntaxNode) => c.type === 'generic_type'
-        );
-        if (genericType) {
-          const innerType = genericType.namedChildren.find(
-            (c: SyntaxNode) => c.type === 'type_identifier'
-          );
-          if (innerType) {
-            return source.substring(innerType.startIndex, innerType.endIndex);
-          }
-        }
-        return undefined;
+        // The grammar names the implementing type directly (the `type` field)
+        // for both `impl Type { … }` and `impl Trait for Type { … }` — see
+        // rustImplTypeName for why the old positional scan was wrong (#1588).
+        return rustImplTypeName(getChildByField(parent, 'type'), source);
       }
       parent = parent.parent;
     }

+ 6 - 0
src/extraction/parse-pool.ts

@@ -214,6 +214,12 @@ export class ParseWorkerPool {
       this.createWorker = opts.createWorker;
     } else if (opts.workerScriptPath) {
       const scriptPath = opts.workerScriptPath;
+      // Deliberately no `resourceLimits.stackSizeMb`: a bigger worker stack
+      // only moves the cliff a deeply nested file falls off (#1581 — the
+      // 8 MiB main thread still dies at 100k levels). The native kernel
+      // guards its own recursion against THIS thread's real stack bounds
+      // (codegraph-kernel/src/stack.rs) and defers such a file to the wasm
+      // path, which catches its JS RangeError per file.
       this.createWorker = () => new Worker(scriptPath);
     } else {
       throw new Error('ParseWorkerPool requires workerScriptPath or createWorker');

+ 35 - 32
src/extraction/tree-sitter.ts

@@ -22,6 +22,7 @@ import { isGeneratedFile } from './generated-detection';
 import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
 import { EXTRACTORS } from './languages';
 import { stripCppTemplateArgs } from './languages/c-cpp';
+import { rustImplTypeName } from './languages/rust';
 import { LiquidExtractor } from './liquid-extractor';
 import { RazorExtractor } from './razor-extractor';
 import { SvelteExtractor } from './svelte-extractor';
@@ -4430,6 +4431,26 @@ export class TreeSitterExtractor {
               } else {
                 calleeName = methodName;
               }
+            } else if (
+              this.language === 'rust' &&
+              receiver &&
+              receiver.type === 'field_expression' &&
+              getChildByField(receiver, 'value')?.type === 'self' &&
+              getChildByField(receiver, 'field')?.type === 'field_identifier'
+            ) {
+              // Rust `self.<field>.<method>()` — a call through a field of the
+              // enclosing type (#1585). Keep the `self.` prefix: the resolver
+              // recognizes the shape, reads the field's declared type off the
+              // owner struct's declaration, and resolves the method on THAT
+              // type — or leaves the ref unresolved when the type is external
+              // or unknown. Previously this collapsed to the bare method name,
+              // which exact-matched whichever same-named method was nearest —
+              // often the calling method itself, a self-edge not in the source.
+              // Deeper chains (`self.a.b.m()`), `self.f().m()` and parenthesized
+              // receivers keep the bare name. Mirrored in the kernel's
+              // extract_call (rustlang.rs).
+              const fieldName = getNodeText(getChildByField(receiver, 'field')!, this.source);
+              calleeName = `self.${fieldName}.${methodName}`;
             } else if (
               (this.language === 'cpp' ||
                 this.language === 'c' ||
@@ -5717,38 +5738,20 @@ export class TreeSitterExtractor {
    * For plain `impl Type { ... }` (no trait), no inheritance edge is needed.
    */
   private extractRustImplItem(node: SyntaxNode): void {
-    // Check if this is `impl Trait for Type` by looking for a `for` keyword
-    const hasFor = node.children.some(
-      (c: SyntaxNode) => c.type === 'for' && !c.isNamed
-    );
-    if (!hasFor) return;
-
-    // In `impl Trait for Type`, the type_identifiers are:
-    // first = Trait name, last = implementing Type name
-    // Also handle generic types like `impl<T> Trait for MyStruct<T>`
-    const typeIdents = node.namedChildren.filter(
-      (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'generic_type' || c.type === 'scoped_type_identifier'
-    );
-    if (typeIdents.length < 2) return;
-
-    const traitNode = typeIdents[0]!;
-    const typeNode = typeIdents[typeIdents.length - 1]!;
-
-    // Get the trait name (handle scoped paths like std::fmt::Display)
-    const traitName = traitNode.type === 'scoped_type_identifier'
-      ? this.source.substring(traitNode.startIndex, traitNode.endIndex)
-      : getNodeText(traitNode, this.source);
-
-    // Get the implementing type name (extract inner type_identifier for generics)
-    let typeName: string;
-    if (typeNode.type === 'generic_type') {
-      const inner = typeNode.namedChildren.find(
-        (c: SyntaxNode) => c.type === 'type_identifier'
-      );
-      typeName = inner ? getNodeText(inner, this.source) : getNodeText(typeNode, this.source);
-    } else {
-      typeName = getNodeText(typeNode, this.source);
-    }
+    // `impl Trait for Type` carries the trait in the grammar's `trait` field;
+    // an inherent `impl Type { … }` has none and needs no inheritance edge.
+    const traitNode = getChildByField(node, 'trait');
+    if (!traitNode) return;
+
+    // Full text, so a scoped path (`std::fmt::Display`) and a generic trait
+    // (`From<u32>`) keep their spelling.
+    const traitName = getNodeText(traitNode, this.source);
+
+    // The implementing type from the `type` field (#1588). The old positional
+    // scan took the LAST type-shaped child, which for a parameterized
+    // implementing type (`BufSource<T>`, `Parents<'a>`, `&Foo`) was the trait.
+    const typeName = rustImplTypeName(getChildByField(node, 'type'), this.source);
+    if (!typeName) return;
 
     // Find the struct/type node for the implementing type
     const typeNodeId = this.findNodeByName(typeName);

+ 3 - 2
src/installer/index.ts

@@ -285,9 +285,10 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis
   // index a surprise directory (e.g. a shell sitting in $HOME). Same next step
   // regardless of global/local scope.
   clack.note(
-    location === 'local'
+    (location === 'local'
       ? 'codegraph init        # build this project’s graph (one time; auto-syncs after)'
-      : 'cd <your-project>\ncodegraph init        # build a project’s graph (one time; auto-syncs after)',
+      : 'cd <your-project>\ncodegraph init        # build a project’s graph (one time; auto-syncs after)') +
+      '\n# (codegraph install --init does both steps in one command)',
     'Next: index a project',
   );
 

+ 15 - 0
src/resolution/import-resolver.ts

@@ -12,6 +12,7 @@ import { applyAliases } from './path-aliases';
 import { resolveWorkspaceImport } from './workspace-packages';
 import {
   resolveMethodOnType,
+  resolveObjectLiteralMember,
   localReceiverTypePatterns,
   normalizeInferredTypeName,
 } from './name-matcher';
@@ -1545,6 +1546,20 @@ export function resolveViaImport(
                 resolvedBy: 'import',
               };
             }
+            // An imported object literal used as a namespace (#1573):
+            // `api.call()` after `import { api } from './api'` where `api` is
+            // `export const api = { call() {…} }`. Its members have bare
+            // qualified names inside the constant's extent, so the
+            // `Container::member` lookup above can't see them and the edge
+            // landed on the constant — every cross-file caller of the method
+            // went missing. Resolve the member by containment instead.
+            if (targetNode.kind === 'constant' || targetNode.kind === 'variable') {
+              const member = ref.referenceName.slice(imp.localName.length + 1).split('.')[0];
+              if (member) {
+                const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
+                if (literalMember) return literalMember;
+              }
+            }
             // An imported VALUE (singleton constant / shared instance) called
             // through a member: `reproStore.notifyJoinGuildStatus()` after
             // `import { reproStore } from './store'`. findExportedSymbol

+ 224 - 0
src/resolution/name-matcher.ts

@@ -543,6 +543,93 @@ export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[]
   return same.length ? [...same, ...other] : nodes;
 }
 
+/**
+ * Languages whose object literals declare callable members — `export const
+ * api = { call() {…}, get: () => {…} }` used as a namespace (#1573).
+ */
+const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
+
+/** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */
+function rangeWithin(inner: Node, outer: Node): boolean {
+  const innerEnd = inner.endLine ?? inner.startLine;
+  const outerEnd = outer.endLine ?? outer.startLine;
+  if (inner.startLine < outer.startLine || innerEnd > outerEnd) return false;
+  if (inner.startLine === outer.startLine && inner.startColumn < outer.startColumn) return false;
+  if (innerEnd === outerEnd && inner.endColumn > outer.endColumn) return false;
+  return true;
+}
+
+function sameRange(a: Node, b: Node): boolean {
+  return (
+    a.startLine === b.startLine &&
+    a.startColumn === b.startColumn &&
+    (a.endLine ?? a.startLine) === (b.endLine ?? b.startLine) &&
+    a.endColumn === b.endColumn
+  );
+}
+
+/**
+ * Resolve `container.member` where `container` is a VALUE holding an object
+ * literal — `export const api = { call() {…}, get: () => {…} }` used as the
+ * module's namespace (#1573). The members are extracted as plain functions
+ * with BARE qualified names inside the constant's source extent (there is no
+ * `api::call`), so neither the `Container::member` lookup the class-shaped
+ * kinds use (#825) nor the declared-type inference for singleton instances
+ * (#1292) can reach them, and every such call resolved to nothing — or, via
+ * an import, to the constant itself. This looks the member up by CONTAINMENT:
+ * a node named `member` whose range lies inside the container's, in the
+ * container's own file. A helper declared inside a member's body is not a
+ * member and is skipped; nothing else in the file can donate a match. Calls
+ * take callable kinds only; other references accept value members too.
+ */
+export function resolveObjectLiteralMember(
+  container: Node,
+  member: string,
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+  confidence: number,
+  resolvedBy: ResolvedRef['resolvedBy'],
+): ResolvedRef | null {
+  if (container.kind !== 'constant' && container.kind !== 'variable') return null;
+  if (!OBJECT_LITERAL_LANGUAGES.has(container.language)) return null;
+  if (!sameLanguageFamily(container.language, ref.language)) return null;
+
+  const inFile = context.getNodesInFile(container.filePath);
+  const callable = (n: Node) => n.kind === 'function' || n.kind === 'method';
+  const valueMember = (n: Node) =>
+    callable(n) || n.kind === 'property' || n.kind === 'variable' || n.kind === 'constant';
+  const accepts = ref.referenceKind === 'calls' ? callable : valueMember;
+
+  const inside = inFile.filter((n) => n.id !== container.id && rangeWithin(n, container));
+  let candidates = inside.filter((n) => n.name === member && accepts(n));
+  if (candidates.length === 0) return null;
+
+  // Drop a candidate nested inside ANOTHER callable's body within the literal
+  // (`{ run() { const call = () => {}; } }` — `call` is `run`'s local, not a
+  // member). Strict containment: an identically-ranged sibling node for the
+  // same member (a property node over an arrow function) is not a body.
+  const bodies = inside.filter(callable);
+  candidates = candidates.filter(
+    (c) => !bodies.some((b) => b.id !== c.id && !sameRange(b, c) && rangeWithin(c, b))
+  );
+  if (candidates.length === 0) return null;
+
+  // Several survivors (a property AND a function for one arrow member, say):
+  // a callable first, then the earliest in source order.
+  candidates.sort((a, b) => {
+    const ca = callable(a) ? 0 : 1;
+    const cb = callable(b) ? 0 : 1;
+    if (ca !== cb) return ca - cb;
+    return a.startLine - b.startLine || a.startColumn - b.startColumn;
+  });
+  return {
+    original: ref,
+    targetNodeId: candidates[0]!.id,
+    confidence,
+    resolvedBy,
+  };
+}
+
 // Exported for the precedence unit tests (#1079): they assert the
 // preferredFqn → same-file → matches[0] ordering directly.
 export function resolveMethodOnType(
@@ -1731,6 +1818,18 @@ export function matchMethodCall(
     return matchGoFieldChainCall(objectOrClass!, methodName!, ref, context);
   }
 
+  // Rust call through a field of the enclosing type — `self.inner.run()`,
+  // emitted as `self.inner.run` (#1585). Same discipline as the Go branch
+  // above, and EXCLUSIVE for the same reason: validated field-type inference
+  // or nothing. Letting this shape reach the bare-name strategies below is
+  // how `self.inner.run()` resolved to a same-named method on an unrelated
+  // type — or to the calling method itself, a self-edge the source doesn't
+  // contain — whenever the field's type was external or merely shared a
+  // method name with something nearby.
+  if (ref.language === 'rust' && dotMatch && objectOrClass!.startsWith('self.')) {
+    return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context);
+  }
+
   // Java/Kotlin: receiver may be a field whose name doesn't match the type by
   // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up
   // the field in the enclosing class to get its declared type, then resolve
@@ -1759,6 +1858,27 @@ export function matchMethodCall(
     }
   }
 
+  // Object-literal namespace receiver (#1573): `api.call()` where `api` is a
+  // same-file `const api = { call() {…}, get: () => {…} }`. Its members are
+  // plain functions with bare names inside the constant's extent — no
+  // `Container::member` qualified name — so none of the class-shaped
+  // strategies below can see them (Strategy 3 only considers `method`
+  // kinds) and the call resolved to nothing at all. Same file only: a
+  // cross-file use reaches the same helper through the import path.
+  if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) {
+    const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => {
+      const holders = preferCallSiteFile(context.getNodesByName(objectOrClass!), ref.filePath).filter(
+        (n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath
+      );
+      for (const holder of holders) {
+        const hit = resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method');
+        if (hit) return hit;
+      }
+      return null;
+    });
+    if (literalMatch) return literalMatch;
+  }
+
   // Strategy 1: Direct class name match (existing logic). When the receiver
   // names a class that exists in several files (`Logger.log()` / `Logger::log()`
   // with a `Logger` in both `a/` and `b/`), try the class in the call site's
@@ -1992,6 +2112,110 @@ function matchGoFieldChainCall(
   return null;
 }
 
+// Rust primitives and the prelude's own types: a field of one of these never
+// names a project type, so a `self.<field>.<method>()` on it stays unresolved.
+const RUST_NON_PROJECT_FIELD_TYPES = new Set([
+  'bool', 'char', 'str', 'String',
+  'i8', 'i16', 'i32', 'i64', 'i128', 'isize',
+  'u8', 'u16', 'u32', 'u64', 'u128', 'usize',
+  'f32', 'f64',
+  'Self', 'self',
+]);
+
+/**
+ * Reduce a Rust field's declared type text to the simple name of the type a
+ * method call on that field auto-derefs to, or null when there is none we can
+ * name. Only the layers Rust's method-call auto-deref looks through are
+ * unwrapped: references (`&`, `&'a mut`) and the owning smart pointers
+ * (`Box`, `Rc`, `Arc`) — `self.inner.run()` with `inner: Box<Inner>` calls
+ * `Inner::run`. Containers that do NOT auto-deref to their parameter
+ * (`Option<Inner>`, `Vec<Inner>`, `Mutex<Inner>`, `RefCell<Inner>`) keep their
+ * own name and, having no project node, resolve to nothing — `self.items.push()`
+ * must never become `Inner::push`. A trait object (`Box<dyn Source>`) yields
+ * the trait, whose method node the interface-impl synthesizer fans out. A
+ * generic parameter (`T`), a primitive, a tuple / array / raw pointer / fn
+ * type, or a non-identifier yields null.
+ */
+export function rustFieldTypeName(raw: string): string | null {
+  let t = raw.trim();
+  for (;;) {
+    const before = t;
+    t = t.replace(/^&\s*(?:'\w+\s+)?(?:mut\s+)?/, '');
+    t = t.replace(/^(?:Box|Rc|Arc)\s*<\s*/, '');
+    t = t.replace(/^(?:dyn|impl)\s+/, '');
+    if (t === before) break;
+  }
+  // Drop generic args, the closing `>`s of unwrapped pointers, and trait-object
+  // bounds (`dyn Source + Send`); keep the last path segment.
+  t = t.replace(/[<>+].*$/, '').trim();
+  const seg = t.split('::').filter(Boolean).pop();
+  if (!seg || !/^[A-Za-z_]\w*$/.test(seg)) return null;
+  if (RUST_NON_PROJECT_FIELD_TYPES.has(seg)) return null;
+  if (/^[A-Z]$/.test(seg)) return null; // bare single-letter generic parameter
+  return seg;
+}
+
+/**
+ * Resolve a Rust call through a field of the enclosing type —
+ * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585).
+ * Mirrors the Go 2-hop precedent above (#1276): the owner type is the calling
+ * method's qualified-name prefix (`Outer::run` → `Outer`), the field's declared
+ * type comes from the owner struct's OWN declaration lines, and the method is
+ * resolved AND VALIDATED on that type by resolveMethodOnType. The caller
+ * treats this branch as exclusive for `self.<field>` receivers: a field whose
+ * type is external (`std::vec::IntoIter`, `regex::Regex`), a generic
+ * parameter, or not declared where we can see it yields null and the ref stays
+ * unresolved. Rust struct fields are not graph nodes, so the declaration text
+ * is the only place the type lives.
+ */
+function matchRustSelfFieldCall(
+  field: string,
+  methodName: string,
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+): ResolvedRef | null {
+  // The extractor only ever emits a single field hop; anything else is not ours.
+  if (!field || field.includes('.')) return null;
+  const caller = context.getNodeById?.(ref.fromNodeId);
+  if (!caller) return null;
+  const sep = caller.qualifiedName.lastIndexOf('::');
+  if (sep <= 0) return null; // a free fn has no `self`
+  const owner = caller.qualifiedName.slice(0, sep).split('::').pop();
+  if (!owner) return null;
+
+  const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter(
+    (n) =>
+      (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class') &&
+      n.language === 'rust'
+  );
+  const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  // `pub inner: Inner,` / `inner: Box<dyn Source>,` / `pub(crate) inner: T }` —
+  // the type text runs to the field separator. A comma inside generic args
+  // (`HashMap<K, V>`) truncates the capture, which rustFieldTypeName then
+  // reduces to the container's own name — exactly the non-deref case it
+  // refuses anyway.
+  const fieldRe = new RegExp(`\\b${fieldEsc}\\s*:\\s*([^,{}]+)`);
+  for (const s of owners) {
+    const source = context.readFile(s.filePath);
+    if (!source) continue;
+    // Only the struct's own declaration lines, comment-stripped line by line —
+    // same discipline as the Go helper: prose or a same-named identifier
+    // elsewhere in the file can never donate a type.
+    const declLines = source.split('\n').slice(Math.max(0, s.startLine - 1), s.endLine);
+    for (const rawLine of declLines) {
+      const line = rawLine.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '');
+      const m = line.match(fieldRe);
+      if (!m || !m[1]) continue;
+      const fieldType = rustFieldTypeName(m[1]);
+      // The field is declared here; whether or not its type names a project
+      // symbol, this owner is the answer — no other same-named struct applies.
+      if (!fieldType) return null;
+      return resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method');
+    }
+  }
+  return null;
+}
+
 /**
  * Split a camelCase or PascalCase string into words.
  */

+ 53 - 6
src/sync/watcher.ts

@@ -34,7 +34,7 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { isSourceFile, buildScopeIgnore, type ScopeIgnore } from '../extraction';
-import { loadExtensionOverrides } from '../project-config';
+import { loadExtensionOverrides, PROJECT_CONFIG_FILENAME } from '../project-config';
 import { logDebug, logWarn } from '../errors';
 import { normalizePath } from '../utils';
 import { isCodeGraphDataDir } from '../directory';
@@ -328,11 +328,13 @@ export class FileWatcher {
    * deterministically gate on watcher readiness.
    */
   private readyWaiters: Array<() => void> = [];
-  // The shared scope matcher (built-in defaults + project .gitignore, with
-  // embedded child repos matched by their OWN rules — #514), built once at
-  // start(). Same source of truth the indexer uses, so watcher scope can
-  // never diverge from index scope. An embedded repo created after start()
-  // joins the scope on the next watcher restart / re-index.
+  // The shared scope matcher (built-in defaults + project .gitignore + the
+  // `codegraph.json` exclude/include rules, with embedded child repos matched
+  // by their OWN rules — #514), built at start() and REBUILT whenever one of
+  // the files it is derived from changes (see `refreshScope`, #1590). Same
+  // source of truth the indexer uses, so watcher scope can never diverge from
+  // index scope. An embedded repo created after start() joins the scope on
+  // the next scope refresh / watcher restart / re-index.
   private ignoreMatcher: ScopeIgnore | null = null;
 
   private readonly projectRoot: string;
@@ -573,7 +575,24 @@ export class FileWatcher {
   private handleChange(rel: string): void {
     if (!rel || rel === '.' || rel.startsWith('..')) return;
     if (this.isAlwaysIgnored(rel)) return;
+    // The two root files the scope matcher is derived from are handled BEFORE
+    // the matcher is consulted: a user `exclude` pattern that happens to cover
+    // them (`*.json`, `.*`) must not be able to hide their own edits (#1590).
+    if (rel === PROJECT_CONFIG_FILENAME || rel === '.gitignore') {
+      this.refreshScope(rel);
+      return;
+    }
     if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
+    // A nested `.gitignore` (an embedded child repo's own rules, #514, or a
+    // subdirectory rule the git-backed full scan honors) is only a scope
+    // change when it sits INSIDE the current scope — checked after the matcher
+    // on purpose, so the thousands of package-local `.gitignore`s an
+    // `npm install` writes under an ignored `node_modules/` never trigger a
+    // rebuild storm.
+    if (rel.endsWith('/.gitignore')) {
+      this.refreshScope(rel);
+      return;
+    }
     if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) {
       this.maybeScheduleForRemovedDir(rel);
       return;
@@ -591,6 +610,34 @@ export class FileWatcher {
     this.scheduleSync();
   }
 
+  /**
+   * A scope-defining file changed (`codegraph.json`, a `.gitignore`): rebuild
+   * the ignore matcher and make the next sync a FULL reconcile (#1590).
+   *
+   * The matcher used to be built once in `start()` and kept for the watcher's
+   * lifetime — in a long-lived MCP daemon that meant a `codegraph.json`
+   * created or edited after startup was invisible to the live watcher, while
+   * `codegraph sync` (a fresh process) honoured it immediately: the CLI
+   * removed a newly excluded file and the watcher re-added it seconds later.
+   * `loadExtensionOverrides()` on the same filter line was already read live
+   * (mtime-cached), so two fields of the same config file disagreed.
+   *
+   * Rebuilding costs one `git ls-files` pass (embedded-repo discovery), which
+   * is fine per config edit — never per event. Replacing the field is enough
+   * for both strategies: the recursive handler and the per-directory
+   * `shouldIgnoreDir` walk read `this.ignoreMatcher` on every call. The full
+   * scan is required because a scope change has no per-file events: newly
+   * excluded files must be REMOVED from the index and newly included ones
+   * added, and only the scan-diff (which builds its own fresh matcher) knows
+   * which those are.
+   */
+  private refreshScope(rel: string): void {
+    logDebug('Scope config changed; rebuilding watcher scope', { file: rel });
+    this.ignoreMatcher = buildScopeIgnore(this.projectRoot);
+    this.needsFullScan = true;
+    this.scheduleSync();
+  }
+
   /**
    * A deleted DIRECTORY arrives as one event on the directory's own path —
    * no source extension, so the source-file filter drops it, and the files