Просмотр исходного кода

Merge origin/main and reconcile regression-audit history

Preserve the local regression-audit commits alongside their upstream squash and subsequent fixes. Resolve the overlapping changelog, regression tests, audit report, and name matcher to retain the reviewed upstream follow-ups.
Colby McHenry 5 дней назад
Родитель
Сommit
464e8cf38e
41 измененных файлов с 2985 добавлено и 200 удалено
  1. 9 0
      CHANGELOG.md
  2. 10 3
      TELEMETRY.md
  3. 148 0
      __tests__/awaited-receiver.test.ts
  4. 31 0
      __tests__/cli-unlock.test.ts
  5. 29 0
      __tests__/daemon-manager.test.ts
  6. 241 0
      __tests__/daemon-pid-reuse.test.ts
  7. 85 0
      __tests__/daemon-registry.test.ts
  8. 42 0
      __tests__/daemon-socket-fallback.test.ts
  9. 15 8
      __tests__/extraction.test.ts
  10. 148 0
      __tests__/git-index-currency.test.ts
  11. 4 2
      __tests__/installer-targets.test.ts
  12. 119 16
      __tests__/mcp-daemon.test.ts
  13. 8 4
      __tests__/mcp-ppid-watchdog.test.ts
  14. 11 0
      __tests__/release-main-regressions.test.ts
  15. 125 0
      __tests__/resolution.test.ts
  16. 68 0
      __tests__/rust-self-owner.test.ts
  17. 86 0
      __tests__/store-binding-cache.test.ts
  18. 80 0
      __tests__/sync-rebuild-convergence.test.ts
  19. 162 0
      __tests__/sync.test.ts
  20. 92 0
      __tests__/telemetry-optout.test.ts
  21. 30 0
      __tests__/writer-lock.test.ts
  22. 10 2
      codegraph-kernel/src/rustlang.rs
  23. 363 0
      docs/benchmarks/regression-audit-2026-09.md
  24. 11 5
      docs/design/telemetry.md
  25. 128 0
      scripts/benchmarks/measure-index.cjs
  26. 91 0
      scripts/benchmarks/observe-index.py
  27. 15 0
      src/db/queries.ts
  28. 190 50
      src/extraction/index.ts
  29. 11 0
      src/extraction/tree-sitter.ts
  30. 11 1
      src/index.ts
  31. 14 0
      src/mcp/daemon-manager.ts
  32. 13 3
      src/mcp/daemon-paths.ts
  33. 65 24
      src/mcp/daemon-registry.ts
  34. 55 27
      src/mcp/daemon.ts
  35. 14 1
      src/mcp/engine.ts
  36. 77 8
      src/mcp/index.ts
  37. 1 0
      src/resolution/index.ts
  38. 12 0
      src/resolution/js-builtins.ts
  39. 281 8
      src/resolution/name-matcher.ts
  40. 4 0
      src/resolution/types.ts
  41. 76 38
      src/telemetry/index.ts

+ 9 - 0
CHANGELOG.md

@@ -145,18 +145,27 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 
 ### Fixes
 ### Fixes
 
 
+- Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861)
+
+- Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869)
+
 - Calls between JavaScript, JSX and TypeScript files keep their callers and callback flows.
 - Calls between JavaScript, JSX and TypeScript files keep their callers and callback flows.
 - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook.
 - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook.
 - Steps diagrams retain database operations made through external client chains without inventing internal dependencies.
 - Steps diagrams retain database operations made through external client chains without inventing internal dependencies.
 - Direct React Native bridge calls retain their native implementations and cross-platform relationships.
 - Direct React Native bridge calls retain their native implementations and cross-platform relationships.
 - Dart extension-type getters remain searchable when using the WebAssembly parser.
 - Dart extension-type getters remain searchable when using the WebAssembly parser.
 
 
+- Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840)
 - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
 - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
 - `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
 - `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
 - `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
 - `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
 
 
 #### MCP / indexing
 #### MCP / indexing
 
 
+- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
+- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
+- Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)
+
 - The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)
 - The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)
 
 
 - Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
 - Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)

+ 10 - 3
TELEMETRY.md

@@ -26,7 +26,14 @@ toggle and never re-asks. If you never saw the installer (e.g. `npx` straight in
 a one-line notice is printed to stderr before the first time anything is sent.
 a one-line notice is printed to stderr before the first time anything is sent.
 
 
 Off means off: when disabled, CodeGraph records nothing, opens no connection to the
 Off means off: when disabled, CodeGraph records nothing, opens no connection to the
-telemetry endpoint, and sends no "opted out" ping.
+telemetry endpoint, and sends no "opted out" ping. Running processes recheck the stored
+choice before recording, persisting, and each send. Turning it off removes the local
+identity and unsent queues (including claimed queues); turning it back on creates a new
+identity. An HTTP request already started cannot be recalled, but opt-out prevents later
+request chunks and prevents its unsent data from being requeued.
+
+Environment overrides still apply: `CODEGRAPH_TELEMETRY=1` explicitly forces telemetry
+on for that process even when the stored choice is off; `DO_NOT_TRACK=1` takes precedence.
 
 
 Separately from telemetry, the MCP server checks GitHub for a newer release in the
 Separately from telemetry, the MCP server checks GitHub for a newer release in the
 background (at most once a day) so it can tell you an update exists — it fetches a
 background (at most once a day) so it can tell you an update exists — it fetches a
@@ -96,8 +103,8 @@ source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It
 every event and property against the allowlist above (anything else is dropped), never
 every event and property against the allowlist above (anything else is dropped), never
 reads the client IP, and rate-limits per machine ID. Sends are fire-and-forget with a
 reads the client IP, and rate-limits per machine ID. Sends are fire-and-forget with a
 short timeout: offline or air-gapped machines buffer a bounded local file (256 KB cap)
 short timeout: offline or air-gapped machines buffer a bounded local file (256 KB cap)
-and never retry-loop, log errors, or slow a command down. Telemetry never adds latency to
-MCP tool calls — recording is an in-memory counter.
+and never retry-loop, log errors, or slow a command down. Recording refreshes the small local consent file, then increments an in-memory counter;
+MCP tool calls never wait for telemetry network requests or queue writes.
 
 
 ## Where it is stored
 ## Where it is stored
 
 

+ 148 - 0
__tests__/awaited-receiver.test.ts

@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CodeGraph } from '../src';
+
+let root: string;
+let cg: CodeGraph | undefined;
+beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-awaited-')); });
+afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); });
+async function index(files: Record<string, string>) {
+  for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(root, name), text);
+  cg = await CodeGraph.init(root, { index: true });
+}
+function calls(name: string, file = 'caller.ts') {
+  const node = cg!.getNodesByKind('function').find(n => n.name === name && n.filePath === file);
+  expect(node).toBeDefined();
+  return cg!.getCallees(node!.id).filter(({ edge }) => edge.kind === 'calls')
+    .map(({ node, edge }) => ({ target: `${node.filePath}:${node.qualifiedName}`, line: edge.line }));
+}
+const engine = 'export class Engine { run() {} }\nexport async function makeEngine(): Promise<Engine> { return new Engine(); }';
+
+it('follows an imported factory alias rather than an unrelated namesake (#1840)', async () => {
+  await index({
+    'engine.ts': engine,
+    'decoy.ts': 'export async function load(): Promise<string> { return ""; }',
+    'caller.ts': 'import { makeEngine as load } from "./engine";\nexport async function drive() { const handle = await load(); handle.run(); }',
+  });
+  expect(calls('drive').map(c => c.target).sort()).toEqual(['engine.ts:Engine::run', 'engine.ts:makeEngine']);
+});
+
+it('resolves return-type aliases in the factory module, not a caller-local decoy (#1840)', async () => {
+  await index({
+    'engine.ts': engine,
+    'factory.ts': 'import { Engine as Service } from "./engine";\nexport async function load(): Promise<Service> { return new Service(); }',
+    'caller.ts': 'import { load } from "./factory";\nclass Service { run() {} }\nexport async function drive() { const handle = await load(); handle.run(); }',
+  });
+  expect(calls('drive').map(c => c.target).sort()).toEqual(['engine.ts:Engine::run', 'factory.ts:load']);
+});
+
+it('does not infer from a factory hidden by a parameter (#1840)', async () => {
+  await index({
+    'engine.ts': engine,
+    'caller.ts': 'import { makeEngine } from "./engine";\nexport async function drive(makeEngine: () => Promise<string>) { const handle = await makeEngine(); handle.run(); }',
+  });
+  expect(calls('drive').map(c => c.target)).not.toContain('engine.ts:Engine::run');
+});
+
+it('distinguishes same-named awaited receivers in sibling blocks (#1840)', async () => {
+  await index({ 'caller.ts': `class PaneManager { split() {} }
+async function text(): Promise<string> { return ""; }
+async function pane(): Promise<PaneManager> { return new PaneManager(); }
+export async function drive() {
+  { const value = await text(); value.split(); }
+  { const value = await pane(); value.split(); }
+}` });
+  expect(calls('drive').filter(c => c.target.endsWith('PaneManager::split'))).toEqual([
+    { target: 'caller.ts:PaneManager::split', line: 6 },
+  ]);
+});
+
+it('keeps captured awaited bindings but rejects shadowing parameters (#1840)', async () => {
+  await index({ 'caller.ts': `${engine}
+export async function outer() {
+  const handle = await makeEngine();
+  function captured() { handle.run(); }
+  function shadow(handle: any) { handle.run(); }
+  return { captured, shadow };
+}` });
+  expect(calls('captured').map(c => c.target)).toContain('caller.ts:Engine::run');
+  expect(calls('shadow').map(c => c.target)).not.toContain('caller.ts:Engine::run');
+});
+
+it('invalidates an awaited return type after edits in the callee file (#1840)', async () => {
+  const caller = 'import { load } from "./factory";\nexport async function drive() { const value = await load(); value.split(); }';
+  const primitive = 'export async function load(): Promise<string> { return ""; }';
+  const project = 'export class Pane { split() {} }\nexport async function load(): Promise<Pane> { return new Pane(); }';
+  await index({ 'caller.ts': caller, 'factory.ts': primitive, 'decoy.ts': 'export class Other { split() {} }' });
+  expect(calls('drive').map(c => c.target)).toEqual(['factory.ts:load']);
+  fs.writeFileSync(path.join(root, 'factory.ts'), project);
+  await cg!.sync();
+  expect(calls('drive').map(c => c.target).sort()).toEqual(['factory.ts:Pane::split', 'factory.ts:load']);
+  fs.writeFileSync(path.join(root, 'factory.ts'), primitive);
+  await cg!.sync();
+  expect(calls('drive').map(c => c.target)).toEqual(['factory.ts:load']);
+});
+
+it('reads a multiline factory annotation and preserves ordinary typed receivers (#1840)', async () => {
+  await index({ 'caller.ts': `class Engine { run() {} }
+class Decoy { run() {} }
+async function load(
+  input: string
+): Promise<Engine> { return new Engine(); }
+export async function drive() { const value = await load(''); value.run(); }
+export function ordinary() { const engine = new Engine(); engine.run(); }` });
+  expect(calls('drive').map(c => c.target).sort()).toEqual(['caller.ts:Engine::run', 'caller.ts:load']);
+  expect(calls('ordinary').map(c => c.target)).toContain('caller.ts:Engine::run');
+});
+
+it('supports newline-terminated awaited declarations without treating a chained result as the factory type (#1840)', async () => {
+  await index({ 'caller.ts': `${engine}
+export async function drive() {
+  const value = await makeEngine()
+  value.run()
+}
+export async function chained() {
+  const value = await makeEngine().toString();
+  value.run();
+}` });
+  expect(calls('drive').map(c => c.target)).toContain('caller.ts:Engine::run');
+  expect(calls('chained').map(c => c.target)).not.toContain('caller.ts:Engine::run');
+});
+
+it('does not borrow a local factory annotation through a nearer variable binding (#1840)', async () => {
+  await index({ 'caller.ts': `${engine}
+export async function drive(other: () => Promise<string>) {
+  const makeEngine = other;
+  const value = await makeEngine();
+  value.run();
+}` });
+  expect(calls('drive').map(c => c.target)).not.toContain('caller.ts:Engine::run');
+});
+
+it('preserves a real awaited member-factory call outside the bare-callee inference path (#1840)', async () => {
+  await index({ 'caller.ts': `class Engine { run() {} }
+class Factory { async create(): Promise<Engine> { return new Engine(); } }
+export async function drive() {
+  const handle = await new Factory().create();
+  handle.run();
+}` });
+  expect(calls('drive').map(c => c.target)).toContain('caller.ts:Engine::run');
+});
+
+it('invalidates negative and positive file eligibility when caller edits add and remove await (#1840)', async () => {
+  const plain = 'import { load } from "./factory";\nfunction opaque() { return null; }\nexport async function drive() { const value = opaque(); value.split(); }';
+  const awaited = 'import { load } from "./factory";\nexport async function drive() { const value = await load(); value.split(); }';
+  await index({
+    'caller.ts': plain,
+    'factory.ts': 'export class Pane { split() {} }\nexport class Other { split() {} }\nexport async function load(): Promise<Pane> { return new Pane(); }',
+  });
+  expect(calls('drive').filter(c => c.target.endsWith('Pane::split'))).toEqual([]);
+  fs.writeFileSync(path.join(root, 'caller.ts'), awaited);
+  await cg!.sync();
+  expect(calls('drive').map(c => c.target)).toContain('factory.ts:Pane::split');
+  fs.writeFileSync(path.join(root, 'caller.ts'), plain);
+  await cg!.sync();
+  expect(calls('drive').filter(c => c.target.endsWith('Pane::split'))).toEqual([]);
+});

+ 31 - 0
__tests__/cli-unlock.test.ts

@@ -99,4 +99,35 @@ describe('codegraph unlock — daemon artifact recovery (#1553)', () => {
       await new Promise<void>((resolve) => server.close(() => resolve()));
       await new Promise<void>((resolve) => server.close(() => resolve()));
     }
     }
   });
   });
+
+  it('preserves a live legacy lock whose daemon identity cannot be probed', () => {
+    const pidPath = getDaemonPidPath(tempDir);
+    fs.writeFileSync(pidPath, `${process.pid}\n`);
+
+    const output = runCodegraph(['unlock', tempDir], tempDir);
+
+    expect(output).toContain('No stale lock files found');
+    expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
+    expect(() => process.kill(process.pid, 0)).not.toThrow();
+  });
+
+  it('removes a legacy lock whose PID is dead', () => {
+    const pidPath = getDaemonPidPath(tempDir);
+    fs.writeFileSync(pidPath, '999999\n');
+
+    const output = runCodegraph(['unlock', tempDir], tempDir);
+
+    expect(output).toContain('Removed stale lock artifacts');
+    expect(fs.existsSync(pidPath)).toBe(false);
+  });
+
+  it('removes a malformed daemon lock', () => {
+    const pidPath = getDaemonPidPath(tempDir);
+    fs.writeFileSync(pidPath, 'not-a-lock\n');
+
+    const output = runCodegraph(['unlock', tempDir], tempDir);
+
+    expect(output).toContain('Removed stale lock artifacts');
+    expect(fs.existsSync(pidPath)).toBe(false);
+  });
 });
 });

+ 29 - 0
__tests__/daemon-manager.test.ts

@@ -98,6 +98,35 @@ describe('runDaemonPicker', () => {
     expect(h.getDone()).toBe('Done.');
     expect(h.getDone()).toBe('Done.');
   });
   });
 
 
+  it('does not report an unverified daemon as stopped', async () => {
+    const h = harness([rec('/p/a', 42, 1)], ['/p/a', CANCEL]);
+    h.deps.stop = async (root): Promise<StopResult> => ({
+      root,
+      pid: 42,
+      outcome: 'unverified',
+    });
+
+    await runDaemonPicker(h.deps);
+
+    expect(h.notes).toEqual([
+      'Could not verify daemon (pid 42); left it running with its artifacts intact — /p/a',
+    ]);
+    expect(h.getDone()).toContain('Cancelled');
+  });
+
+  it.each([
+    ['not-running', 42, 'Daemon was no longer running; removed stale artifacts — /p/a'],
+    ['no-daemon', null, 'No daemon was found — /p/a'],
+  ] as const)('reports the %s race outcome accurately', async (outcome, pid, message) => {
+    const h = harness([rec('/p/a', 42, 1)], ['/p/a', CANCEL]);
+    h.deps.stop = async (root): Promise<StopResult> => ({ root, pid, outcome });
+
+    await runDaemonPicker(h.deps);
+
+    expect(h.notes).toEqual([message]);
+    expect(h.getDone()).toContain('Cancelled');
+  });
+
   it('Cancel (and Esc/Ctrl-C) stop nothing', async () => {
   it('Cancel (and Esc/Ctrl-C) stop nothing', async () => {
     const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
     const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
     await runDaemonPicker(h1.deps);
     await runDaemonPicker(h1.deps);

+ 241 - 0
__tests__/daemon-pid-reuse.test.ts

@@ -0,0 +1,241 @@
+// Preserve successful PID-reuse recovery alongside the live-lock guards in #1850.
+/**
+ * Shared MCP daemon — issue #411.
+ *
+ * Validates the daemon architecture in `src/mcp/{daemon,proxy,session,index}.ts`
+ * AFTER the review fixes:
+ *
+ *   - The daemon is a *detached* background process; every `serve --mcp`
+ *     invocation is a thin proxy to it. Two invocations against one project
+ *     share ONE daemon.
+ *   - Concurrent launchers converge on a single daemon (the must-fix-1
+ *     lockfile-race: an empty-pidfile window used to let a racing candidate
+ *     delete the winner's lock → two daemons).
+ *   - Killing the launcher that spawned the daemon does NOT take the daemon
+ *     down — other attached clients keep working (the must-fix-2 detach: the
+ *     in-process daemon used to die with its launcher's process group and
+ *     orphan on host SIGKILL, regressing #277).
+ *   - A stale lockfile (dead pid) is cleared; `CODEGRAPH_NO_DAEMON=1` opts out;
+ *     the proxy refuses to attach across a version mismatch; the daemon
+ *     idle-times-out after the last client leaves (so a single session can't
+ *     leak a daemon forever).
+ *
+ * These tests intentionally spawn real `node dist/bin/codegraph.js` processes
+ * over real sockets/pipes — the same surface a Claude Code / Cursor / Codex
+ * install exercises. The daemon logs to `.codegraph/daemon.log` (it has no
+ * client stderr of its own), so daemon-side assertions read that file.
+ *
+ * `realRoot` vs `tempDir`: processes are spawned with the (possibly symlinked)
+ * `tempDir` as cwd/rootUri — on macOS `os.tmpdir()` lives under `/var`, a
+ * symlink to `/private/var`, and a spawned child's `process.cwd()` is already
+ * realpath'd. The daemon canonicalizes the root with `realpathSync`, so all
+ * path assertions use `realRoot` (the canonical form). That this matches end to
+ * end is itself the proof the canonicalization works.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
+import { CodeGraphPackageVersion } from '../src/mcp/version';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+interface SpawnedServer {
+  child: ChildProcessWithoutNullStreams;
+  stdout: string[];
+  stderr: string[];
+}
+
+function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer {
+  const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], {
+    cwd,
+    stdio: ['pipe', 'pipe', 'pipe'],
+    // #618: the daemon-attach log line is now off by default; opt the test
+    // harness into it (CODEGRAPH_MCP_LOG_ATTACH=1) so the attach assertions
+    // below can still observe a successful attach. A per-test env still wins.
+    env: { CODEGRAPH_MCP_LOG_ATTACH: '1', ...process.env, ...env },
+  }) as ChildProcessWithoutNullStreams;
+  // Swallow spawn/EPIPE errors so killing a child mid-write can't surface as an
+  // unhandled error that crashes the vitest worker.
+  child.on('error', () => { /* ignore */ });
+  child.stdin.on('error', () => { /* ignore */ });
+  const stdout: string[] = [];
+  const stderr: string[] = [];
+  let stdoutBuf = '';
+  let stderrBuf = '';
+  child.stdout.on('data', (chunk: Buffer) => {
+    stdoutBuf += chunk.toString('utf8');
+    let idx: number;
+    while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
+      stdout.push(stdoutBuf.slice(0, idx));
+      stdoutBuf = stdoutBuf.slice(idx + 1);
+    }
+  });
+  child.stderr.on('data', (chunk: Buffer) => {
+    stderrBuf += chunk.toString('utf8');
+    let idx: number;
+    while ((idx = stderrBuf.indexOf('\n')) !== -1) {
+      stderr.push(stderrBuf.slice(0, idx));
+      stderrBuf = stderrBuf.slice(idx + 1);
+    }
+  });
+  return { child, stdout, stderr };
+}
+
+function sendMessage(child: ChildProcessWithoutNullStreams, msg: unknown): void {
+  try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* child may be gone */ }
+}
+
+function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string, id: number): void {
+  sendMessage(child, {
+    jsonrpc: '2.0',
+    id,
+    method: 'initialize',
+    params: {
+      protocolVersion: '2024-11-05',
+      capabilities: {},
+      clientInfo: { name: 'test', version: '0.0.0' },
+      rootUri,
+    },
+  });
+}
+
+/** Find a JSON-RPC response with the given id (result OR error) on stdout. */
+function findResponse(stdout: string[], id: number): any | null {
+  for (const line of stdout) {
+    if (!line.trim()) continue;
+    try {
+      const parsed = JSON.parse(line);
+      if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) {
+        return parsed;
+      }
+    } catch { /* not JSON */ }
+  }
+  return null;
+}
+
+function waitFor<T>(
+  predicate: () => T | undefined | null | false,
+  timeoutMs: number,
+  pollMs = 25,
+  label = '',
+): Promise<T> {
+  return new Promise((resolve, reject) => {
+    const started = Date.now();
+    const tick = () => {
+      let v: T | undefined | null | false;
+      try { v = predicate(); } catch (e) { return reject(e); }
+      if (v) return resolve(v as T);
+      if (Date.now() - started > timeoutMs) {
+        // Name the wait: an async stack loses the await site, so an unlabeled
+        // timeout can't tell WHICH step flaked (the #662 test's recurring
+        // timeout was undiagnosable for exactly this reason).
+        return reject(new Error(`Timed out after ${timeoutMs}ms${label ? ` waiting for: ${label}` : ''}`));
+      }
+      setTimeout(tick, pollMs);
+    };
+    tick();
+  });
+}
+
+function isAlive(pid: number): boolean {
+  try { process.kill(pid, 0); return true; } catch { return false; }
+}
+
+function readLockPid(root: string): number | null {
+  try {
+    const raw = fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8');
+    const info = JSON.parse(raw);
+    return typeof info.pid === 'number' ? info.pid : null;
+  } catch { return null; }
+}
+
+function readDaemonLog(root: string): string {
+  try { return fs.readFileSync(path.join(root, '.codegraph', 'daemon.log'), 'utf8'); }
+  catch { return ''; }
+}
+
+function countListeningLines(root: string): number {
+  return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length;
+}
+
+function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
+  for (const p of procs) {
+    if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } }
+  }
+}
+
+async function waitProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
+  return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false);
+}
+
+describe('Shared MCP daemon (issue #411)', () => {
+  let tempDir: string;   // the (possibly symlinked) path processes are spawned with
+  let realRoot: string;  // its canonical form — what the daemon keys paths on
+  const servers: SpawnedServer[] = [];
+
+  beforeEach(async () => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-daemon-'));
+    const cg = await CodeGraph.init(tempDir);
+    cg.close();
+    realRoot = fs.realpathSync(tempDir);
+  });
+
+  afterEach(async () => {
+    killTree(...servers.map((s) => s.child));
+    // The daemon is detached (not a tracked child) — reap it explicitly via the
+    // pid it recorded, so a test can't leak a background daemon. Guard against
+    // our own pid: the version-mismatch test plants `pid: process.pid` in the
+    // lockfile, and we must never SIGKILL the vitest worker.
+    const daemonPid = readLockPid(realRoot);
+    if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) {
+      try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ }
+    }
+    await new Promise((r) => setTimeout(r, 50));
+    servers.length = 0;
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('takes over after SIGKILL even when the stale PID has been reused (#1553)', async () => {
+    const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000' };
+    const first = spawnServer(tempDir, env);
+    servers.push(first);
+    sendInitialize(first.child, `file://${tempDir}`, 1);
+    await waitFor(() => findResponse(first.stdout, 1), 10000);
+    await waitFor(() => countListeningLines(realRoot) >= 1, 10000);
+    const killedPid = readLockPid(realRoot)!;
+
+    process.kill(killedPid, 'SIGKILL');
+    expect(await waitProcessExit(killedPid, 8000)).toBe(true);
+
+    // Model OS PID reuse without risking another process: the stale lock now
+    // names this live vitest worker, but no daemon answers the leftover socket.
+    fs.writeFileSync(
+      path.join(realRoot, '.codegraph', 'daemon.pid'),
+      JSON.stringify({
+        pid: process.pid,
+        version: CodeGraphPackageVersion,
+        socketPath: getDaemonSocketPath(realRoot),
+        startedAt: Date.now() - 60_000,
+      }),
+    );
+
+    const second = spawnServer(tempDir, env);
+    servers.push(second);
+    sendInitialize(second.child, `file://${tempDir}`, 2);
+    const response = await waitFor(() => findResponse(second.stdout, 2), 12000);
+    expect(response.result.serverInfo.name).toBe('codegraph');
+    await waitFor(() => countListeningLines(realRoot) >= 2, 10000);
+
+    const replacementPid = readLockPid(realRoot)!;
+    expect(replacementPid).not.toBe(killedPid);
+    expect(replacementPid).not.toBe(process.pid);
+    expect(isAlive(replacementPid)).toBe(true);
+    expect(isAlive(process.pid)).toBe(true);
+  }, 50000);
+
+});

+ 85 - 0
__tests__/daemon-registry.test.ts

@@ -11,10 +11,12 @@ import {
   deregisterDaemon,
   deregisterDaemon,
   listDaemons,
   listDaemons,
   listVerifiedDaemons,
   listVerifiedDaemons,
+  clearStaleDaemonArtifacts,
   stopDaemonAt,
   stopDaemonAt,
   type DaemonRecord,
   type DaemonRecord,
 } from '../src/mcp/daemon-registry';
 } from '../src/mcp/daemon-registry';
 import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';
 import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';
+import { releaseWriterLock, tryAcquireWriterLock } from '../src/mcp/writer-lock';
 
 
 /** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
 /** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
 async function deadPid(): Promise<number> {
 async function deadPid(): Promise<number> {
@@ -155,4 +157,87 @@ describe('daemon-registry', () => {
     expect(isProcessAlive(process.pid)).toBe(true);
     expect(isProcessAlive(process.pid)).toBe(true);
     expect(fs.existsSync(pidPath)).toBe(false);
     expect(fs.existsSync(pidPath)).toBe(false);
   });
   });
+
+  it('preserves a live legacy lock when stop cannot verify daemon identity', async () => {
+    const root = fs.mkdtempSync(path.join(tmpHome, 'legacy-stop-'));
+    const pidPath = getDaemonPidPath(root);
+    fs.mkdirSync(path.dirname(pidPath), { recursive: true });
+    fs.writeFileSync(pidPath, `${process.pid}\n`);
+
+    const result = await stopDaemonAt(root);
+
+    expect(result).toMatchObject({ pid: process.pid, outcome: 'unverified' });
+    expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
+    expect(isProcessAlive(process.pid)).toBe(true);
+  });
+
+  it('preserves a replacement lock written while stale identity is probed', async () => {
+    const root = fs.mkdtempSync(path.join(tmpHome, 'probe-race-'));
+    const pidPath = getDaemonPidPath(root);
+    const socketPath = process.platform === 'win32'
+      ? `\\\\.\\pipe\\cg-race-old-${process.pid}-${Date.now()}`
+      : path.join(tmpHome, 'probe-race-old.sock');
+    const replacementSocketPath = process.platform === 'win32'
+      ? `\\\\.\\pipe\\cg-race-new-${process.pid}-${Date.now()}`
+      : path.join(tmpHome, 'probe-race-new.sock');
+    let acceptConnection!: () => void;
+    const connected = new Promise<void>((resolve) => { acceptConnection = resolve; });
+    let acceptedSocket: net.Socket | null = null;
+    const server = net.createServer((socket) => {
+      acceptedSocket = socket;
+      acceptConnection();
+    });
+    await new Promise<void>((resolve, reject) => {
+      server.once('error', reject);
+      server.listen(socketPath, resolve);
+    });
+    const original = encodeLockInfo({
+      pid: process.pid,
+      version: '1.5.0',
+      socketPath,
+      startedAt: 1,
+    });
+    const replacement = encodeLockInfo({
+      pid: process.pid,
+      version: '1.5.0',
+      socketPath: replacementSocketPath,
+      startedAt: 2,
+    });
+    fs.mkdirSync(path.dirname(pidPath), { recursive: true });
+    fs.writeFileSync(pidPath, original);
+
+    try {
+      const clearing = clearStaleDaemonArtifacts(root);
+      await connected;
+      fs.writeFileSync(pidPath, replacement);
+      acceptedSocket!.end('{"protocol":0}\n');
+
+      expect(await clearing).toBe(false);
+      expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
+    } finally {
+      acceptedSocket?.destroy();
+      await new Promise<void>((resolve) => server.close(() => resolve()));
+    }
+  });
+
+  it('does not clean daemon artifacts while another writer owns the project', async () => {
+    const root = fs.mkdtempSync(path.join(tmpHome, 'writer-claim-'));
+    const pidPath = getDaemonPidPath(root);
+    const lock = encodeLockInfo({
+      pid: process.pid,
+      version: '1.5.0',
+      socketPath: path.join(root, '.codegraph', 'not-listening.sock'),
+      startedAt: 1,
+    });
+    fs.mkdirSync(path.dirname(pidPath), { recursive: true });
+    fs.writeFileSync(pidPath, lock);
+    expect(tryAcquireWriterLock(root, 'daemon').kind).toBe('acquired');
+
+    try {
+      expect(await clearStaleDaemonArtifacts(root)).toBe(false);
+      expect(fs.readFileSync(pidPath, 'utf8')).toBe(lock);
+    } finally {
+      releaseWriterLock(root);
+    }
+  });
 });
 });

+ 42 - 0
__tests__/daemon-socket-fallback.test.ts

@@ -43,6 +43,7 @@ import { decodeLockInfo } from '../src/mcp/daemon-paths';
 import {
 import {
   acquireLockViaExclusiveOpen,
   acquireLockViaExclusiveOpen,
   bindFirstUsableSocket,
   bindFirstUsableSocket,
+  clearStaleDaemonLock,
   tryAcquireDaemonLock,
   tryAcquireDaemonLock,
 } from '../src/mcp/daemon';
 } from '../src/mcp/daemon';
 
 
@@ -244,3 +245,44 @@ describe('lock acquisition without hard links (#997)', () => {
     expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(winner);
     expect(decodeLockInfo(fs.readFileSync(pidPath, 'utf8'))).toEqual(winner);
   });
   });
 });
 });
+
+describe('legacy daemon lock decoding', () => {
+  it('decodes a plain decimal PID as a legacy lock record', () => {
+    expect(decodeLockInfo('4242\n')).toEqual({
+      pid: 4242,
+      version: 'unknown',
+      socketPath: '',
+      startedAt: 0,
+    });
+  });
+
+  it.each(['1e3', '0x3e8', '1000.0'])('rejects non-decimal PID syntax %s', (raw) => {
+    expect(decodeLockInfo(raw)).toBeNull();
+  });
+});
+
+describe('stale daemon lock snapshot validation', () => {
+  it('does not delete a same-PID replacement whose identity was never probed', () => {
+    const pidPath = path.join(os.tmpdir(), `cg-snapshot-${process.pid}-${Date.now()}.pid`);
+    tmpFiles.push(pidPath);
+    const original = JSON.stringify({
+      pid: process.pid,
+      version: '1.5.0',
+      socketPath: '/old.sock',
+      startedAt: 1,
+    });
+    const replacement = JSON.stringify({
+      pid: process.pid,
+      version: '1.5.0',
+      socketPath: '/new.sock',
+      startedAt: 2,
+    });
+    fs.writeFileSync(pidPath, replacement);
+
+    expect(clearStaleDaemonLock(pidPath, process.pid, {
+      allowLivePid: true,
+      expectedLockContents: original,
+    })).toBe(false);
+    expect(fs.readFileSync(pidPath, 'utf8')).toBe(replacement);
+  });
+});

+ 15 - 8
__tests__/extraction.test.ts

@@ -1481,7 +1481,7 @@ impl From<u32> for Own {
     ).toBe(true);
     ).toBe(true);
   });
   });
 
 
-  it('keeps the owner-field shape for `self.<field>.<method>()` and collapses every other receiver (#1585)', () => {
+  it('keeps the owner shape for `self.<method>()` and `self.<field>.<method>()`, and collapses every other receiver (#1585, #1861)', () => {
     const code = `
     const code = `
 pub struct Outer { pub inner: Inner, pub deep: Deep }
 pub struct Outer { pub inner: Inner, pub deep: Deep }
 impl Outer {
 impl Outer {
@@ -1500,15 +1500,22 @@ impl Outer {
     const calls = result.unresolvedReferences
     const calls = result.unresolvedReferences
       .filter((r) => r.referenceKind === 'calls')
       .filter((r) => r.referenceKind === 'calls')
       .map((r) => r.referenceName);
       .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']);
+    // Two shapes keep an owner the resolver can act on: the single-hop field
+    // receiver, whose type it reads off the owner struct (#1585), and the bare
+    // `self` receiver, whose type is the calling method's own owner (#1861).
+    // `self.make().run()` contributes `self.make` — the inner call — and its
+    // OUTER call collapses, because a method's return type is not read here.
+    expect(calls.filter((c) => c.startsWith('self.')).sort()).toEqual([
+      'self.inner.run',
+      'self.make',
+      'self.run',
+    ]);
     // A local receiver keeps its name as before…
     // A local receiver keeps its name as before…
     expect(calls).toContain('local.run');
     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');
+    // …and the deeper chain, the call receiver and the parenthesized receiver
+    // still collapse to the method name. `self.run()` no longer does, so this
+    // is three rather than four.
+    expect(calls.filter((c) => c === 'run')).toHaveLength(3);
     const outerRun = result.nodes.find((n) => n.qualifiedName === 'Outer::run');
     const outerRun = result.nodes.find((n) => n.qualifiedName === 'Outer::run');
     expect(outerRun).toBeDefined();
     expect(outerRun).toBeDefined();
     const fieldRef = result.unresolvedReferences.find((r) => r.referenceName === 'self.inner.run');
     const fieldRef = result.unresolvedReferences.find((r) => r.referenceName === 'self.inner.run');

+ 148 - 0
__tests__/git-index-currency.test.ts

@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as cp from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { CodeGraph } from '../src';
+
+// Wrap only I/O entry points for deterministic failure injection; all other
+// calls, files, parser work and SQLite remain real.
+vi.mock('child_process', async importOriginal => {
+  const actual = await importOriginal<typeof import('child_process')>();
+  return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
+});
+vi.mock('fs', async importOriginal => {
+  const actual = await importOriginal<typeof import('fs')>();
+  return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
+});
+
+describe('git index currency across commits and restores (#1829)', () => {
+  let root: string;
+  let cg: CodeGraph;
+  const git = (...args: string[]) => cp.execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
+  const write = (name: string, symbol: string) => fs.writeFileSync(path.join(root, name), `export function ${symbol}() { return 1; }\n`);
+  const commit = () => { git('add', '-A'); git('commit', '-m', 'change'); return git('rev-parse', 'HEAD').trim(); };
+  const metadata = () => (cg as any).queries;
+  const symbols = (name: string) => cg.searchNodes(name).map(r => r.node.name);
+  const clean = () => expect(cg.getChangedFiles()).toEqual({ added: [], modified: [], removed: [] });
+
+  beforeEach(async () => {
+    root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-git-currency-'));
+    git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Test');
+    fs.writeFileSync(path.join(root, '.gitignore'), '.codegraph/\n');
+    write('source.ts', 'original'); commit();
+    cg = CodeGraph.initSync(root);
+    expect((await cg.indexAll()).success).toBe(true);
+  });
+  afterEach(() => { vi.restoreAllMocks(); cg?.close(); fs.rmSync(root, { recursive: true, force: true }); });
+
+  it.each(['index', 'sync', 'scoped'] as const)('sees a restored indexed dirty edit after %s', async mode => {
+    write('source.ts', 'dirtyVersion');
+    if (mode === 'index') await cg.indexAll();
+    else await cg.sync(mode === 'scoped' ? { paths: ['source.ts'] } : {});
+    expect(symbols('dirtyVersion')).toContain('dirtyVersion');
+    git('restore', 'source.ts');
+    expect(git('status', '--porcelain')).toBe('');
+    // Reopen proves that dirty candidates survive beyond one engine instance.
+    cg.close(); cg = CodeGraph.openSync(root);
+    expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
+    await cg.sync();
+    expect(symbols('original')).toContain('original');
+    expect(symbols('dirtyVersion')).not.toContain('dirtyVersion'); clean();
+  });
+
+  it.each(['index', 'sync'] as const)('does not claim a commit made during %s maintenance', async mode => {
+    const oldHead = git('rev-parse', 'HEAD').trim();
+    write('source.ts', 'firstEdit');
+    const db = (cg as any).db;
+    const maintain = db.runMaintenance.bind(db);
+    const spy = vi.spyOn(db, 'runMaintenance').mockImplementationOnce(async () => {
+      write('source.ts', 'lateCommit'); commit();
+      return maintain();
+    });
+    if (mode === 'index') await cg.indexAll(); else await cg.sync();
+    expect(spy).toHaveBeenCalledOnce();
+    expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
+    expect(cg.getChangedFiles().modified).toEqual(['source.ts']);
+    await cg.sync(); expect(symbols('lateCommit')).toContain('lateCommit'); clean();
+  });
+
+  it('handles non-ASCII and quoted committed paths without git text escaping', async () => {
+    const names = ['тест.ts', 'space name.ts'];
+    if (process.platform !== 'win32') names.push('quote"name.ts');
+    for (const file of names) write(file, 'newSymbol');
+    commit();
+    expect(cg.getChangedFiles().added.sort()).toEqual(names.sort());
+    await cg.sync(); clean();
+    fs.renameSync(path.join(root, 'тест.ts'), path.join(root, 'renamed.ts')); commit();
+    expect(cg.getChangedFiles()).toEqual({ added: ['renamed.ts'], modified: [], removed: ['тест.ts'] });
+  });
+
+  it('falls back when git diff fails instead of claiming a clean index', () => {
+    write('new.ts', 'newSymbol'); commit();
+    const real = cp.execFileSync;
+    let injected = 0;
+    vi.spyOn(cp, 'execFileSync').mockImplementation(((file: string, args: string[], options: any) => {
+      if (file === 'git' && args[0] === 'diff') { injected++; throw new Error('Injected git diff timeout'); }
+      return real(file, args, options);
+    }) as typeof cp.execFileSync);
+    expect(cg.getChangedFiles().added).toEqual(['new.ts']);
+    expect(injected).toBeGreaterThan(0);
+  });
+
+  it('does not advance the commit stamp after a scoped sync', async () => {
+    const oldHead = metadata().getMetadata('indexed_at_commit');
+    write('one.ts', 'one'); write('two.ts', 'two'); commit();
+    await cg.sync({ paths: ['one.ts'] });
+    expect(metadata().getMetadata('indexed_at_commit')).toBe(oldHead);
+    expect(cg.getChangedFiles().added).toEqual(['two.ts']);
+    await cg.sync(); clean();
+  });
+
+  it('retains a restored dirty path when a later scoped sync touches another file', async () => {
+    write('source.ts', 'dirtyVersion'); await cg.sync();
+    git('restore', 'source.ts'); write('other.ts', 'other');
+    await cg.sync({ paths: ['other.ts'] });
+    expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
+    await cg.sync(); expect(symbols('original')).toContain('original'); clean();
+  });
+
+  it('does not call a recreated committed deletion removed when the current bytes match the DB', async () => {
+    fs.unlinkSync(path.join(root, 'source.ts')); commit();
+    write('source.ts', 'original');
+    clean();
+    write('source.ts', 'replacement');
+    expect(cg.getChangedFiles()).toEqual({ added: [], modified: ['source.ts'], removed: [] });
+    await cg.sync(); expect(symbols('replacement')).toContain('replacement'); clean();
+  });
+
+  it('retains deleted untracked files as candidates after they were indexed', async () => {
+    write('untracked.ts', 'temporary'); await cg.sync();
+    fs.unlinkSync(path.join(root, 'untracked.ts'));
+    expect(cg.getChangedFiles().removed).toEqual(['untracked.ts']);
+    await cg.sync(); expect(symbols('temporary')).not.toContain('temporary'); clean();
+  });
+
+  it('never advances freshness after a failed full index', async () => {
+    write('new.ts', 'newSymbol'); commit();
+    const controller = new AbortController(); controller.abort();
+    expect((await cg.indexAll({ signal: controller.signal })).success).toBe(false);
+    expect(cg.getChangedFiles().added).toEqual(['new.ts']);
+  });
+
+  it('keeps a committed path pending when sync cannot read it', async () => {
+    write('new.ts', 'newSymbol'); commit();
+    const real = fs.readFileSync;
+    let injected = 0;
+    vi.spyOn(fs, 'readFileSync').mockImplementation(((file: any, ...args: any[]) => {
+      if (String(file) === path.join(root, 'new.ts')) { injected++; throw new Error('Injected transient read error'); }
+      return (real as any)(file, ...args);
+    }) as typeof fs.readFileSync);
+    await cg.sync();
+    expect(injected).toBeGreaterThan(0);
+    expect(symbols('newSymbol')).not.toContain('newSymbol');
+    vi.restoreAllMocks();
+    expect(cg.getChangedFiles().added).toEqual(['new.ts']);
+    await cg.sync(); clean();
+  });
+});

+ 4 - 2
__tests__/installer-targets.test.ts

@@ -2679,8 +2679,10 @@ describe('Installer targets — Claude CLAUDE_CONFIG_DIR override (#1627)', () =
   let homeRestore: { restore: () => void };
   let homeRestore: { restore: () => void };
 
 
   beforeEach(() => {
   beforeEach(() => {
-    tmpHome = mkTmpDir('home');
-    tmpCwd = mkTmpDir('cwd');
+    // chdir resolves symlinks (macOS /var -> /private/var). Build the
+    // expected paths from the same canonical roots without relaxing equality.
+    tmpHome = fs.realpathSync(mkTmpDir('home'));
+    tmpCwd = fs.realpathSync(mkTmpDir('cwd'));
     origCwd = process.cwd();
     origCwd = process.cwd();
     process.chdir(tmpCwd);
     process.chdir(tmpCwd);
     homeRestore = setHome(tmpHome);
     homeRestore = setHome(tmpHome);

+ 119 - 16
__tests__/mcp-daemon.test.ts

@@ -337,7 +337,7 @@ describe('Shared MCP daemon (issue #411)', () => {
     expect(isAlive(livePid!)).toBe(true);
     expect(isAlive(livePid!)).toBe(true);
   }, 40000);
   }, 40000);
 
 
-  it('takes over after SIGKILL even when the stale PID has been reused (#1553)', async () => {
+  it('preserves paired daemon/writer locks when their live PID may have been reused', async () => {
     const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000' };
     const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000' };
     const first = spawnServer(tempDir, env);
     const first = spawnServer(tempDir, env);
     servers.push(first);
     servers.push(first);
@@ -351,30 +351,116 @@ describe('Shared MCP daemon (issue #411)', () => {
 
 
     // Model OS PID reuse without risking another process: the stale lock now
     // Model OS PID reuse without risking another process: the stale lock now
     // names this live vitest worker, but no daemon answers the leftover socket.
     // names this live vitest worker, but no daemon answers the leftover socket.
-    fs.writeFileSync(
-      path.join(realRoot, '.codegraph', 'daemon.pid'),
-      JSON.stringify({
-        pid: process.pid,
-        version: CodeGraphPackageVersion,
-        socketPath: getDaemonSocketPath(realRoot),
-        startedAt: Date.now() - 60_000,
-      }),
-    );
+    const daemonPath = path.join(realRoot, '.codegraph', 'daemon.pid');
+    const writerPath = path.join(realRoot, '.codegraph', 'writer.pid');
+    const staleDaemonLock = JSON.stringify({
+      pid: process.pid,
+      version: CodeGraphPackageVersion,
+      socketPath: getDaemonSocketPath(realRoot),
+      startedAt: Date.now() - 60_000,
+    });
+    const staleWriterLock = JSON.stringify({
+      pid: process.pid,
+      mode: 'daemon',
+      startedAt: Date.now() - 60_000,
+    }) + '\n';
+    fs.writeFileSync(daemonPath, staleDaemonLock);
+    fs.writeFileSync(writerPath, staleWriterLock);
 
 
     const second = spawnServer(tempDir, env);
     const second = spawnServer(tempDir, env);
     servers.push(second);
     servers.push(second);
     sendInitialize(second.child, `file://${tempDir}`, 2);
     sendInitialize(second.child, `file://${tempDir}`, 2);
     const response = await waitFor(() => findResponse(second.stdout, 2), 12000);
     const response = await waitFor(() => findResponse(second.stdout, 2), 12000);
     expect(response.result.serverInfo.name).toBe('codegraph');
     expect(response.result.serverInfo.name).toBe('codegraph');
-    await waitFor(() => countListeningLines(realRoot) >= 2, 10000);
+    await waitFor(
+      () => second.stderr.some((line) =>
+        line.includes('Attached to shared daemon') || line.includes('Shared daemon unavailable')
+      ),
+      12000,
+      25,
+      'the proxy to attach or fall back',
+    );
 
 
-    const replacementPid = readLockPid(realRoot)!;
-    expect(replacementPid).not.toBe(killedPid);
-    expect(replacementPid).not.toBe(process.pid);
-    expect(isAlive(replacementPid)).toBe(true);
+    expect(second.stderr.some((line) => line.includes('Attached to shared daemon'))).toBe(false);
+    expect(countListeningLines(realRoot)).toBe(1);
+    expect(fs.readFileSync(daemonPath, 'utf8')).toBe(staleDaemonLock);
+    expect(fs.readFileSync(writerPath, 'utf8')).toBe(staleWriterLock);
     expect(isAlive(process.pid)).toBe(true);
     expect(isAlive(process.pid)).toBe(true);
+
+    sendMessage(second.child, {
+      jsonrpc: '2.0',
+      id: 3,
+      method: 'tools/call',
+      params: { name: 'codegraph_status', arguments: {} },
+    });
+    const toolResponse = await waitFor(() => findResponse(second.stdout, 3), 5000);
+    expect(toolResponse).toMatchObject({
+      error: { message: expect.stringContaining('writer lock held') },
+    });
   }, 50000);
   }, 50000);
 
 
+  it('does not replace a live legacy lock with a second daemon', async () => {
+    const pidPath = path.join(realRoot, '.codegraph', 'daemon.pid');
+    fs.writeFileSync(pidPath, `${process.pid}\n`);
+
+    const server = spawnServer(tempDir, { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' });
+    servers.push(server);
+    sendInitialize(server.child, `file://${tempDir}`, 1);
+    const response = await waitFor(() => findResponse(server.stdout, 1), 12000);
+    expect(response.result.serverInfo.name).toBe('codegraph');
+
+    await waitFor(
+      () => server.stderr.some((line) =>
+        line.includes('Attached to shared daemon') || line.includes('Shared daemon unavailable')
+      ),
+      12000,
+      25,
+      'the proxy to attach or fall back',
+    );
+
+    expect(server.stderr.some((line) => line.includes('Attached to shared daemon'))).toBe(false);
+    expect(fs.readFileSync(pidPath, 'utf8')).toBe(`${process.pid}\n`);
+    expect(countListeningLines(realRoot)).toBe(0);
+    expect(isAlive(process.pid)).toBe(true);
+
+    sendMessage(server.child, {
+      jsonrpc: '2.0',
+      id: 2,
+      method: 'tools/call',
+      params: { name: 'codegraph_status', arguments: {} },
+    });
+    const toolResponse = await waitFor(() => findResponse(server.stdout, 2), 5000);
+    expect(toolResponse).toMatchObject({
+      error: { message: expect.stringContaining('live legacy daemon') },
+    });
+  }, 30000);
+
+  it('does not start a fallback writer when the daemon lock is unreadable', async () => {
+    const pidPath = path.join(realRoot, '.codegraph', 'daemon.pid');
+    fs.mkdirSync(pidPath);
+
+    const server = spawnServer(tempDir);
+    servers.push(server);
+    sendInitialize(server.child, `file://${tempDir}`, 1);
+    await waitFor(
+      () => server.stderr.some((line) => line.includes('Shared daemon unavailable')),
+      12000,
+      25,
+      'the proxy to fall back',
+    );
+
+    sendMessage(server.child, {
+      jsonrpc: '2.0',
+      id: 2,
+      method: 'tools/call',
+      params: { name: 'codegraph_status', arguments: {} },
+    });
+    const toolResponse = await waitFor(() => findResponse(server.stdout, 2), 5000);
+    expect(toolResponse).toMatchObject({
+      error: { message: expect.stringContaining('daemon lock could not be read') },
+    });
+  }, 30000);
+
   it('proxy falls back to direct mode on a daemon version mismatch', async () => {
   it('proxy falls back to direct mode on a daemon version mismatch', async () => {
     const net = await import('net');
     const net = await import('net');
     const sockPath = getDaemonSocketPath(realRoot);
     const sockPath = getDaemonSocketPath(realRoot);
@@ -385,7 +471,12 @@ describe('Shared MCP daemon (issue #411)', () => {
       JSON.stringify({ pid: process.pid, version: '0.0.0-mismatch', socketPath: sockPath, startedAt: Date.now() }),
       JSON.stringify({ pid: process.pid, version: '0.0.0-mismatch', socketPath: sockPath, startedAt: Date.now() }),
     );
     );
     const miniServer = net.createServer((sock) => {
     const miniServer = net.createServer((sock) => {
-      sock.write(JSON.stringify({ codegraph: '0.0.0-mismatch', pid: 1, socketPath: sockPath, protocol: 1 }) + '\n');
+      sock.write(JSON.stringify({
+        codegraph: '0.0.0-mismatch',
+        pid: process.pid,
+        socketPath: sockPath,
+        protocol: 1,
+      }) + '\n');
     });
     });
     await new Promise<void>((resolve) => miniServer.listen(sockPath, () => resolve()));
     await new Promise<void>((resolve) => miniServer.listen(sockPath, () => resolve()));
 
 
@@ -402,6 +493,18 @@ describe('Shared MCP daemon (issue #411)', () => {
         () => server.stderr.some((l) => l.includes('serving this session in-process')),
         () => server.stderr.some((l) => l.includes('serving this session in-process')),
         6000,
         6000,
       );
       );
+
+      sendMessage(server.child, {
+        jsonrpc: '2.0',
+        id: 2,
+        method: 'tools/call',
+        params: { name: 'codegraph_status', arguments: {} },
+      });
+      const toolResponse = await waitFor(() => findResponse(server.stdout, 2), 5000);
+      expect(toolResponse).toMatchObject({
+        error: { message: expect.stringContaining('live daemon') },
+      });
+      expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false);
     } finally {
     } finally {
       await new Promise<void>((resolve) => miniServer.close(() => resolve()));
       await new Promise<void>((resolve) => miniServer.close(() => resolve()));
     }
     }

+ 8 - 4
__tests__/mcp-ppid-watchdog.test.ts

@@ -55,6 +55,7 @@ describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () =>
   let wrapper: ChildProcessWithoutNullStreams | null = null;
   let wrapper: ChildProcessWithoutNullStreams | null = null;
   let childPid: number | null = null;
   let childPid: number | null = null;
   let stdinHolderPid: number | null = null;
   let stdinHolderPid: number | null = null;
+  let tmpDir: string | null = null;
 
 
   afterEach(() => {
   afterEach(() => {
     if (wrapper && !wrapper.killed) {
     if (wrapper && !wrapper.killed) {
@@ -69,6 +70,8 @@ describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () =>
     wrapper = null;
     wrapper = null;
     childPid = null;
     childPid = null;
     stdinHolderPid = null;
     stdinHolderPid = null;
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = null;
   });
   });
 
 
   it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => {
   it("shuts down when its parent is SIGKILL'd and stdin stays open", async () => {
@@ -83,10 +86,8 @@ describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () =>
     //
     //
     // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the
     // CODEGRAPH_PPID_POLL_MS=200 keeps the watchdog responsive in test; the
     // production default is 5000ms.
     // production default is 5000ms.
-    const stderrLog = path.join(
-      fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-')),
-      'codegraph.stderr.log',
-    );
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ppid-watchdog-'));
+    const stderrLog = path.join(tmpDir, 'codegraph.stderr.log');
     // The wrapper waits 800ms before reporting the PIDs so the codegraph
     // The wrapper waits 800ms before reporting the PIDs so the codegraph
     // child has time to finish its async start() (dynamic import + transport
     // child has time to finish its async start() (dynamic import + transport
     // setup + watchdog registration). Otherwise the test races: it
     // setup + watchdog registration). Otherwise the test races: it
@@ -119,6 +120,9 @@ describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () =>
     `;
     `;
     wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
     wrapper = spawn(process.execPath, ['-e', wrapperSrc], {
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
+      // All descendants inherit an isolated project. An editor's live writer
+      // lock in the repository must not terminate the child before the watchdog.
+      cwd: tmpDir,
     }) as ChildProcessWithoutNullStreams;
     }) as ChildProcessWithoutNullStreams;
 
 
     const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => {
     const pids = await new Promise<{ pid: number; stdinHolderPid: number }>((resolve, reject) => {

+ 11 - 0
__tests__/release-main-regressions.test.ts

@@ -61,6 +61,11 @@ beforeAll(async () => {
   export function unknownFactory(db: any) { db.prepare().reset(); }
   export function unknownFactory(db: any) { db.prepare().reset(); }
   `);
   `);
   write('not-a-store.ts', `export const fake = otherFactory(() => ({ reset() { return 1; } }));`);
   write('not-a-store.ts', `export const fake = otherFactory(() => ({ reset() { return 1; } }));`);
+  write('barrel.ts', `export { useStore as routedStore } from './store';`);
+  write('barrel-consumer.ts', `import { routedStore as current } from './barrel';
+  export function barrelReset() { current.getState().reset(); }
+  export function barrelSelected() { const selected = current(s => s.reset); selected(); }
+  `);
   write('selectors.ts', `import { useStore as current, anotherStore } from './store';
   write('selectors.ts', `import { useStore as current, anotherStore } from './store';
   import { fake } from './not-a-store';
   import { fake } from './not-a-store';
   export function rootShadow(current: any) { const selected = current(s => s.reset); selected(); }
   export function rootShadow(current: any) { const selected = current(s => s.reset); selected(); }
@@ -144,6 +149,12 @@ describe('release-to-main correctness regressions', () => {
     expect(targets(node('Screen::captured').id)).toEqual([node('reset', 'store.ts', 5).id]);
     expect(targets(node('Screen::captured').id)).toEqual([node('reset', 'store.ts', 5).id]);
     expect(targets(node('Screen::otherCaptured').id)).toEqual([node('reset', 'store.ts', 8).id]);
     expect(targets(node('Screen::otherCaptured').id)).toEqual([node('reset', 'store.ts', 8).id]);
   });
   });
+  it.each(['barrelReset', 'barrelSelected'])('resolves %s through both a re-export and local import alias', (name) => {
+    const store = cg.getNodesByKind('constant').find(n => n.name === 'useStore' && n.filePath === 'store.ts')!;
+    // The imported store itself is also referenced by the accessor/hook call.
+    // Pin the whole target set so the other store's same-named reset cannot leak in.
+    expect(targets(node(name, 'barrel-consumer.ts').id).sort()).toEqual([node('reset', 'store.ts', 5).id, store.id].sort());
+  });
   it.each(['Screen::parameterShadow', 'Screen::arrowShadow', 'Screen::localShadow', 'outside', 'wrongSelector', 'unknownSelector', 'rootShadow', 'rootBlockShadow'])('does not guess a selector action in %s', (name) => {
   it.each(['Screen::parameterShadow', 'Screen::arrowShadow', 'Screen::localShadow', 'outside', 'wrongSelector', 'unknownSelector', 'rootShadow', 'rootBlockShadow'])('does not guess a selector action in %s', (name) => {
     const calls = targets(node(name, 'selectors.ts').id);
     const calls = targets(node(name, 'selectors.ts').id);
     expect(calls).not.toContain(node('reset', 'store.ts', 5).id);
     expect(calls).not.toContain(node('reset', 'store.ts', 5).id);

+ 125 - 0
__tests__/resolution.test.ts

@@ -1258,6 +1258,78 @@ impl<T> Source for BufSource<T> {
       expect(callsFrom('Countdown::run').map((c) => c.target)).toEqual(['Countdown::run']);
       expect(callsFrom('Countdown::run').map((c) => c.target)).toEqual(['Countdown::run']);
     });
     });
 
 
+    // ── Rust `self.<method>()` receivers (#1861) ──────────────────────────
+    it('resolves `self.method()` on the enclosing type, not on whichever same-named method sits nearer (#1861)', async () => {
+      // The issue's repro, one file: `Decoy::reset` sits between the call and
+      // the method it means, so a bare name ranked by file proximity picked
+      // the decoy — and the edge carried no provenance to say it was a guess.
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub struct Target { pub n: i32 }\n\nimpl Target {\n    pub fn reset(&mut self) { self.n = -1; }\n}\n\n' +
+          'pub struct Decoy { pub n: i32 }\n\nimpl Decoy {\n    pub fn reset(&mut self) { self.n = 0; }\n}\n\n' +
+          'impl Target {\n    pub fn run(&mut self) { self.reset(); }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      expect(callsFrom('Target::run')).toEqual([
+        { target: 'Target::reset', resolvedBy: 'qualified-name', provenance: undefined },
+      ]);
+    });
+
+    it('decides the same way across directories, where proximity decided before (#1861)', async () => {
+      // Same code, only the layout changes. If the answer moved with the file
+      // tree, proximity was still deciding it.
+      writeRustCrate(tempDir, {
+        'lib.rs': 'pub mod near;\npub mod far;\n',
+        'near.rs': 'pub struct Decoy { pub n: i32 }\nimpl Decoy {\n    pub fn reset(&mut self) { self.n = 0; }\n}\n',
+        'far.rs':
+          'pub struct Target { pub n: i32 }\nimpl Target {\n    pub fn reset(&mut self) { self.n = -1; }\n}\n' +
+          'impl Target {\n    pub fn run(&mut self) { self.reset(); }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      expect(callsFrom('Target::run').map((c) => c.target)).toEqual(['Target::reset']);
+    });
+
+    it('declines when the enclosing type has no such method, and does not change a receiver-less call (#1861)', async () => {
+      // The two ways this could overreach. `self.missing()` names nothing on
+      // the owner, so it must not fall back to some other type's `missing`.
+      //
+      // The receiver-less half is pinned as it BEHAVES, not as it should: a
+      // bare `reset()` is a free-function call, and it already resolved to
+      // `Target::reset` before this change — the mirror image of #1861, where
+      // a call with no receiver is given one. That is a separate defect in the
+      // bare-name strategy, measured on this branch's parent; the cell is here
+      // so this change is pinned to not make it worse.
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub fn reset() {}\n\n' +
+          'pub struct Other { pub n: i32 }\nimpl Other {\n    pub fn missing(&mut self) {}\n}\n\n' +
+          'pub struct Target { pub n: i32 }\nimpl Target {\n    pub fn reset(&mut self) { self.n = -1; }\n' +
+          '    pub fn free(&mut self) { reset(); }\n' +
+          '    pub fn absent(&mut self) { self.missing(); }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      // Unchanged by this commit — see the note above.
+      expect(callsFrom('Target::free').map((c) => c.target)).toEqual(['Target::reset']);
+      // Nothing on the owner is named `missing`, so no edge at all.
+      expect(callsFrom('Target::absent')).toEqual([]);
+    });
+
+    it('resolves `self.method()` inside a trait impl to that impl (#1861)', async () => {
+      writeRustCrate(tempDir, {
+        'lib.rs':
+          'pub trait Run {\n    fn go(&mut self);\n}\n\n' +
+          'pub struct Decoy { pub n: i32 }\nimpl Decoy {\n    pub fn step(&mut self) { self.n = 0; }\n}\n\n' +
+          'pub struct Doer { pub n: i32 }\nimpl Doer {\n    pub fn step(&mut self) { self.n = 1; }\n}\n' +
+          'impl Run for Doer {\n    fn go(&mut self) { self.step(); }\n}\n',
+      });
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      expect(callsFrom('Doer::go').map((c) => c.target)).toEqual(['Doer::step']);
+    });
+
     it('resolves a trait-object field to the trait method and typed fields to the right implementation (#1585, #1588)', async () => {
     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
       // The #1588 repro's second half: `UsesFile::go` / `UsesBuf::go` each
       // forward through a typed field, and a `Box<dyn Source>` field lands on
       // forward through a typed field, and a `Box<dyn Source>` field lands on
@@ -2358,6 +2430,59 @@ export function useProjectCache() {
       }
       }
     });
     });
 
 
+    it('keeps a built-in string method off an unrelated project method (#1840)', async () => {
+      fs.writeFileSync(path.join(tempDir, 'strings.ts'), `
+export async function listPaths(): Promise<string> { return "a\0b"; }
+export async function snapshot(): Promise<string[]> {
+  const listed = await listPaths();
+  return listed.split('\0');
+}
+`);
+      fs.writeFileSync(path.join(tempDir, 'pane.ts'), `
+export class PaneManager {
+  split(): string { return "new pane"; }
+}
+`);
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+
+      const caller = cg.getNodesByName('snapshot').find((n) => n.kind === 'function');
+      expect(caller).toBeDefined();
+      expect(
+        cg.getCallees(caller!.id)
+          .filter(({ edge }) => edge.kind === 'calls')
+          .map(({ node }) => node.qualifiedName)
+          .sort(),
+      ).toEqual(['listPaths']);
+    });
+
+    it('types an awaited receiver from the callee\'s declared return (#1840)', async () => {
+      fs.writeFileSync(path.join(tempDir, 'engine.ts'), `
+export class Engine {
+  run(): string { return "ran"; }
+}
+export class Decoy {
+  run(): string { return "decoy"; }
+}
+export async function makeEngine(): Promise<Engine> { return new Engine(); }
+export async function drive(): Promise<string> {
+  const handle = await makeEngine();
+  return handle.run();
+}
+`);
+      cg = await CodeGraph.init(tempDir, { index: true });
+      cg.resolveReferences();
+
+      const caller = cg.getNodesByName('drive').find((n) => n.kind === 'function');
+      expect(caller).toBeDefined();
+      expect(
+        cg.getCallees(caller!.id)
+          .filter(({ edge }) => edge.kind === 'calls')
+          .map(({ node }) => node.qualifiedName)
+          .sort(),
+      ).toEqual(['Engine::run', 'makeEngine']);
+    });
+
     it('keeps a validated project class that shadows Map (#1566)', async () => {
     it('keeps a validated project class that shadows Map (#1566)', async () => {
       fs.writeFileSync(path.join(tempDir, 'shadow.ts'), `
       fs.writeFileSync(path.join(tempDir, 'shadow.ts'), `
 export class Map { get() { return 1; } }
 export class Map { get() { return 1; } }

+ 68 - 0
__tests__/rust-self-owner.test.ts

@@ -0,0 +1,68 @@
+import { afterEach, beforeEach, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CodeGraph } from '../src';
+
+let root: string;
+let cg: CodeGraph | undefined;
+beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-self-owner-')); });
+afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); });
+async function index(files: Record<string, string>) {
+  fs.mkdirSync(path.join(root, 'src'));
+  fs.writeFileSync(path.join(root, 'Cargo.toml'), '[package]\nname="owners"\nversion="0.1.0"\nedition="2021"\n');
+  for (const [file, text] of Object.entries(files)) fs.writeFileSync(path.join(root, 'src', file), text);
+  cg = await CodeGraph.init(root, { index: true });
+}
+function targets(file: string) {
+  const caller = cg!.getNodesByKind('method').find(n => n.filePath === `src/${file}` && n.qualifiedName === 'Target::run');
+  expect(caller).toBeDefined();
+  return cg!.getOutgoingEdges(caller!.id).filter(e => e.kind === 'calls').map(e => {
+    const target = cg!.getNode(e.target)!;
+    return `${target.filePath}:${target.qualifiedName}`;
+  });
+}
+
+it('does not borrow a missing method from a same-named type in another module (#1861)', async () => {
+  await index({
+    'lib.rs': 'pub mod caller; pub mod decoy;',
+    'caller.rs': 'pub struct Target;\nimpl Target { pub fn run(&self) { self.reset(); } }',
+    'decoy.rs': 'pub struct Target;\nimpl Target { pub fn reset(&self) {} }',
+  });
+  expect(targets('caller.rs')).toEqual([]);
+});
+
+it('keeps a proven local owner despite a same-named type and method in another module (#1861)', async () => {
+  await index({
+    'lib.rs': 'pub mod caller; pub mod decoy;',
+    'caller.rs': 'pub struct Target;\nimpl Target { pub fn reset(&self) {} }\nimpl Target { pub fn run(&self) { self.reset(); } }',
+    'decoy.rs': 'pub struct Target;\nimpl Target { pub fn reset(&self) {} }',
+  });
+  expect(targets('caller.rs')).toEqual(['src/caller.rs:Target::reset']);
+  fs.writeFileSync(path.join(root, 'src/caller.rs'), 'pub struct Target;\nimpl Target { pub fn run(&self) { self.reset(); } }');
+  await cg!.sync();
+  expect(targets('caller.rs')).toEqual([]);
+  fs.writeFileSync(path.join(root, 'src/caller.rs'), 'pub struct Target;\nimpl Target { pub fn reset(&self) {} }\nimpl Target { pub fn run(&self) { self.reset(); } }');
+  await cg!.sync();
+  expect(targets('caller.rs')).toEqual(['src/caller.rs:Target::reset']);
+});
+
+it('keeps a unique owner whose impl is split across files (#1861)', async () => {
+  await index({
+    'lib.rs': 'pub mod caller;\npub struct Target;\nimpl Target { pub fn reset(&self) {} }',
+    'caller.rs': 'use crate::Target;\nimpl Target { pub fn run(&self) { self.reset(); } }',
+  });
+  expect(targets('caller.rs')).toEqual(['src/lib.rs:Target::reset']);
+});
+
+it('declines indistinguishable inline-module owners instead of claiming one (#1861)', async () => {
+  await index({ 'lib.rs': `mod a {
+    pub struct Target;
+    impl Target { pub fn reset(&self) {} }
+  }
+  mod b {
+    pub struct Target;
+    impl Target { pub fn run(&self) { self.reset(); } }
+  }` });
+  expect(targets('lib.rs')).toEqual([]);
+});

+ 86 - 0
__tests__/store-binding-cache.test.ts

@@ -0,0 +1,86 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { CodeGraph } from '../src';
+
+const projects: { dir: string; cg: CodeGraph }[] = [];
+afterEach(() => {
+  for (const { dir, cg } of projects.splice(0)) {
+    cg.close();
+    fs.rmSync(dir, { recursive: true, force: true });
+  }
+});
+
+function consumer(active: boolean): string {
+  return `import { useStore as current } from './store';
+export function run() {
+  const { reset } = ${active ? 'current.getState()' : 'external()'};
+  reset();
+  reset();
+}
+export function effects(client: any) {
+  client.user.create();
+  client?.user?.create();
+}
+`;
+}
+
+async function project(active: boolean) {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-cache-'));
+  fs.writeFileSync(path.join(dir, 'store.ts'), `import { create } from 'zustand';
+export const useStore = create((set) => ({ reset: () => set({}) }));
+`);
+  fs.writeFileSync(path.join(dir, 'decoy.ts'), 'export function reset() { return 99; }');
+  fs.writeFileSync(path.join(dir, 'consumer.ts'), consumer(active));
+  const cg = CodeGraph.initSync(dir);
+  projects.push({ dir, cg });
+  const result = await cg.indexAll();
+  expect(result.success).toBe(true);
+  expect(result.filesErrored).toBe(0);
+  return { dir, cg };
+}
+
+function assertBindings(cg: CodeGraph, active: boolean) {
+  const functions = cg.getNodesByKind('function');
+  const run = functions.find(n => n.name === 'run' && n.filePath === 'consumer.ts')!;
+  const action = functions.find(n => n.name === 'reset' && n.filePath === 'store.ts')!;
+  const decoy = functions.find(n => n.name === 'reset' && n.filePath === 'decoy.ts')!;
+  const targets = cg.getOutgoingEdges(run.id).filter(e => e.kind === 'calls').map(e => e.target);
+  expect(targets.includes(action.id)).toBe(active);
+  expect(targets).not.toContain(decoy.id);
+  const pendingActions = cg.getUnresolvedReferencesFrom(run.id).filter(r => r.referenceName === 'reset');
+  expect(pendingActions).toHaveLength(active ? 0 : 2);
+
+  // Eligibility must not remove untyped qualified call-site evidence, including
+  // repeated/optional chains, or turn it into a guessed edge.
+  const effects = functions.find(n => n.name === 'effects' && n.filePath === 'consumer.ts')!;
+  expect(cg.getOutgoingEdges(effects.id).filter(e => e.kind === 'calls')).toEqual([]);
+  expect(cg.getUnresolvedReferencesFrom(effects.id).filter(r => r.referenceKind === 'calls')
+    .map(r => [r.referenceName, r.line, r.column])).toEqual([
+      ['client.user.create', 8, 2], ['client.user.create', 9, 2],
+    ]);
+}
+
+describe('store eligibility cache across edits and resolver contexts', () => {
+  it.each([false, true])('sync refreshes eligibility starting with getState=%s', async (initial) => {
+    const { dir, cg } = await project(initial);
+    assertBindings(cg, initial);
+    // Reuse the same CodeGraph/resolver and path in both directions. A cached
+    // negative must not mask a new store binding, and removing it must remove
+    // both action edges while preserving unresolved call-site evidence.
+    for (const active of [!initial, initial]) {
+      fs.writeFileSync(path.join(dir, 'consumer.ts'), consumer(active));
+      const result = await cg.sync();
+      expect(result.filesModified).toBe(1);
+      assertBindings(cg, active);
+    }
+  }, 60000);
+
+  it('does not share eligibility between projects with the same relative file path', async () => {
+    const absent = await project(false);
+    const present = await project(true);
+    assertBindings(absent.cg, false);
+    assertBindings(present.cg, true);
+  }, 60000);
+});

+ 80 - 0
__tests__/sync-rebuild-convergence.test.ts

@@ -146,6 +146,86 @@ describe('Incremental sync converges to a full rebuild (CG-33)', () => {
     expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
     expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
   });
   });
 
 
+  it('keeps one edge when re-resolution selects the same target', async () => {
+    write('src/caller.ts', `export function run(): number {\n  return pct(1);\n}\n`);
+    write('src/alpha.ts', `export function pct(n: number): number {\n  return n;\n}\n`);
+    cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+
+    // zeta.ts introduces a competing definition, so the existing edge is
+    // reopened, but alpha.ts remains the deterministic first candidate.
+    write('src/zeta.ts', `export function pct(n: number): number {\n  return n * 2;\n}\n`);
+    const result = await cg.sync();
+    expect(result.definitionDelta).toContain('pct');
+
+    const targets = withDb((db) =>
+      (
+        db
+          .prepare(
+            `SELECT target.file_path AS file
+               FROM edges edge
+               JOIN nodes source ON source.id = edge.source
+               JOIN nodes target ON target.id = edge.target
+              WHERE source.name = 'run'
+                AND target.name = 'pct'
+                AND edge.kind = 'calls'`
+          )
+          .all() as Array<{ file: string }>
+      ).map((row) => row.file)
+    );
+    expect(targets).toEqual(['src/alpha.ts']);
+  });
+
+  it('rolls back edge deletion when requeueing its reference is interrupted', async () => {
+    write('src/caller.ts', `export function run(): number {\n  return pct(1);\n}\n`);
+    write('src/zeta.ts', `export function pct(n: number): number {\n  return n;\n}\n`);
+    cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+
+    const originalEdge = withDb((db) => {
+      const row = db
+        .prepare(
+          `SELECT edge.source, edge.target, edge.kind
+             FROM edges edge
+             JOIN nodes source ON source.id = edge.source
+             JOIN nodes target ON target.id = edge.target
+            WHERE source.name = 'run'
+              AND target.name = 'pct'
+              AND edge.kind = 'calls'`
+        )
+        .get() as { source: string; target: string; kind: string };
+      db.exec(
+        `CREATE TRIGGER interrupt_pct_requeue
+         BEFORE INSERT ON unresolved_refs
+         WHEN NEW.reference_name = 'pct'
+         BEGIN
+           SELECT RAISE(ABORT, 'forced rebind interruption');
+         END;`
+      );
+      return `${row.source}|${row.target}|${row.kind}`;
+    });
+
+    write('src/alpha.ts', `export function pct(n: number): number {\n  return n * 2;\n}\n`);
+    await expect(cg.sync()).rejects.toThrow(/forced rebind interruption/);
+
+    // A failed requeue leaves the last committed graph answer untouched.
+    expect(edgeSet().has(originalEdge)).toBe(true);
+    const queued = withDb(
+      (db) =>
+        (
+          db
+            .prepare(
+              `SELECT COUNT(*) AS count
+                 FROM unresolved_refs ref
+                 JOIN nodes source ON source.id = ref.from_node_id
+                WHERE source.name = 'run' AND ref.reference_name = 'pct'`
+            )
+            .get() as { count: number }
+        ).count
+    );
+    expect(queued).toBe(0);
+  });
+
   /**
   /**
    * The mirror direction: removing a definition narrows the candidate set too,
    * The mirror direction: removing a definition narrows the candidate set too,
    * so the delta must include names the sync DROPPED, not just names it added.
    * so the delta must include names the sync DROPPED, not just names it added.

+ 162 - 0
__tests__/sync.test.ts

@@ -881,3 +881,165 @@ describe('Scoped sync parity (#watcher-scoped)', () => {
     expect(cg.searchNodes('gamma').length).toBe(1);
     expect(cg.searchNodes('gamma').length).toBe(1);
   });
   });
 });
 });
+
+// A change that is COMMITTED but not yet indexed used to read as zero pending
+// changes: getChangedFiles' git fast path built its candidate list from
+// `git status --porcelain`, and committing is exactly what removes a file from
+// that output. The hash comparison below it was correct and simply never
+// reached. Committed work is now sourced from `git diff <indexed commit> HEAD`.
+// (#1829)
+describe('committed-but-unindexed changes (#1829)', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+
+  const git = (...args: string[]) =>
+    execFileSync('git', args, { cwd: testDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
+
+  beforeEach(async () => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1829-'));
+    git('init');
+    git('config', 'user.email', 'test@test.com');
+    git('config', 'user.name', 'Test');
+
+    fs.mkdirSync(path.join(testDir, 'src'));
+    fs.writeFileSync(path.join(testDir, 'src', 'one.ts'), `export function alpha() { return 1; }`);
+    git('add', '-A');
+    git('commit', '-m', 'initial');
+
+    cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
+    await cg.indexAll();
+  });
+
+  afterEach(() => {
+    if (cg) cg.destroy();
+    if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  it('sees a committed NEW file (git status shows nothing; the DB has no row)', async () => {
+    fs.writeFileSync(path.join(testDir, 'src', 'two.ts'), `export function beta() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add two');
+
+    const changes = cg.getChangedFiles();
+    expect(changes.added).toContain('src/two.ts');
+
+    // status and sync must agree — the whole point is that the number a user
+    // reads matches the work that is actually outstanding.
+    const result = await cg.sync();
+    expect(result.filesAdded).toBe(1);
+    expect(cg.searchNodes('beta').length).toBeGreaterThan(0);
+  });
+
+  it('sees a committed MODIFICATION to an already-tracked file', async () => {
+    fs.writeFileSync(path.join(testDir, 'src', 'one.ts'), `export function alphaRenamed() { return 99; }`);
+    git('add', '-A');
+    git('commit', '-m', 'edit one');
+
+    expect(cg.getChangedFiles().modified).toContain('src/one.ts');
+    const result = await cg.sync();
+    expect(result.filesModified).toBe(1);
+    expect(cg.searchNodes('alphaRenamed').length).toBeGreaterThan(0);
+  });
+
+  it('sees a committed DELETE', async () => {
+    fs.writeFileSync(path.join(testDir, 'src', 'two.ts'), `export function beta() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add two');
+    await cg.sync();
+
+    fs.rmSync(path.join(testDir, 'src', 'two.ts'));
+    git('add', '-A');
+    git('commit', '-m', 'remove two');
+
+    expect(cg.getChangedFiles().removed).toContain('src/two.ts');
+    const result = await cg.sync();
+    expect(result.filesRemoved).toBe(1);
+  });
+
+  it('reports zero once the sync has absorbed the commit (the stamp advances)', async () => {
+    fs.writeFileSync(path.join(testDir, 'src', 'two.ts'), `export function beta() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add two');
+    await cg.sync();
+
+    const after = cg.getChangedFiles();
+    expect(after.added).toHaveLength(0);
+    expect(after.modified).toHaveLength(0);
+    expect(after.removed).toHaveLength(0);
+  });
+
+  it('counts a file once when it was committed AND edited again since', async () => {
+    // The same path now reaches the candidate list from both sources — the
+    // committed diff and `git status`. It is still one changed file.
+    fs.writeFileSync(path.join(testDir, 'src', 'one.ts'), `export function alpha() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'edit one');
+    fs.writeFileSync(path.join(testDir, 'src', 'one.ts'), `export function alpha() { return 3; }`);
+
+    const changes = cg.getChangedFiles();
+    expect(changes.modified.filter((f) => f === 'src/one.ts')).toHaveLength(1);
+    expect(changes.added).toHaveLength(0);
+
+    const result = await cg.sync();
+    expect(result.filesModified).toBe(1);
+  });
+
+  it('sees a committed RENAME as a removal plus an add', async () => {
+    // `--no-renames` on the committed diff is deliberate: the index keys files
+    // by path, so a rename IS a removal and an add, and pairing them up would
+    // only have to be taken apart again.
+    fs.renameSync(path.join(testDir, 'src', 'one.ts'), path.join(testDir, 'src', 'renamed.ts'));
+    git('add', '-A');
+    git('commit', '-m', 'rename one');
+
+    const changes = cg.getChangedFiles();
+    expect(changes.removed).toContain('src/one.ts');
+    expect(changes.added).toContain('src/renamed.ts');
+
+    const result = await cg.sync();
+    expect(result.filesRemoved).toBe(1);
+    expect(result.filesAdded).toBe(1);
+    expect(cg.searchNodes('alpha').every((r) => r.node.filePath !== 'src/one.ts')).toBe(true);
+  });
+
+  it('still filters committed changes by the rules the full index uses', async () => {
+    // vendor/ is a built-in exclude git knows nothing about. Sourcing candidates
+    // from `git diff` must not smuggle in files `git status` would have had
+    // filtered out (#766) — same classifier, both sources.
+    fs.mkdirSync(path.join(testDir, 'vendor'));
+    fs.writeFileSync(path.join(testDir, 'vendor', 'lib.ts'), `export function vendored() { return 1; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add vendor');
+
+    const changes = cg.getChangedFiles();
+    expect(changes.added).not.toContain('vendor/lib.ts');
+    expect(changes.modified).not.toContain('vendor/lib.ts');
+  });
+
+  it('falls back to the full scan when history moved under the index', async () => {
+    // A rebase/gc/shallow clone can leave the stamped commit unreachable. The
+    // fast path cannot diff against a commit that is gone, so the (correct,
+    // slower) full scan has to answer instead of silently reporting zero.
+    fs.writeFileSync(path.join(testDir, 'src', 'two.ts'), `export function beta() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add two');
+    (cg as unknown as { queries: { setMetadata(k: string, v: string): void } })
+      .queries.setMetadata('indexed_at_commit', '0'.repeat(40));
+
+    expect(cg.getChangedFiles().added).toContain('src/two.ts');
+  });
+
+  it('an index with no stamp still answers correctly (pre-#1829 index upgrading)', async () => {
+    (cg as unknown as { queries: { setMetadata(k: string, v: string): void } })
+      .queries.setMetadata('indexed_at_commit', '');
+    fs.writeFileSync(path.join(testDir, 'src', 'two.ts'), `export function beta() { return 2; }`);
+    git('add', '-A');
+    git('commit', '-m', 'add two');
+
+    expect(cg.getChangedFiles().added).toContain('src/two.ts');
+
+    // ...and it self-heals: the sync writes a stamp, so the next read is clean.
+    await cg.sync();
+    expect(cg.getChangedFiles().added).toHaveLength(0);
+  });
+});

+ 92 - 0
__tests__/telemetry-optout.test.ts

@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { Telemetry } from '../src/telemetry';
+
+describe('telemetry opt-out across running instances (#1869)', () => {
+  let dir: string;
+  let now: Date;
+  let sends: any[];
+  const make = (env = {}, fetchImpl: typeof fetch = async (_url, init) => {
+    sends.push(JSON.parse(String(init?.body)));
+    return new Response(null, { status: 204 });
+  }) => new Telemetry({ dir, env, fetchImpl, now: () => now, stderr: () => {}, installExitHook: false });
+  const queued = () => fs.readdirSync(dir).filter(n => n.startsWith('telemetry-queue'));
+  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-telemetry-off-')); now = new Date('2026-06-12T08:00:00Z'); sends = []; });
+  afterEach(() => { vi.useRealTimers(); fs.rmSync(dir, { recursive: true, force: true }); });
+
+  it('removes the identity on off and assigns a new one on on', () => {
+    const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId;
+    make().setEnabled(false, 'cli');
+    expect(a.getStatus()).toMatchObject({ enabled: false, machineId: null });
+    expect(fs.readFileSync(a.configPath, 'utf8')).not.toContain(old!);
+    make().setEnabled(true, 'cli');
+    expect(a.getStatus().machineId).toBeTruthy(); expect(a.getStatus().machineId).not.toBe(old);
+  });
+
+  it.each(['persist', 'flush'] as const)('drops memory and new recording after another instance opts out: %s', async action => {
+    const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
+    make().setEnabled(false, 'cli'); a.recordLifecycle('index', {}); a.recordUsage('mcp_tool', 'codegraph_explore', true);
+    if (action === 'persist') a.persistSync(); else await a.flushNow();
+    expect(sends).toEqual([]); expect(queued()).toEqual([]);
+    make().setEnabled(true, 'cli'); await a.flushNow();
+    expect(sends).toEqual([]); expect(queued()).toEqual([]);
+  });
+
+  it('observes external off at the completed-day interval', async () => {
+    vi.useFakeTimers(); const a = make(); a.setEnabled(true, 'cli'); a.recordUsage('cli_command', 'query', true);
+    a.startInterval(); await vi.advanceTimersByTimeAsync(0);
+    make().setEnabled(false, 'cli'); now = new Date('2026-06-13T08:00:00Z');
+    try { await vi.advanceTimersByTimeAsync(6 * 60 * 60_000); expect(sends).toEqual([]); expect(queued()).toEqual([]); }
+    finally { a.stopInterval(); }
+  });
+
+  it('does not reuse pre-opt-out memory after an off/on cycle the process missed', async () => {
+    const a = make(); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
+    const b = make(); b.setEnabled(false, 'cli'); b.setEnabled(true, 'cli');
+    a.recordLifecycle('index', {}); await a.flushNow();
+    expect(sends).toHaveLength(1); expect(sends[0].events.map((e: any) => e.event)).toEqual(['index']);
+    expect(sends[0].machine_id).toBe(b.getStatus().machineId);
+  });
+
+  it.each([false, true])('in-flight failure cannot recreate old data after off (re-enable=%s)', async reEnable => {
+    let reject!: (error: Error) => void;
+    const a = make({}, async () => { sends.push('started'); return new Promise((_resolve, fail) => { reject = fail; }); });
+    a.setEnabled(true, 'cli'); a.recordLifecycle('install', {}); a.recordUsage('cli_command', 'query', true);
+    const flushing = a.flushNow(); expect(sends).toEqual(['started']);
+    const b = make(); b.setEnabled(false, 'cli'); if (reEnable) b.setEnabled(true, 'cli');
+    reject(new Error('network failure')); await flushing;
+    expect(queued()).toEqual([]); await b.flushNow(); expect(sends).toEqual(['started']);
+  });
+
+  it('checks consent before each request chunk after an in-flight request returns', async () => {
+    let finish!: (r: Response) => void;
+    const a = make({}, async () => { sends.push('started'); if (sends.length > 1) return new Response(null, { status: 204 }); return new Promise(resolve => { finish = resolve; }); });
+    a.setEnabled(true, 'cli'); for (let i = 0; i < 105; i++) a.recordLifecycle('index', {});
+    const flushing = a.flushNow(); expect(sends).toHaveLength(1);
+    make().setEnabled(false, 'cli'); finish(new Response(null, { status: 204 })); await flushing;
+    expect(sends).toHaveLength(1); expect(queued()).toEqual([]);
+  });
+
+  it('off removes stale claims as well as the queue so on cannot revive them', async () => {
+    const a = make(); a.setEnabled(true, 'cli');
+    const claim = path.join(dir, 'telemetry-queue.sending.98765.jsonl');
+    fs.writeFileSync(claim, JSON.stringify({ v: 2, ev: 'install', ts: now.toISOString(), props: {} }) + '\n');
+    const old = new Date(now.getTime() - 2 * 60 * 60_000); fs.utimesSync(claim, old, old);
+    a.setEnabled(false, 'cli'); expect(queued()).toEqual([]); a.setEnabled(true, 'cli'); await a.flushNow(); expect(sends).toEqual([]);
+  });
+
+  it.each(['DO_NOT_TRACK', 'CODEGRAPH_TELEMETRY'])('environment off drops pending memory: %s', async key => {
+    const env: NodeJS.ProcessEnv = {}; const a = make(env); a.setEnabled(true, 'cli'); a.recordLifecycle('install', {});
+    env[key] = key === 'DO_NOT_TRACK' ? '1' : '0'; await a.flushNow(); a.persistSync(); delete env[key]; await a.flushNow();
+    expect(sends).toEqual([]); expect(queued()).toEqual([]);
+  });
+
+  it('retains the documented explicit environment-on override without resurrecting the old identity', async () => {
+    const a = make(); a.setEnabled(true, 'cli'); const old = a.getStatus().machineId; a.setEnabled(false, 'cli');
+    const forced = make({ CODEGRAPH_TELEMETRY: '1' }); forced.recordLifecycle('index', {}); await forced.flushNow();
+    expect(sends).toHaveLength(1); expect(sends[0].machine_id).toMatch(/^[0-9a-f-]{36}$/); expect(sends[0].machine_id).not.toBe(old);
+    expect(make().isEnabled()).toBe(false);
+  });
+});

+ 30 - 0
__tests__/writer-lock.test.ts

@@ -4,9 +4,11 @@
  */
  */
 
 
 import { afterEach, describe, expect, it } from 'vitest';
 import { afterEach, describe, expect, it } from 'vitest';
+import { spawn, type ChildProcess } from 'child_process';
 import * as fs from 'fs';
 import * as fs from 'fs';
 import * as os from 'os';
 import * as os from 'os';
 import * as path from 'path';
 import * as path from 'path';
+import { MCPEngine } from '../src/mcp/engine';
 import {
 import {
   decodeWriterLockInfo,
   decodeWriterLockInfo,
   getWriterPidPath,
   getWriterPidPath,
@@ -17,8 +19,11 @@ import {
 
 
 describe('writer lock (#1740)', () => {
 describe('writer lock (#1740)', () => {
   let dir: string;
   let dir: string;
+  let holder: ChildProcess | null = null;
 
 
   afterEach(() => {
   afterEach(() => {
+    try { holder?.kill('SIGKILL'); } catch { /* already gone */ }
+    holder = null;
     if (dir) {
     if (dir) {
       releaseWriterLock(dir);
       releaseWriterLock(dir);
       try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
       try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
@@ -84,4 +89,29 @@ describe('writer lock (#1740)', () => {
     expect(r.kind).toBe('acquired');
     expect(r.kind).toBe('acquired');
     releaseWriterLock(root);
     releaseWriterLock(root);
   });
   });
+
+  it('lets a fallback engine atomically claim and release writer ownership', () => {
+    const root = makeProject();
+    const engine = new MCPEngine({ writerLockRoot: root });
+
+    expect(decodeWriterLockInfo(fs.readFileSync(getWriterPidPath(root), 'utf8'))).toMatchObject({
+      pid: process.pid,
+      mode: 'fallback',
+    });
+
+    engine.stop();
+    expect(fs.existsSync(getWriterPidPath(root))).toBe(false);
+  });
+
+  it('rejects a fallback engine before opening when another process owns writer.pid', () => {
+    const root = makeProject();
+    holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
+    if (!holder.pid) throw new Error('Failed to spawn writer-lock holder');
+    fs.writeFileSync(
+      getWriterPidPath(root),
+      JSON.stringify({ pid: holder.pid, mode: 'daemon', startedAt: Date.now() }) + '\n',
+    );
+
+    expect(() => new MCPEngine({ writerLockRoot: root })).toThrow(/writer lock held/i);
+  });
 });
 });

+ 10 - 2
codegraph-kernel/src/rustlang.rs

@@ -894,9 +894,17 @@ impl<'t> Walker<'t> {
                                     _ => callee_name = method_name.to_string(),
                                     _ => callee_name = method_name.to_string(),
                                 }
                                 }
                             }
                             }
+                            // `self.method()` — keep the `self.` prefix so the
+                            // resolver can read the owner off the calling
+                            // method's qualified name and resolve the method on
+                            // THAT type, instead of matching a bare name by file
+                            // proximity (#1861). Mirrors the wasm extractor.
+                            "self" => {
+                                callee_name = format!("self.{method_name}");
+                            }
                             _ => {
                             _ => {
-                                // parenthesized, await_expression, `self` —
-                                // bare method name.
+                                // parenthesized, await_expression — bare method
+                                // name.
                                 callee_name = method_name.to_string();
                                 callee_name = method_name.to_string();
                             }
                             }
                         }
                         }

+ 363 - 0
docs/benchmarks/regression-audit-2026-09.md

@@ -83,3 +83,366 @@ of this extreme-input parsing limit.
 These checks do not cover other operating systems, a long-running watcher,
 These checks do not cover other operating systems, a long-running watcher,
 MCP transport latency, or the paid agent A/B harness. Shared-host timing was
 MCP transport latency, or the paid agent A/B harness. Shared-host timing was
 noisy, particularly for Excalidraw, and is not used to claim a performance win.
 noisy, particularly for Excalidraw, and is not used to claim a performance win.
+
+## Independent-review follow-up (September 14–15, 2026)
+
+The user-supplied Claude review of this PR reported 4,636 passing / 3 failing
+tests, compared with 4,600 / 18 before the repairs, and confirmed the five
+correctness fixes. Those full-suite figures are independent review evidence;
+this follow-up did not repeat the completed full audit.
+
+The two `CLAUDE_CONFIG_DIR` failures were path-alias mismatches. The test
+fixtures now canonicalize their temporary home and working directories with
+`realpathSync`, matching `chdir`'s behavior on macOS `/var` → `/private/var`.
+The exact path, file content and idempotency assertions remain in place.
+Both failures were reproduced on Linux using a symlinked `TMPDIR` before the
+change; all seven override cases pass afterward. macOS was unavailable, so
+this is a reproduced path-alias fix, not a claim of a macOS test run.
+
+The PPID watchdog integration test now launches its wrapper and descendants
+in its own temporary project. An editor's writer lock in the source checkout
+can no longer end the child before the watchdog is exercised. The existing
+assertions still require a live child, held-open stdin, detection of its
+terminated parent and actual child shutdown. No live lock was changed and no
+user server was stopped. The Linux check used a subprocess subreaper to
+provide the orphan-reaping behavior otherwise supplied by `docker --init`.
+
+The direct `name-matcher` ↔ `import-resolver` cycle is removed. The resolver
+coordinator supplies import lookup through `ResolutionContext`, preserving
+the existing import resolver and its caches. Two additional exact-target-set
+tests cover store actions through a barrel re-export and renamed import,
+including exclusion of another store's identically named action.
+
+Follow-up validation:
+
+- 233 native resolver/regression cases and 42 affected WASM cases pass.
+- The installer suite passes 245 cases with its three existing platform
+  skips; the separate symlink-root reproduction passes all seven selected
+  override cases.
+- The real PPID process case and 14 watchdog decision cases pass.
+- `npm run build` passes, including the viewer and packaged grammar checks.
+- The monolithic-worker and extreme Scala-input limitations above remain;
+  these test-isolation changes do not repair native allocation retention.
+
+## Bounded large TypeScript comparison
+
+The comparison indexes `microsoft/vscode`'s `src/vs/platform` subtree at
+`38246c086c8a825ca90190749dd88df6effec257`: 2,623 TypeScript files (26.7 MiB
+of TypeScript source), plus 12 JavaScript and 457 YAML files. This is a large
+subsystem, not all of VS Code; definitions outside the subtree are absent in
+both arms. It is larger than the previously pinned Excalidraw fixture.
+
+The baseline is main `3ed73bc127323e63153bf6ec8354afa82ce36aaf`; the fixed
+arm is PR head `c7d2892180874f42b9f9f99119f2868fe093a816` plus the six-file
+follow-up committed locally as `ec13d99`. This measures the whole PR versus
+its base, not the isolated causal cost of qualified-chain retention or the
+cycle refactor. Both builds use the bundled Node 24.16.0 and identical source.
+
+Three sequential pairs were attempted per backend, with baseline/fixed order
+reversed for the middle pair. Each run has a fresh
+process and database, one assigned CPU, one parse worker, one resolver worker,
+parallel resolution disabled, `RAYON_NUM_THREADS=1`, a 1 GiB JS heap limit,
+and `--liftoff-only`. Source pages are warmed once before the first pair;
+there is no separate discarded warm-up run. No other audit test/build runs
+concurrently. Limits are 150 seconds and 1,500 MiB sampled RSS per process.
+Successful native runs consumed about 9.8 minutes; the WASM continuation was
+capped at 10 minutes, keeping benchmark subprocess time below 20 minutes.
+
+Three native pairs and two WASM pairs completed. The third WASM baseline also
+completed (111.7 seconds), but its fixed partner was stopped after 21.6 seconds
+when the continuation budget expired. That pair is excluded from comparisons;
+the interrupted run is not a product failure or a valid timing result.
+
+The initial three WASM preflight attempts were rejected by a harness mistake:
+`getKernel()` checks whether the native library is installed but deliberately
+ignores the kill switch. The guard was corrected to `kernelSupports('typescript')`,
+the extraction routing predicate. Those attempts performed no indexing and
+are preserved but excluded. Completed native runs were not repeated.
+
+Total process wall time includes startup, indexing, database checks and
+close. Index wall/CPU time brackets `indexAll()`. The resolution stage wraps
+`resolveReferencesBatched` and includes persistence and synthesis as well as
+matching. Process CPU time includes its worker threads; RSS is sampled every
+100 ms and checked against the process high-water mark. These controls
+reduce local contention but cannot reserve the shared host's CPU.
+
+Values are median (minimum–maximum) over **complete pairs only**. The last
+column is the median of the per-pair percentage changes, not a ratio of the
+two displayed medians. Positive values mean more time or memory.
+
+| Backend / metric | Main baseline | Fixed PR | Paired change |
+|---|---:|---:|---:|
+| native / Process elapsed (s) | 92.0 (91.2–95.2) | 102.5 (101.6–102.8) | +11.3% |
+| native / Index elapsed (s) | 87.8 (87.2–91.1) | 97.2 (96.4–98.7) | +10.6% |
+| native / Index CPU (s) | 54.5 (53.9–55.9) | 60.8 (60.1–61.5) | +10.2% |
+| native / Resolution elapsed (s) | 50.1 (39.0–52.1) | 59.5 (57.9–61.3) | +17.7% |
+| native / Resolution CPU (s) | 37.6 (37.1–38.8) | 43.8 (42.8–44.1) | +13.8% |
+| native / Process peak RSS (MiB) | 1235.7 (1168.9–1250.8) | 1194.3 (1149.1–1196.3) | -3.3% |
+| native / Resolution peak RSS (MiB) | 1218.7 (1141.4–1230.5) | 1170.9 (1148.0–1181.4) | -3.1% |
+| wasm / Process elapsed (s) | 112.4 (111.3–113.4) | 121.0 (117.2–124.8) | +7.7% |
+| wasm / Index elapsed (s) | 108.2 (107.2–109.3) | 116.3 (113.0–119.6) | +7.5% |
+| wasm / Index CPU (s) | 77.3 (77.1–77.4) | 83.3 (82.7–83.9) | +7.7% |
+| wasm / Resolution elapsed (s) | 49.7 (45.3–54.0) | 56.5 (51.6–61.4) | +15.6% |
+| wasm / Resolution CPU (s) | 37.8 (37.7–37.8) | 43.7 (43.5–43.9) | +15.7% |
+| wasm / Process peak RSS (MiB) | 1163.4 (1119.2–1207.6) | 1127.4 (1051.8–1203.0) | -2.7% |
+| wasm / Resolution peak RSS (MiB) | 1135.8 (1091.5–1180.0) | 1111.2 (1041.4–1181.1) | -1.8% |
+
+The completed pairs show a consistent increase in CPU work: about **10.2%
+native / 7.7% WASM** for indexing and **13.8% / 15.7%** for the resolution
+stage, using median paired changes. Whole-process elapsed time increases by
+11.3% / 7.7% here. Resolution wall time is much less stable (one WASM pair
+actually decreases), so an exact wall-time penalty is not portable to another
+host. Memory ranges overlap and pairwise RSS changes have both signs; this
+does not establish a memory improvement or regression. The prior small-corpus
+3.4-second observation does not establish that the added work is free at scale.
+
+All 11 completed indexes pass integrity, foreign-key and orphan checks with
+zero indexing errors. Each contains 75,767 nodes and 256,523 edges. Pending/failed
+references after indexing increase from 156,817 to 166,516 (+9,699, **6.2%**);
+references entering resolution increase from 342,370 to 352,069 (**2.8%**).
+The additional retained references do not create guessed internal calls.
+Complete row/multiplicity comparisons of the first pair in each backend also
+confirm identical node and edge contents, excluding node update timestamps
+and auto-increment row IDs. All 9,699 additions are qualified call references;
+no unresolved reference was removed. Bounded-memory SQL was used after an
+initial in-memory postprocessing attempt was interrupted; the index runs and
+saved databases were unaffected.
+
+
+The precision and correctness fixes remain warranted. This experiment finds
+a bounded, repeatable CPU cost on this large subsystem, not an isolated
+causal estimate for one retention rule and not a full-VS-Code/default-worker
+benchmark. There is no new timing-based release gate or claim of unchanged
+performance. The unavailable macOS run and earlier parser/worker limitations
+remain explicit.
+
+### Reproduction and retained evidence
+
+The portable `scripts/benchmarks/measure-index.cjs` harness accepts a built
+engine directory, fixture directory, existing output directory and backend.
+Build both pinned engines first and stage their matching native kernels. Set
+`BENCH_NODE`, `BENCH_ENGINE`, `BENCH_FIXTURE`, and `BENCH_OUT` to absolute paths;
+use a new output directory and an unindexed fixture for each run. For WASM:
+
+```sh
+mkdir -p "$BENCH_OUT"
+test ! -e "$BENCH_FIXTURE/.codegraph"
+CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1 CODEGRAPH_NO_UPDATE_CHECK=1 \
+CODEGRAPH_KERNEL=0 CODEGRAPH_WASM_RELAUNCHED=1 \
+CODEGRAPH_PARSE_WORKERS=1 CODEGRAPH_RESOLVE_WORKERS=1 \
+CODEGRAPH_NO_PARALLEL_RESOLVE=1 RAYON_NUM_THREADS=1 \
+taskset -c 0 "$BENCH_NODE" --liftoff-only --max-old-space-size=1024 \
+  scripts/benchmarks/measure-index.cjs \
+  "$BENCH_ENGINE" "$BENCH_FIXTURE" "$BENCH_OUT" wasm
+mv "$BENCH_FIXTURE/.codegraph" "$BENCH_OUT/index"
+```
+
+For native, change `CODEGRAPH_KERNEL=1` and the final argument to `native`.
+Use an available CPU from the host's affinity mask. Repeat sequentially in
+baseline/fixed, fixed/baseline, baseline/fixed order. The outer runner enforces
+the stated time/RSS ceilings, samples RSS, and preserves every database.
+
+The full commands, outer runners, source/build fingerprints, every attempted
+run, RSS samples and final JSON summaries are retained under
+`/data/workspace/codegraph-regression/review-followup/`. `artifacts/perf-summary.json`
+contains the complete-pair statistics; `artifacts/perf-manifest.json` records
+all attempts, including the rejected preflights and interrupted last run.
+The original audit artifacts and indexes remain unchanged.
+
+
+## Store eligibility cache follow-up (2026-09-15)
+
+The per-file `.getState` gate in `matchDestructuredStoreCall` now caches both
+boolean answers per resolver context, capped at 8,192 files with FIFO eviction.
+It is cleared with source caches during sync. Positive files still run all
+existing lexical, shadow, import and store-action checks. Qualified references
+and both extractors are unchanged; no diagnostic helper bypass was applied.
+
+The pre-optimization PR is `c6036f09fb1af3c5f4ae680d4ca63a0978016871`;
+implementation is `92a6c85de7050e99034a12bea1494375f5cbdab8`. Compiled-engine
+fingerprints show only `name-matcher.js` changed, with an identical native kernel.
+
+Three new real-SQLite tests pass on native and WASM: same-instance edit+sync
+in both directions, independent projects sharing relative paths, rejection of
+a same-named decoy, and retained qualified call coordinates/multiplicity.
+Deliberately removing invalidation makes the negative-to-positive case fail;
+that mutation was restored. Native focused checks passed 234/236 at normal
+limits; two Objective-C cases timed out at five seconds (also in isolation),
+then all four Objective-C assertions passed with a diagnostic 15-second
+allowance. The baseline four passed in 0.66 seconds; the optimized diagnostic
+run spent 75 seconds collecting tests. This is not a default-limit native-suite
+green verdict. WASM verified 45 affected cases: 32 initially plus 13 Steps cases
+passing in isolation after a combined-run setup timeout. No committed timeout
+or assertion was weakened. TypeScript/assets passed; the viewer build reached
+the first 240s bound, then completed separately with all 29 grammar asset checks.
+
+Full native before/after indexes on saved Excalidraw `afa3a653` have identical
+**node, edge and retained-reference contents and multiplicities**, excluding
+only update timestamps and auto-increment IDs:693 files,12,779 nodes,54,045
+edges,38,044 references. Integrity/FK/orphan/error checks pass. This preserves
+the repaired render/store relationships and the source evidence.
+
+Fresh VS Code platform timings were bounded to two reversed-order native pairs
+on the previously pinned corpus, same physical root, one CPU and worker,
+Node 24.16.0,1GiB heap/1,500MiB RSS. Three attempts reached 180s before resolution
+completed; the repeated-limit stop rule cancelled the fourth. **No whole-index
+speedup is established.** The correctness-only Excalidraw pair also varied:
+index CPU 11.90→15.87s, resolution CPU 7.71→9.57s, elapsed 14.45→170.89s, peak
+RSS 558.9→551.3MiB. Even CPU outside the changed stage rose 4.19→6.31s. This
+single pair cannot isolate a cache-caused improvement or slowdown.
+
+A bounded diagnostic replay through the actual store matcher and production
+contexts, with all 207,446 saved JS/TS call references, fresh contexts and
+reversed order, confirms the direct benefit without bypassing any helper:
+
+| Pair | Before helper CPU | Cached helper CPU | Reduction |
+| --- | ---: | ---: | ---: |
+| Before then cached |7.265s|1.046s|85.6%|
+| Cached then before |6.768s|0.990s|85.4%|
+
+Both return identical results (zero matches on this corpus). The replay's
+broader call set differs from the actual pipeline invocation set and excludes
+other resolver work, extraction, persistence and synthesis. Its 5.8–6.2s saving
+is **not** a whole-index estimate and does not establish that the earlier
+8–10% penalty or diagnostic 4.7s has been recovered in full. WASM timings and
+the full audit matrix were not repeated. Earlier Mac/parser/worker limitations
+remain.
+
+Commands, the preserved pre-change engine, scripts, all attempts, databases,
+checks and full SQL comparisons are in
+`/data/workspace/codegraph-regression/review-followup/cache-optimization/`;
+`REPORT.md` and `artifacts/summary.json` consolidate the evidence.
+
+
+## Completing the large benchmark and explaining the timeouts (2026-09-15)
+
+**All four fresh native indexes and their full database checks completed.** The
+old three stops were SIGTERM from the audit runner's 180-second wall timer, not
+CodeGraph rejecting a large project or running out of memory. Those interrupted
+artifacts are unchanged. They lack CPU/progress traces, so their precise wait
+sites cannot be reconstructed retrospectively.
+
+The completing comparison uses the same VS Code platform tree (`38246c086c8a825ca90190749dd88df6effec257`, source fingerprint
+`3d629a40f93a90d29d5aa00bd06f2a7e119b4d628f20bb449cd815d972e4ccdb`),
+3,092 supported files, Node 24.16.0, native parsing and fresh SQLite databases.
+Pre-cache engine: `c6036f09fb1af3c5f4ae680d4ca63a0978016871`; cached engine:
+`da5e6e76c908447d0abd3e6c05e11deb64984736`. Only compiled `name-matcher.js`
+differs; the native kernel is identical. All qualified-reference retention
+and the repaired matching guards are preserved.
+
+Runs were sequential, cached/before with the old one-core restrictions, then
+before/cached with both available CPUs and automatic parser/resolver sizing.
+The latter is normal **CPU** configuration; both arms still use the same 1GiB
+V8 heap cap and `--liftoff-only`. Automatic resolution correctly stays sequential
+on this two-CPU VM. Both sides use identical lightweight phase/batch/DB-call
+observers; no V8 sampling profiler, reference bypass, or runtime code edit.
+There was **no elapsed-time termination condition**. Each child was polled until
+completion, with progress, CPU, RSS, thread scheduler/wait state, pressure,
+I/O and cgroup counters saved. No other servers or locks were touched.
+
+| CPU configuration | Engine | Index elapsed | Index CPU | Resolution elapsed | Resolution CPU | Peak process RSS |
+| --- | --- | ---: | ---: | ---: | ---: | ---: |
+| One core, forced sequential | Before cache | 144.19s | 61.33s | 89.51s | 44.15s | 1003.8MiB |
+| One core, forced sequential | Cached | 133.86s | 57.58s | 79.20s | 40.43s | 1031.4MiB |
+| Two cores, automatic workers | Before cache | 126.31s | 62.73s | 74.97s | 44.84s | 1006.9MiB |
+| Two cores, automatic workers | Cached | 128.05s | 58.21s | 77.39s | 39.98s | 1037.5MiB |
+
+Index times cover `await cg.indexAll()`, including maintenance. Resolution
+includes setup, matching, persistence and synthesis. Full processes, including
+subsequent integrity/FK/orphan scans and shutdown, took 176.70/191.21/178.91/191.50s
+in execution order. In the new baseline attempts, a 180s process limit would
+have confused an already completed index with an unfinished verification.
+
+The cache saves **3.75–4.52s of whole-index CPU (6.1–7.2%)** in these pairs.
+Matching CPU falls 30.62→26.69s and 31.14→26.43s; resolution CPU falls 8.4–10.8%.
+This supports a real CPU benefit from the narrow cache. It does not establish a
+universal wall-time improvement or that every part of the original 8–10% CPU
+increase is recovered: one elapsed comparison improves 7.2%, the other worsens
+1.4%, and these are only two pairs across two CPU configurations.
+
+### What caused the long waits
+
+The reproduced delays are predominantly **disk/page waits under memory and I/O
+pressure**, not a matching loop that gets progressively more expensive:
+
+- Main-thread samples repeatedly show `D` state in `folio_wait_bit_common`,
+  `rq_qos_wait`, buffer/journal waits and block-request allocation. These are
+  kernel storage/page waits. Index maintenance has an idle, responsive main
+  event loop while its worker completes I/O; no resolver pool deadlock appears.
+- Global I/O pressure reports all runnable work stalled for 57.5–63.8% of the
+  sampled whole-process windows. This is a host metric, not an exact per-stage
+  allocation, but the indexer's own wait states directly corroborate it.
+- The VM has 3,916.6MiB total RAM. In the three runs with continuous meminfo
+  capture, available memory reaches only 158.0, 142.2 and 92.0MiB. A spot check
+  during the first completing run showed about 262MiB available. Memory-pressure
+  counters also rise. The exact source of shared memory/storage pressure is not
+  identified; no unrelated processes were modified.
+- Visible CPU quota is unlimited; throttling counters remain zero. CPU steal
+  is only 0.21–0.30% over these runs. CPU starvation is not the dominant observed
+  delay. Thread CPU/scheduler samples and responsive maintenance heartbeats
+  distinguish CPU work from waiting.
+- Across four successive groups of 18 matching batches, cached one-core median
+  CPU per batch is 365, 353, 268 and 269ms. In the two-core cached run it is 365,
+  329, 270 and 253ms. CPU work does not grow with progress. Late elapsed batches
+  can stretch while CPU remains low because the process waits for pages.
+- Setup is only 0.06–0.07s. Matching, SQLite inserts/cleanup, index rebuilding,
+  synthesis and final maintenance have separate observations. For example,
+  one-core cached matching takes 45.29s elapsed but 26.69s CPU; synthesis takes
+  15.26s elapsed/6.94s CPU, and final maintenance takes 25.04s elapsed.
+
+Three diagnostic reports were requested during long final verification gaps;
+they were delivered when the synchronous work yielded, so their JS stacks are
+empty and are not used as hotspot evidence. Kernel wait samples, batch CPU and
+phase logs are the actionable evidence. No healthy process was killed.
+
+### Correctness and benchmark repair
+
+All four runs have exactly the same 75,767 nodes, 256,523 edges and 166,516 retained
+references, including 89,157 qualified names. Every retained reference has been
+processed (`failed` denotes unresolved after attempted matching); zero remain
+pending. Full SQL `EXCEPT` comparisons in both directions, grouping complete
+rows with multiplicity, show zero additions/removals. Only node update timestamps
+and auto-increment edge/reference IDs are excluded. Ordered SHA-256 fingerprints
+also match for all three tables. Integrity checks are `ok`, with no foreign-key
+violations, orphan edges, indexing errors or missing supported files.
+
+No further resolver change was warranted by this evidence. The benchmark now
+writes phase/batch progress, cumulative CPU/RSS and event-loop measurements as
+it runs, names maintenance separately, and saves `index-result.json` **before**
+full verification. `result.json` represents completion of checks and shutdown.
+Database verification failures produce nonzero exit status. It refuses existing
+fixture indexes and reused trace files, preserving prior evidence.
+
+The portable Linux observer has no wall timeout, records the child/thread/host
+resource counters every two seconds, and leaves the caller's CPU/environment
+settings unchanged. For example, from a built checkout (all engine/fixture/output
+paths absolute; the output directory must not exist):
+
+```bash
+CODEGRAPH_KERNEL=1 CODEGRAPH_TELEMETRY=0 DO_NOT_TRACK=1 CODEGRAPH_NO_UPDATE_CHECK=1 \
+CODEGRAPH_WASM_RELAUNCHED=1 python3 scripts/benchmarks/observe-index.py /absolute/run-before -- \
+  /absolute/node --liftoff-only --max-old-space-size=1024 \
+  scripts/benchmarks/measure-index.cjs /absolute/built-before /absolute/pinned-fixture \
+  /absolute/run-before native
+```
+
+Watch `progress.ndjson` and `resources.ndjson`; inspect CPU deltas, thread wait
+states and phase progress before stopping an apparently slow child. Archive that
+run's owned `.codegraph` directory before the next fresh run. Use the same source,
+flags and observer on both sides; run sequentially. Do not treat an external
+execution deadline as a product failure or compare incomplete databases.
+
+The revised harness and observer passed actual native and WASM integration
+checks on a two-file fixture: the real cross-file call and retained qualified
+external reference exist; completed-index evidence precedes verification;
+maintenance is identified; successful observer exits are recorded; and refusal
+of an existing index leaves its SQLite bytes unchanged. JavaScript syntax,
+Python compilation and diff checks pass. Product code and compiled engines are
+unchanged by this follow-up, so prior focused resolver tests remain applicable;
+no full-suite rerun, Mac validation, or new npm release is claimed.
+
+Full commands, four complete databases, raw observations, diagnostic startup
+failure (a worker inherited the preload; fixed before the four measured runs),
+reports, graph comparisons and harness checks are retained in
+`/data/workspace/codegraph-regression/review-followup/timeout-diagnosis/`.
+`artifacts/summary.json` and `artifacts/provenance.json` consolidate the evidence.

+ 11 - 5
docs/design/telemetry.md

@@ -41,8 +41,8 @@ Answer, in aggregate and anonymously:
 1. **The schema is the allowlist.** Client sends only the events below; the ingest Worker
 1. **The schema is the allowlist.** Client sends only the events below; the ingest Worker
    validates against the same allowlist and drops anything else. Adding a field = PR that
    validates against the same allowlist and drops anything else. Adding a field = PR that
    edits this doc + `TELEMETRY.md` + the Worker allowlist together.
    edits this doc + `TELEMETRY.md` + the Worker allowlist together.
-2. **Telemetry may never cost the user anything**: zero added latency on the MCP tool-call
-   hot path (the repo's core invariant), zero new npm dependencies (global `fetch`, Node ≥18),
+2. **Telemetry may never cost the user anything**: no network requests or queue writes on the MCP tool-call
+   hot path (only a small local consent-file read), zero new npm dependencies (global `fetch`, Node ≥18),
    zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise.
    zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise.
    Every failure mode is silence.
    Every failure mode is silence.
 3. **Off is off.** When disabled, no process opens a socket to the telemetry endpoint — not
 3. **Off is off.** When disabled, no process opens a socket to the telemetry endpoint — not
@@ -53,7 +53,7 @@ Answer, in aggregate and anonymously:
 
 
 ## Events
 ## Events
 
 
-Common envelope on every batch (computed once per process):
+Common envelope on every batch (identity revalidated before each request):
 
 
 | field | example | notes |
 | field | example | notes |
 |---|---|---|
 |---|---|---|
@@ -131,7 +131,12 @@ Surfaces:
   `codegraph collects anonymous usage stats (no code or paths) — "codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: TELEMETRY.md`
   `codegraph collects anonymous usage stats (no code or paths) — "codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: TELEMETRY.md`
 - **CLI:** `codegraph telemetry status|on|off` (status prints the machine ID, current
 - **CLI:** `codegraph telemetry status|on|off` (status prints the machine ID, current
   state, and what decided it). Deleting `~/.codegraph/telemetry.json` resets everything,
   state, and what decided it). Deleting `~/.codegraph/telemetry.json` resets everything,
-  including the machine ID.
+  including the machine ID. Turning telemetry off stores a null `machine_id` and removes
+  both queued and claimed unsent data. Turning it back on mints a new ID; processes
+  discard memory from the previous identity even if they missed the off/on transition.
+  Requests already in flight cannot be recalled, but every later request chunk and
+  requeue checks current consent and identity again. Config writes use atomic replacement
+  so concurrent readers never see a half-written choice.
 
 
 `~/.codegraph/telemetry.json`:
 `~/.codegraph/telemetry.json`:
 
 
@@ -154,7 +159,8 @@ other filenames.)
 New module `src/telemetry/` (single small module, no deps):
 New module `src/telemetry/` (single small module, no deps):
 
 
 - **Counters in memory** — recording a tool call/CLI command is an in-memory increment.
 - **Counters in memory** — recording a tool call/CLI command is an in-memory increment.
-  Nothing on the hot path touches disk or network. MCP tool handlers call
+  The small consent file is refreshed before recording so another process's opt-out is
+  observed. No queue writes or network requests run on this path. MCP tool handlers call
   `telemetry.count('mcp_tool', name, ok)` and move on.
   `telemetry.count('mcp_tool', name, ok)` and move on.
 - **Buffer** — counters persist (debounced, async) to `~/.codegraph/telemetry-queue.jsonl`.
 - **Buffer** — counters persist (debounced, async) to `~/.codegraph/telemetry-queue.jsonl`.
   Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.
   Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.

+ 128 - 0
scripts/benchmarks/measure-index.cjs

@@ -0,0 +1,128 @@
+// Standalone benchmark harness; see docs/benchmarks/regression-audit-2026-09.md.
+// Arguments: built engine directory, fixture root, existing output directory, native|wasm.
+const fs = require('node:fs');
+const path = require('node:path');
+const { performance, monitorEventLoopDelay } = require('node:perf_hooks');
+const { DatabaseSync } = require('node:sqlite');
+const [engine, root, out, backend] = process.argv.slice(2);
+if (!engine || !root || !out || !['native', 'wasm'].includes(backend)) {
+  throw new Error('Usage: node measure-index.cjs BUILT_ENGINE FRESH_FIXTURE EXISTING_OUTPUT native|wasm');
+}
+if (fs.existsSync(path.join(root, '.codegraph'))) {
+  throw new Error('Refusing an existing fixture index; preserve it and use a fresh fixture.');
+}
+const begin = performance.now();
+const tracePath = path.join(out, 'progress.ndjson');
+// Exclusive creation prevents accidental reuse of a previous run's evidence.
+const traceFd = fs.openSync(tracePath, 'wx');
+let phase = 'opening';
+function progress(event, details = {}) {
+  fs.writeSync(traceFd, JSON.stringify({ event, phase, epochMs: Date.now(),
+    wallMs: performance.now() - begin, cpu: process.cpuUsage(), rss: process.memoryUsage().rss,
+    ...details }) + '\n');
+}
+const loopDelay = monitorEventLoopDelay({ resolution: 20 });
+loopDelay.enable();
+let previousLoop = performance.eventLoopUtilization();
+const heartbeat = setInterval(() => {
+  const current = performance.eventLoopUtilization();
+  progress('heartbeat', { eventLoop: performance.eventLoopUtilization(current, previousLoop),
+    delayMaxMs: loopDelay.max / 1e6 });
+  previousLoop = current;
+  loopDelay.reset();
+}, 5000);
+heartbeat.unref();
+progress('start');
+const { CodeGraph } = require(path.join(engine, 'dist/index.js'));
+const { DatabaseConnection } = require(path.join(engine, 'dist/db/index.js'));
+const loader = require(path.join(engine, 'dist/extraction/kernel/loader.js'));
+const result = { engine, root, backend, node: process.version, stages: [] };
+let cg;
+const orig = CodeGraph.prototype.resolveReferencesBatched;
+CodeGraph.prototype.resolveReferencesBatched = async function (...args) {
+  const stage = { name: 'resolution-and-synthesis', startEpochMs: Date.now(), memoryBefore: process.memoryUsage(), refsBefore: this.db.getDb().prepare('SELECT count(*) AS n FROM unresolved_refs').get().n };
+  const start = performance.now(), cpu = process.cpuUsage();
+  progress('stage-start', { name: stage.name });
+  try { const value = await orig.apply(this, args); stage.stats = value.stats; return value; }
+  finally {
+    Object.assign(stage, { endEpochMs: Date.now(), wallMs: performance.now() - start, cpu: process.cpuUsage(cpu), memoryAfter: process.memoryUsage() });
+    result.stages.push(stage);
+    progress('stage-complete', { stage });
+    phase = 'finalizing';
+  }
+};
+const origMaintenance = DatabaseConnection.prototype.runMaintenance;
+DatabaseConnection.prototype.runMaintenance = async function (...args) {
+  phase = 'maintenance';
+  const start = performance.now(), cpu = process.cpuUsage();
+  progress('stage-start', { name: 'database-maintenance' });
+  try { return await origMaintenance.apply(this, args); }
+  finally {
+    const stage = { name: 'database-maintenance', wallMs: performance.now() - start,
+      cpu: process.cpuUsage(cpu) };
+    result.stages.push(stage);
+    progress('stage-complete', { stage });
+    phase = 'finalizing';
+  }
+};
+(async () => {
+  try {
+    // getKernel() deliberately ignores CODEGRAPH_KERNEL=0; kernelSupports()
+    // is the actual per-call routing predicate used by extraction.
+    result.nativeLoaded = loader.kernelSupports('typescript');
+    if (result.nativeLoaded !== (backend === 'native')) throw new Error('Wrong extraction backend');
+    cg = CodeGraph.initSync(root);
+    result.openMs = performance.now() - begin;
+    const start = performance.now(), cpu = process.cpuUsage();
+    result.indexStartEpochMs = Date.now();
+    let lastProgress = 0;
+    phase = 'indexing';
+    progress('index-start');
+    result.index = await cg.indexAll({ onProgress: p => {
+      const now = performance.now();
+      // Preserve every resolution/synthesis batch, but avoid one disk write per
+      // scanned/parsed file. Heartbeats continue while asynchronous work waits.
+      if (phase !== p.phase || p.phase === 'resolving' || p.phase === 'linking' ||
+          now - lastProgress >= 1000 || (p.total > 0 && p.current === p.total)) {
+        phase = p.phase;
+        progress('progress', { progress: p });
+        lastProgress = now;
+      }
+    } });
+    result.indexEndEpochMs = Date.now();
+    result.indexMs = performance.now() - start;
+    result.indexCpu = process.cpuUsage(cpu);
+    progress('index-complete', { indexMs: result.indexMs, indexCpu: result.indexCpu, index: result.index });
+    // Save the completed index measurement BEFORE potentially expensive full DB
+    // checks. A stopped validation must not look like an indexing timeout.
+    fs.writeFileSync(path.join(out, 'index-result.json'), JSON.stringify(result, null, 2));
+    phase = 'verification';
+    progress('verification-start');
+    if (!result.index.success || result.index.filesErrored) throw new Error('Index did not finish cleanly');
+    const db = new DatabaseSync(path.join(root, '.codegraph/codegraph.db'), { readOnly: true });
+    result.counts = Object.fromEntries(['files','nodes','edges','unresolved_refs'].map(table => [table, db.prepare(`SELECT count(*) AS n FROM ${table}`).get().n]));
+    result.languages = db.prepare('SELECT language,count(*) AS n FROM files GROUP BY language').all();
+    result.integrity = db.prepare('PRAGMA integrity_check').all();
+    result.foreignKeys = db.prepare('PRAGMA foreign_key_check').all();
+    result.orphans = db.prepare('SELECT count(*) AS n FROM edges e LEFT JOIN nodes s ON s.id=e.source LEFT JOIN nodes t ON t.id=e.target WHERE s.id IS NULL OR t.id IS NULL').get().n;
+    db.close();
+    if (result.integrity.length !== 1 || result.integrity[0].integrity_check !== 'ok' ||
+        result.foreignKeys.length || result.orphans) throw new Error('Database verification failed');
+    progress('verification-complete');
+  } catch (e) { result.error = e.stack; process.exitCode = 1; }
+  finally {
+    phase = 'closing';
+    progress('closing');
+    try { cg?.close(); }
+    catch (e) { result.closeError = e.stack; process.exitCode = 1; }
+    result.totalInsideProcessMs = performance.now() - begin;
+    result.maxRSSKiB = process.resourceUsage().maxRSS;
+    result.finalMemory = process.memoryUsage();
+    fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2));
+    progress('complete', { error: result.error, closeError: result.closeError });
+    clearInterval(heartbeat);
+    loopDelay.disable();
+    fs.closeSync(traceFd);
+    console.log(JSON.stringify({ indexMs: result.indexMs, stages: result.stages.map(x => ({ wallMs: x.wallMs, stats: x.stats })), error: result.error }));
+  }
+})();

+ 91 - 0
scripts/benchmarks/observe-index.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Observe one owned benchmark child on Linux, without a wall-clock timeout.
+
+Usage: python3 observe-index.py NEW_RUN_DIR -- node measure-index.cjs ENGINE FIXTURE NEW_RUN_DIR native
+The child retains the caller's environment/affinity. No host settings are changed.
+"""
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import time
+
+
+def read(path):
+    try:
+        return Path(path).read_text()
+    except (FileNotFoundError, PermissionError, ProcessLookupError):
+        return None
+
+
+def sample(pid):
+    tasks = {}
+    try:
+        for task in Path(f'/proc/{pid}/task').iterdir():
+            tasks[task.name] = {name: read(task / name) for name in ('stat', 'schedstat', 'wchan')}
+    except (FileNotFoundError, ProcessLookupError):
+        pass
+    # Both common cgroup layouts; unavailable counters are recorded as null.
+    paths = [
+        '/proc/stat', '/proc/meminfo', '/proc/loadavg', '/proc/diskstats',
+        '/proc/pressure/cpu', '/proc/pressure/io', '/proc/pressure/memory',
+        '/sys/fs/cgroup/cpu.stat', '/sys/fs/cgroup/cpu.max',
+        '/sys/fs/cgroup/memory.current', '/sys/fs/cgroup/memory.max',
+        '/sys/fs/cgroup/cpu,cpuacct/cpu.stat',
+        '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us',
+        '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us',
+        '/sys/fs/cgroup/memory/memory.usage_in_bytes',
+        '/sys/fs/cgroup/memory/memory.limit_in_bytes',
+    ]
+    return {'epochMs': time.time() * 1000,
+            'process': {name: read(f'/proc/{pid}/{name}') for name in ('stat', 'status', 'io')},
+            'threads': tasks, 'host': {path: read(path) for path in paths}}
+
+
+def main():
+    if sys.platform != 'linux' or len(sys.argv) < 4 or sys.argv[2] != '--':
+        raise SystemExit(__doc__)
+    out = Path(sys.argv[1]).resolve()
+    out.mkdir(parents=True, exist_ok=False)  # Preserve completed/interrupted runs.
+    argv = sys.argv[3:]
+    command = {'argv': argv, 'startedEpochMs': time.time() * 1000,
+               'affinity': sorted(os.sched_getaffinity(0)), 'wallTimeout': None}
+    command_path = out / 'command.json'
+    command_path.write_text(json.dumps(command, indent=2))
+    start = time.monotonic()
+    with (out / 'console.log').open('w') as log, (out / 'resources.ndjson').open('w') as resources:
+        # Inherit the foreground process group: Ctrl-C reaches this owned child
+        # too. Never signal a PID discovered outside this invocation.
+        child = subprocess.Popen(argv, stdout=log, stderr=subprocess.STDOUT)
+        command['pid'] = child.pid
+        command_path.write_text(json.dumps(command, indent=2))
+        next_notice = start
+        try:
+            while child.poll() is None:
+                resources.write(json.dumps(sample(child.pid)) + '\n')
+                resources.flush()
+                now = time.monotonic()
+                if now >= next_notice:
+                    print(f'Benchmark PID {child.pid}: {now-start:.0f}s elapsed; progress in {out}', flush=True)
+                    next_notice = now + 30
+                time.sleep(2)
+        except BaseException:
+            # Record an explicit interrupted outcome, even when no final result
+            # exists. Give only our child a chance to stop, then reap it.
+            child.terminate()
+            try:
+                child.wait(timeout=10)
+            except subprocess.TimeoutExpired:
+                child.kill()
+                child.wait()
+            command['interrupted'] = True
+            raise
+        finally:
+            command.update(exit=child.poll(), wallSec=time.monotonic()-start)
+            command_path.write_text(json.dumps(command, indent=2))
+    return child.returncode
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 15 - 0
src/db/queries.ts

@@ -3526,6 +3526,21 @@ export class QueryBuilder {
     return changed;
     return changed;
   }
   }
 
 
+  /**
+   * Replace resolution edges with their original unresolved references as one
+   * transaction. If ref insertion fails, the edge deletion is rolled back.
+   */
+  replaceResolutionEdgesWithUnresolvedRefs(
+    edgeIds: number[],
+    refs: UnresolvedReference[]
+  ): number {
+    return this.db.transaction(() => {
+      const changed = this.deleteEdgesByIds(edgeIds);
+      this.insertUnresolvedRefsBatch(refs);
+      return changed;
+    })();
+  }
+
   /**
   /**
    * Distinct node names present in the given files — the symbol names a sync
    * Distinct node names present in the given files — the symbol names a sync
    * pass uses to look up retryable failed refs after those files changed.
    * pass uses to look up retryable failed refs after those files changed.

+ 190 - 50
src/extraction/index.ts

@@ -126,6 +126,8 @@ export interface SyncResult {
   nodesUpdated: number;
   nodesUpdated: number;
   durationMs: number;
   durationMs: number;
   changedFilePaths?: string[];
   changedFilePaths?: string[];
+  /** Paths not absorbed because reading or extraction failed; retain for status/retry. */
+  failedFilePaths?: string[];
   /**
   /**
    * Symbol names whose set of definitions this sync CHANGED — names the synced
    * Symbol names whose set of definitions this sync CHANGED — names the synced
    * files gained or lost, as the symmetric difference of their `file\0name`
    * files gained or lost, as the symmetric difference of their `file\0name`
@@ -1287,20 +1289,93 @@ interface GitChanges {
  * case this cannot see (the child status that would report the deletions is gone
  * case this cannot see (the child status that would report the deletions is gone
  * with it); a full `codegraph index` reconciles that.
  * with it); a full `codegraph index` reconciles that.
  */
  */
-export function getGitChangedFiles(rootDir: string): GitChanges | null {
+export function getGitChangedFiles(rootDir: string, sinceCommit?: string | null): GitChanges | null {
   try {
   try {
+    // `git status` only ever describes the WORKING TREE, so a change that has
+    // been committed leaves no entry and never enters the candidate set — the
+    // hash comparison in getChangedFiles is correct but is never reached for
+    // it, and `pendingChanges` reads 0 while the index is genuinely behind
+    // (#1829). `sinceCommit` — the commit the index was last brought up to
+    // date at — adds the other half: what has been committed since. Callers
+    // that hold no such stamp still get exactly what they always did, the
+    // working-tree changes.
     const changes: GitChanges = { modified: [], added: [], deleted: [] };
     const changes: GitChanges = { modified: [], added: [], deleted: [] };
     // Custom extension → language overrides from the project's codegraph.json,
     // Custom extension → language overrides from the project's codegraph.json,
     // so change detection sees the same custom-extension files the full index does.
     // so change detection sees the same custom-extension files the full index does.
     const overrides = loadExtensionOverrides(rootDir);
     const overrides = loadExtensionOverrides(rootDir);
-    collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir));
+    collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined);
     return changes;
     return changes;
   } catch {
   } catch {
     return null;
     return null;
   }
   }
 }
 }
 
 
-function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void {
+/**
+ * Metadata key: the commit the index was last brought up to date at. Written by
+ * a full index AND by every successful sync — unlike the extraction stamp, which
+ * a sync must not advance because it only touches a subset of files. This one is
+ * about the tree; failed file paths remain explicit retry candidates. (#1829)
+ */
+export const INDEXED_AT_COMMIT_KEY = 'indexed_at_commit';
+
+/** HEAD's commit sha, or null in a non-git repo or one with no commits yet. */
+export function getGitHeadSha(rootDir: string): string | null {
+  try {
+    return execFileSync('git', ['rev-parse', 'HEAD'], {
+      cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
+    }).trim() || null;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * NUL-delimited status/path pairs for every path committed
+ * between `sinceCommit` and HEAD. Empty when the stamp IS HEAD, which is the
+ * common case — one cheap git call on the hot path.
+ */
+function gitCommittedChangesSince(repoDir: string, sinceCommit: string): string[] {
+  // NUL framing preserves Unicode, quotes, tabs and newlines in Git paths.
+  // Let command failures reach getGitChangedFiles: [] would falsely mean clean.
+  const out = execFileSync('git', ['diff', '--relative', '--name-status', '--no-renames', '-z', sinceCommit, 'HEAD', '--', '.'], {
+    cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
+  });
+  return out.split('\0');
+}
+
+/**
+ * Can an INDEX trust the git fast path, given the commit it was built at?
+ *
+ * False means "fall back to the full scan" — the expensive path that compares
+ * every file on disk against the DB, and the only correct read when git cannot
+ * say what happened between the stamp and now:
+ *
+ *  - stamp present but unknown to this repo (rebase, gc, shallow clone, a stamp
+ *    from a different checkout) — history moved under the index.
+ *  - stamp absent while the repo HAS commits — an index built before stamping
+ *    existed. One full scan; the next sync stamps it and the fast path returns.
+ *
+ * A repo with NO commits keeps the fast path with or without a stamp: every
+ * file is untracked, so `git status` already sees all of them. Callers with no
+ * index behind them (the exported `getGitChangedFiles`) never ask this — a
+ * working-tree diff is the whole of what they wanted. (#1829)
+ */
+export function canTrustGitFastPath(rootDir: string, sinceCommit?: string | null): boolean {
+  const head = getGitHeadSha(rootDir);
+  if (head == null) return true; // no commits (or not a git repo — caller handles that)
+  if (!sinceCommit) return false;
+  if (sinceCommit === head) return true;
+  try {
+    execFileSync('git', ['cat-file', '-e', `${sinceCommit}^{commit}`], {
+      cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
+    });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null, exclude: Ignore | null = null, sinceCommit?: string): void {
   const output = execFileSync(
   const output = execFileSync(
     'git',
     'git',
     // `-uall` lists individual untracked files instead of collapsing an
     // `-uall` lists individual untracked files instead of collapsing an
@@ -1309,7 +1384,7 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
     // below). Nested untracked git repos still collapse to `?? repo/` even
     // below). Nested untracked git repos still collapse to `?? repo/` even
     // with `-uall` — git never crosses a repo boundary — so the recursion
     // with `-uall` — git never crosses a repo boundary — so the recursion
     // still handles them. (#1213)
     // still handles them. (#1213)
-    ['status', '--porcelain', '--no-renames', '-uall'],
+    ['status', '--porcelain', '--no-renames', '-z', '-uall'],
     { cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
     { cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
   );
   );
 
 
@@ -1325,46 +1400,66 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
   // parent's. (#766)
   // parent's. (#766)
   const ig = buildDefaultIgnore(repoDir);
   const ig = buildDefaultIgnore(repoDir);
 
 
-  const untrackedDirs: string[] = [];
-  for (const line of output.split('\n')) {
-    if (line.length < 4) continue; // Minimum: "XY file"
-
-    const statusCode = line.substring(0, 2);
-    const rel = normalizePath(line.substring(3));
-
-    // Untracked directory entries (trailing slash) may hide an embedded repo —
-    // collect for the recursion below instead of treating as a file.
-    if (statusCode === '??' && rel.endsWith('/')) {
-      untrackedDirs.push(rel);
-      continue;
-    }
-
+  // One classifier for both candidate sources below, so a committed change is
+  // filtered by exactly the rules a working-tree change is (#766, #999, #1829).
+  const classify = (statusCode: string, rel: string): void => {
     const filePath = normalizePath(prefix + rel);
     const filePath = normalizePath(prefix + rel);
-    if (!isSourceFile(filePath, overrides)) continue;
+    if (!isSourceFile(filePath, overrides)) return;
 
 
     if (statusCode.includes('D')) {
     if (statusCode.includes('D')) {
       // Deletions stay unfiltered: getChangedFiles acts on one only when the
       // Deletions stay unfiltered: getChangedFiles acts on one only when the
       // path is already tracked in the DB, where removal is always correct — and
       // path is already tracked in the DB, where removal is always correct — and
       // that lets a newly-excluded dir's stale rows clean themselves up. (#766)
       // that lets a newly-excluded dir's stale rows clean themselves up. (#766)
       out.deleted.push(filePath);
       out.deleted.push(filePath);
-      continue;
+      return;
     }
     }
 
 
     // Added (`??`) / modified files inside an excluded dir must not enter the
     // Added (`??`) / modified files inside an excluded dir must not enter the
     // index — match against the repo-relative path, same as the full scan. (#766)
     // index — match against the repo-relative path, same as the full scan. (#766)
-    if (ig.ignores(rel)) continue;
+    if (ig.ignores(rel)) return;
     // User `codegraph.json` `exclude` (#999) is project-root-relative, so it's
     // User `codegraph.json` `exclude` (#999) is project-root-relative, so it's
     // matched against the full path — sync must not re-add a tracked file the
     // matched against the full path — sync must not re-add a tracked file the
     // full index now keeps out. Deletions above stay unfiltered so a file that
     // full index now keeps out. Deletions above stay unfiltered so a file that
     // WAS indexed before an exclude was added still cleans itself out.
     // WAS indexed before an exclude was added still cleans itself out.
-    if (exclude && exclude.ignores(filePath)) continue;
+    if (exclude && exclude.ignores(filePath)) return;
 
 
     if (statusCode === '??') {
     if (statusCode === '??') {
       out.added.push(filePath);
       out.added.push(filePath);
     } else {
     } else {
-      // M, MM, AM, A (staged), etc. — treat as modified
+      // M, MM, AM, A (staged), etc. — treat as modified. getChangedFiles
+      // re-decides added-vs-modified from the DB, so a committed `A` that the
+      // index never saw still lands in `added`.
       out.modified.push(filePath);
       out.modified.push(filePath);
     }
     }
+  };
+
+  const untrackedDirs: string[] = [];
+  for (const line of output.split('\0')) {
+    if (line.length < 4) continue; // Minimum: "XY file"
+
+    const statusCode = line.substring(0, 2);
+    const rel = normalizePath(line.substring(3));
+
+    // Untracked directory entries (trailing slash) may hide an embedded repo —
+    // collect for the recursion below instead of treating as a file.
+    if (statusCode === '??' && rel.endsWith('/')) {
+      untrackedDirs.push(rel);
+      continue;
+    }
+
+    classify(statusCode, rel);
+  }
+
+  // Committed but unindexed: everything between the commit this index was last
+  // brought up to date at and HEAD. `git status` cannot see these — committing
+  // is precisely what removes a file from its output — so without this pass a
+  // `git commit` makes a real pending change read as zero (#1829). The stamp
+  // belongs to the ROOT repo, so the embedded-repo recursion below passes none.
+  if (sinceCommit) {
+    const fields = gitCommittedChangesSince(repoDir, sinceCommit);
+    for (let i = 0; i + 1 < fields.length; i += 2) {
+      classify(`${fields[i]!.charAt(0)} `, normalizePath(fields[i + 1]!));
+    }
   }
   }
 
 
   // Recurse embedded repos found under untracked dirs (at the dir itself or
   // Recurse embedded repos found under untracked dirs (at the dir itself or
@@ -2906,8 +3001,7 @@ export class ExtractionOrchestrator {
     // rebind to the same target is a clean no-op, but leaving the old row in
     // rebind to the same target is a clean no-op, but leaving the old row in
     // place for a rebind ELSEWHERE would keep both, turning drift into
     // place for a rebind ELSEWHERE would keep both, turning drift into
     // duplication.
     // duplication.
-    this.queries.deleteEdgesByIds(edgeIds);
-    this.queries.insertUnresolvedRefsBatch(refs);
+    this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
     return refs.length;
     return refs.length;
   }
   }
 
 
@@ -2958,6 +3052,7 @@ export class ExtractionOrchestrator {
     });
     });
 
 
     const filesToIndex: string[] = [];
     const filesToIndex: string[] = [];
+    const failedFilePaths: string[] = [];
     // === Filesystem reconcile (git-independent) ===
     // === Filesystem reconcile (git-independent) ===
     // The source of truth for "what changed" is the filesystem vs the indexed
     // The source of truth for "what changed" is the filesystem vs the indexed
     // state — never git. We enumerate the current source files and reconcile
     // state — never git. We enumerate the current source files and reconcile
@@ -3083,6 +3178,7 @@ export class ExtractionOrchestrator {
           }
           }
         } catch (error) {
         } catch (error) {
           logDebug('Skipping unstattable file during sync', { filePath, error: String(error) });
           logDebug('Skipping unstattable file during sync', { filePath, error: String(error) });
+          failedFilePaths.push(filePath);
           continue;
           continue;
         }
         }
       }
       }
@@ -3093,6 +3189,7 @@ export class ExtractionOrchestrator {
         content = fs.readFileSync(fullPath, 'utf-8');
         content = fs.readFileSync(fullPath, 'utf-8');
       } catch (error) {
       } catch (error) {
         logDebug('Skipping unreadable file during sync', { filePath, error: String(error) });
         logDebug('Skipping unreadable file during sync', { filePath, error: String(error) });
+        failedFilePaths.push(filePath);
         continue;
         continue;
       }
       }
       const contentHash = hashContent(content);
       const contentHash = hashContent(content);
@@ -3134,6 +3231,7 @@ export class ExtractionOrchestrator {
       });
       });
 
 
       const result = await this.indexFile(filePath);
       const result = await this.indexFile(filePath);
+      if (result.errors.some(e => e.severity === 'error')) failedFilePaths.push(filePath);
       nodesUpdated += result.nodes.length;
       nodesUpdated += result.nodes.length;
 
 
       const pause = backpressure?.();
       const pause = backpressure?.();
@@ -3167,16 +3265,67 @@ export class ExtractionOrchestrator {
       nodesUpdated,
       nodesUpdated,
       durationMs: Date.now() - startTime,
       durationMs: Date.now() - startTime,
       changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined,
       changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined,
+      ...(failedFilePaths.length > 0 ? { failedFilePaths } : {}),
       definitionDelta: definitionDelta.length > 0 ? definitionDelta : undefined,
       definitionDelta: definitionDelta.length > 0 ? definitionDelta : undefined,
     };
     };
   }
   }
 
 
+  private indexedDirtyPaths(stamp: string | null): string[] | null {
+    try {
+      const state = JSON.parse(this.queries.getMetadata('indexed_dirty_paths') ?? 'null');
+      if (!state || state.commit !== (stamp ?? '') || !Array.isArray(state.paths)) return null;
+      if (!state.paths.every((p: unknown) => typeof p === 'string' && p.length > 0 &&
+        !path.isAbsolute(p) && !p.split('/').includes('..'))) return null;
+      return state.paths;
+    } catch { return null; }
+  }
+
+  /** Capture before file reads. In-flight/failed full writes must not claim freshness. */
+  beginGitIndexState(full: boolean): { head: string; stamp: string; dirty: string[] | null } {
+    const head = getGitHeadSha(this.rootDir) ?? '';
+    const stamp = this.queries.getMetadata(INDEXED_AT_COMMIT_KEY) ?? '';
+    const prior = this.indexedDirtyPaths(stamp);
+    const status = getGitChangedFiles(this.rootDir);
+    const dirty = status ? [...new Set([
+      ...(full ? [] : prior ?? []), ...status.added, ...status.modified, ...status.deleted,
+    ])] : null;
+    if (full || prior === null || dirty === null) {
+      this.queries.setMetadata(INDEXED_AT_COMMIT_KEY, '');
+      this.queries.setMetadata('indexed_dirty_paths', '');
+    } else {
+      // Scoped writes leave the commit alone and retain dirty paths before the
+      // first write, so a crash cannot forget an indexed uncommitted edit.
+      this.queries.setMetadata('indexed_dirty_paths', JSON.stringify({ commit: stamp, paths: dirty }));
+    }
+    return { head, stamp, dirty };
+  }
+
+  finishGitIndexState(snapshot: { head: string; stamp: string; dirty: string[] | null }, full: boolean, retries: string[] = []): void {
+    const after = getGitChangedFiles(this.rootDir);
+    if (!snapshot.dirty || !after) return;
+    const commit = full ? snapshot.head : snapshot.stamp;
+    const paths = [...new Set([...snapshot.dirty, ...after.added, ...after.modified, ...after.deleted, ...retries])].sort();
+    // The embedded commit makes a torn pair fail closed: readers reject a dirty
+    // set that doesn't match the separately stored commit. Write the set first.
+    this.queries.setMetadata('indexed_dirty_paths', JSON.stringify({ commit, paths }));
+    if (full) this.queries.setMetadata(INDEXED_AT_COMMIT_KEY, commit);
+  }
+
   /**
   /**
    * Get files that have changed since last index.
    * Get files that have changed since last index.
    * Uses git status as a fast path when available, falling back to full scan.
    * Uses git status as a fast path when available, falling back to full scan.
    */
    */
   getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
   getChangedFiles(): { added: string[]; modified: string[]; removed: string[] } {
-    const gitChanges = getGitChangedFiles(this.rootDir);
+    // The commit this index was last brought up to date at. Absent on an index
+    // built before stamping existed — getGitChangedFiles then declines the fast
+    // path and the full scan below answers correctly, once, until a sync or a
+    // full index writes the stamp. (#1829)
+    let sinceCommit: string | null = null;
+    try { sinceCommit = this.queries.getMetadata(INDEXED_AT_COMMIT_KEY) ?? null; } catch { /* advisory */ }
+    const dirtyPaths = this.indexedDirtyPaths(sinceCommit);
+    const gitChanges = dirtyPaths !== null && canTrustGitFastPath(this.rootDir, sinceCommit)
+      ? getGitChangedFiles(this.rootDir, sinceCommit)
+      : null;
 
 
     if (gitChanges) {
     if (gitChanges) {
       // === Git fast path ===
       // === Git fast path ===
@@ -3184,36 +3333,27 @@ export class ExtractionOrchestrator {
       const modified: string[] = [];
       const modified: string[] = [];
       const removed: string[] = [];
       const removed: string[] = [];
 
 
-      // Deleted files — only report if tracked in DB
-      for (const filePath of gitChanges.deleted) {
+      // Git supplies candidates, never the verdict. A committed deletion may
+      // have been recreated locally; a previously indexed dirty path may have
+      // vanished from git status after restore. Classify current disk vs DB once.
+      const candidates = new Set([...gitChanges.deleted, ...gitChanges.modified, ...gitChanges.added, ...dirtyPaths!]);
+      const scope = this.scopedSyncMatcher();
+      const overrides = loadExtensionOverrides(this.rootDir);
+      for (const filePath of candidates) {
         const tracked = this.queries.getFileByPath(filePath);
         const tracked = this.queries.getFileByPath(filePath);
-        if (tracked) {
-          removed.push(filePath);
-        }
-      }
-
-      // Modified + added files — read + hash, compare with DB. Untracked (`??`)
-      // files stay untracked in git even after indexing, so they must be
-      // hash-compared like modified files instead of always counting as added —
-      // otherwise status reports them as pending forever. (See issue #206.)
-      for (const filePath of [...gitChanges.modified, ...gitChanges.added]) {
         const fullPath = path.join(this.rootDir, filePath);
         const fullPath = path.join(this.rootDir, filePath);
+        if (!isSourceFile(filePath, overrides) || scope.ignores(filePath) || !fs.existsSync(fullPath)) {
+          if (tracked) removed.push(filePath);
+          continue;
+        }
         let content: string;
         let content: string;
-        try {
-          content = fs.readFileSync(fullPath, 'utf-8');
-        } catch (error) {
+        try { content = fs.readFileSync(fullPath, 'utf-8'); }
+        catch (error) {
           logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) });
           logDebug('Skipping unreadable file while detecting changes', { filePath, error: String(error) });
           continue;
           continue;
         }
         }
-
-        const contentHash = hashContent(content);
-        const tracked = this.queries.getFileByPath(filePath);
-
-        if (!tracked) {
-          added.push(filePath);
-        } else if (tracked.contentHash !== contentHash) {
-          modified.push(filePath);
-        }
+        if (!tracked) added.push(filePath);
+        else if (tracked.contentHash !== hashContent(content)) modified.push(filePath);
       }
       }
 
 
       return { added, modified, removed };
       return { added, modified, removed };

+ 11 - 0
src/extraction/tree-sitter.ts

@@ -4709,6 +4709,17 @@ export class TreeSitterExtractor {
               } else {
               } else {
                 calleeName = methodName;
                 calleeName = methodName;
               }
               }
+            } else if (this.language === 'rust' && receiver && receiver.type === 'self') {
+              // Rust `self.method()`. Keep the `self.` prefix, exactly as the
+              // field shape below does (#1585): the resolver reads the owner
+              // off the CALLING method's qualified name and resolves the
+              // method on that type. Collapsing to the bare method name handed
+              // the resolver a name with no owner, which it then matched among
+              // all same-named methods by file proximity — so `self.reset()`
+              // inside `impl Target` landed on a `Decoy::reset` that happened
+              // to sit nearer, with nothing in the edge to show it was a guess
+              // (#1861). Mirrored in the kernel's extract_call (rustlang.rs).
+              calleeName = `self.${methodName}`;
             } else if (
             } else if (
               this.language === 'rust' &&
               this.language === 'rust' &&
               receiver &&
               receiver &&

+ 11 - 1
src/index.ts

@@ -517,6 +517,7 @@ export class CodeGraph {
         walValve.start();
         walValve.start();
       }
       }
       try {
       try {
+        const gitState = this.orchestrator.beginGitIndexState(true);
         const before = this.queries.getNodeAndEdgeCount();
         const before = this.queries.getNodeAndEdgeCount();
         // Mark the index as in-flight BEFORE any writes: a run killed
         // Mark the index as in-flight BEFORE any writes: a run killed
         // mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
         // mid-index (OOM, SIGKILL, the #850 liveness watchdog) leaves this
@@ -695,6 +696,11 @@ export class CodeGraph {
           } catch { /* metadata is advisory — never fail an index over it */ }
           } catch { /* metadata is advisory — never fail an index over it */ }
         }
         }
 
 
+        if (result.success && result.filesErrored === 0 &&
+          (result.filesDiscovered === undefined || result.filesIndexed + result.filesSkipped >= result.filesDiscovered)) {
+          this.orchestrator.finishGitIndexState(gitState, true);
+        }
+
         // Reconcile the scan's ground truth against what the pipeline
         // Reconcile the scan's ground truth against what the pipeline
         // accounted for. A shortfall means files were silently dropped
         // accounted for. A shortfall means files were silently dropped
         // (observed in the wild: a run under heavy load came up 37 files
         // (observed in the wild: a run under heavy load came up 37 files
@@ -822,6 +828,9 @@ export class CodeGraph {
         // timer-driven PASSIVE checkpoints ran, and a query-pool reader could
         // timer-driven PASSIVE checkpoints ran, and a query-pool reader could
         // pin frames while the WAL grew without a bound.
         // pin frames while the WAL grew without a bound.
         const backpressure = walValve ? () => walValve!.backpressure() : undefined;
         const backpressure = walValve ? () => walValve!.backpressure() : undefined;
+        const fullReconcile = !options.paths || options.paths.length === 0;
+        const gitState = this.orchestrator.beginGitIndexState(fullReconcile);
+
         const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
         const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure);
 
 
         // Fold the store phase's WAL BEFORE the post-store reads below
         // Fold the store phase's WAL BEFORE the post-store reads below
@@ -1023,11 +1032,12 @@ export class CodeGraph {
         // A killed full index leaves this marker at `indexing`. Sync repairs
         // A killed full index leaves this marker at `indexing`. Sync repairs
         // missing files, pending refs, and (on open) dropped indexes, so a
         // missing files, pending refs, and (on open) dropped indexes, so a
         // successful recovery must also close the metadata state (#1556).
         // successful recovery must also close the metadata state (#1556).
-        const fullReconcile = !options.paths || options.paths.length === 0;
         if (fullReconcile && this.getIndexState() === 'indexing') {
         if (fullReconcile && this.getIndexState() === 'indexing') {
           try { this.queries.setMetadata('index_state', 'complete'); } catch { /* advisory */ }
           try { this.queries.setMetadata('index_state', 'complete'); } catch { /* advisory */ }
         }
         }
 
 
+        this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);
+
         return result;
         return result;
       } finally {
       } finally {
         // Mirror indexAll's teardown: stop the valve, then restore the
         // Mirror indexAll's teardown: stop the valve, then restore the

+ 14 - 0
src/mcp/daemon-manager.ts

@@ -109,6 +109,20 @@ export async function runDaemonPicker(deps: PickerDeps): Promise<void> {
     }
     }
 
 
     const result = await deps.stop(String(choice));
     const result = await deps.stop(String(choice));
+    if (result.outcome === 'unverified') {
+      deps.note(
+        `Could not verify daemon (pid ${result.pid}); left it running with its artifacts intact — ${choice}`
+      );
+      continue;
+    }
+    if (result.outcome === 'not-running') {
+      deps.note(`Daemon was no longer running; removed stale artifacts — ${choice}`);
+      continue;
+    }
+    if (result.outcome === 'no-daemon') {
+      deps.note(`No daemon was found — ${choice}`);
+      continue;
+    }
     const forced = result.outcome === 'kill' ? ', forced' : '';
     const forced = result.outcome === 'kill' ? ', forced' : '';
     deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
     deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
     // Loop: the next iteration re-lists; if more remain it re-prompts, otherwise
     // Loop: the next iteration re-lists; if more remain it re-prompts, otherwise

+ 13 - 3
src/mcp/daemon-paths.ts

@@ -102,13 +102,23 @@ export interface DaemonLockInfo {
   startedAt: number;
   startedAt: number;
 }
 }
 
 
+/** Whether a lock record contains enough identity data for a socket hello. */
+export function canProbeDaemonIdentity(info: DaemonLockInfo): boolean {
+  return (
+    Number.isInteger(info.pid) &&
+    info.pid > 0 &&
+    typeof info.socketPath === 'string' &&
+    info.socketPath.length > 0
+  );
+}
+
 /**
 /**
  * Verify that the process named by a lockfile is the CodeGraph daemon serving
  * Verify that the process named by a lockfile is the CodeGraph daemon serving
  * its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs
  * its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs
  * after an OOM/SIGKILL (#1553).
  * after an OOM/SIGKILL (#1553).
  */
  */
 export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000): Promise<boolean> {
 export function probeDaemonIdentity(info: DaemonLockInfo, timeoutMs = 1_000): Promise<boolean> {
-  if (!Number.isInteger(info.pid) || info.pid <= 0 || !info.socketPath) return Promise.resolve(false);
+  if (!canProbeDaemonIdentity(info)) return Promise.resolve(false);
   return new Promise<boolean>((resolve) => {
   return new Promise<boolean>((resolve) => {
     let socket: net.Socket;
     let socket: net.Socket;
     let buffer = '';
     let buffer = '';
@@ -178,12 +188,12 @@ export function decodeLockInfo(raw: string): DaemonLockInfo | null {
     ) {
     ) {
       return parsed as DaemonLockInfo;
       return parsed as DaemonLockInfo;
     }
     }
-    return null;
   } catch {
   } catch {
     // Fall through to legacy plain-pid handling.
     // Fall through to legacy plain-pid handling.
   }
   }
+  if (!/^[1-9]\d*$/.test(trimmed)) return null;
   const pid = Number(trimmed);
   const pid = Number(trimmed);
-  if (Number.isFinite(pid) && pid > 0) {
+  if (Number.isSafeInteger(pid)) {
     return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
     return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
   }
   }
   return null;
   return null;

+ 65 - 24
src/mcp/daemon-registry.ts

@@ -26,9 +26,11 @@ import {
   getDaemonPidPath,
   getDaemonPidPath,
   getDaemonSocketCandidates,
   getDaemonSocketCandidates,
   decodeLockInfo,
   decodeLockInfo,
+  canProbeDaemonIdentity,
   probeDaemonIdentity,
   probeDaemonIdentity,
   type DaemonLockInfo,
   type DaemonLockInfo,
 } from './daemon-paths';
 } from './daemon-paths';
+import { readWriterLock, releaseWriterLock, tryAcquireWriterLock } from './writer-lock';
 
 
 export interface DaemonRecord {
 export interface DaemonRecord {
   /** Realpath'd project root the daemon serves. */
   /** Realpath'd project root the daemon serves. */
@@ -140,18 +142,45 @@ export async function listVerifiedDaemons(opts: { prune?: boolean } = {}): Promi
   return verified;
   return verified;
 }
 }
 
 
-/** Remove a stopped daemon's leftover lockfile + socket + registry record. */
-function cleanupDaemonArtifacts(root: string): void {
-  try { fs.unlinkSync(getDaemonPidPath(root)); } catch { /* gone */ }
-  // POSIX sockets are real files; Windows named pipes vanish with the process.
-  // Sweep every candidate — a daemon that relocated past an unusable in-project
-  // FS (ExFAT/FAT; #997) left its socket at the tmpdir fallback, not candidate 0.
-  if (process.platform !== 'win32') {
-    for (const candidate of getDaemonSocketCandidates(root)) {
-      try { fs.unlinkSync(candidate); } catch { /* gone */ }
+/** Remove stale artifacts while holding the project writer slot exclusively. */
+function cleanupDaemonArtifacts(
+  root: string,
+  expectedLockContents: string | null,
+): boolean {
+  const pidPath = getDaemonPidPath(root);
+  // A daemon owns writer.pid before binding or relocating its socket. Claiming
+  // the writer slot therefore freezes every legitimate daemon artifact writer
+  // while we compare the inspected lock snapshot and clean it up.
+  if (readWriterLock(root)?.pid === process.pid) return false;
+  const claim = tryAcquireWriterLock(root, 'cleanup');
+  if (claim.kind === 'taken') return false;
+
+  try {
+    if (expectedLockContents === null) {
+      if (fs.existsSync(pidPath)) return false;
+    } else {
+      try {
+        if (fs.readFileSync(pidPath, 'utf8') !== expectedLockContents) return false;
+      } catch {
+        return false;
+      }
+    }
+    // POSIX sockets are real files; Windows named pipes vanish with the process.
+    // Sweep every candidate before releasing daemon.pid, so no successor can
+    // acquire the lock and bind a socket that this cleanup then removes.
+    if (process.platform !== 'win32') {
+      for (const candidate of getDaemonSocketCandidates(root)) {
+        try { fs.unlinkSync(candidate); } catch { /* gone */ }
+      }
+    }
+    deregisterDaemon(root);
+    try { fs.unlinkSync(pidPath); } catch (err) {
+      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false;
     }
     }
+    return true;
+  } finally {
+    releaseWriterLock(root);
   }
   }
-  deregisterDaemon(root);
 }
 }
 
 
 /** Remove daemon artifacts only when no matching daemon answers the socket hello. */
 /** Remove daemon artifacts only when no matching daemon answers the socket hello. */
@@ -162,10 +191,17 @@ export async function clearStaleDaemonArtifacts(root: string): Promise<boolean>
   );
   );
   if (!hadArtifacts) return false;
   if (!hadArtifacts) return false;
   let info: DaemonLockInfo | null = null;
   let info: DaemonLockInfo | null = null;
-  try { info = decodeLockInfo(fs.readFileSync(pidPath, 'utf8')); } catch { /* missing/corrupt */ }
-  if (info && isProcessAlive(info.pid) && await probeDaemonIdentity(info)) return false;
-  cleanupDaemonArtifacts(root);
-  return true;
+  let lockContents: string | null = null;
+  try {
+    lockContents = fs.readFileSync(pidPath, 'utf8');
+    info = decodeLockInfo(lockContents);
+  } catch { /* missing/corrupt */ }
+  if (info && isProcessAlive(info.pid)) {
+    // A live legacy holder has no socket path to probe. That is inconclusive,
+    // not proof of PID reuse, so preserve its lock rather than risk two writers.
+    if (!canProbeDaemonIdentity(info) || await probeDaemonIdentity(info)) return false;
+  }
+  return cleanupDaemonArtifacts(root, lockContents);
 }
 }
 
 
 const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
 const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -182,8 +218,8 @@ async function waitForDeath(pid: number, timeoutMs: number): Promise<boolean> {
 export interface StopResult {
 export interface StopResult {
   root: string;
   root: string;
   pid: number | null;
   pid: number | null;
-  /** 'term' graceful, 'kill' force, 'not-running' stale lock, 'no-daemon' none found. */
-  outcome: 'term' | 'kill' | 'not-running' | 'no-daemon';
+  /** 'term' graceful, 'kill' force, 'not-running' stale, 'no-daemon' absent, 'unverified' preserved. */
+  outcome: 'term' | 'kill' | 'not-running' | 'no-daemon' | 'unverified';
 }
 }
 
 
 /**
 /**
@@ -195,8 +231,10 @@ export interface StopResult {
 export async function stopDaemonAt(root: string): Promise<StopResult> {
 export async function stopDaemonAt(root: string): Promise<StopResult> {
   let pid: number | null = null;
   let pid: number | null = null;
   let identity: DaemonLockInfo | null = null;
   let identity: DaemonLockInfo | null = null;
+  let lockContents: string | null = null;
   try {
   try {
-    identity = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8'));
+    lockContents = fs.readFileSync(getDaemonPidPath(root), 'utf8');
+    identity = decodeLockInfo(lockContents);
     pid = identity?.pid ?? null;
     pid = identity?.pid ?? null;
   } catch {
   } catch {
     /* no lockfile */
     /* no lockfile */
@@ -210,18 +248,21 @@ export async function stopDaemonAt(root: string): Promise<StopResult> {
   }
   }
 
 
   if (pid == null) {
   if (pid == null) {
-    cleanupDaemonArtifacts(root);
+    cleanupDaemonArtifacts(root, lockContents);
     return { root, pid: null, outcome: 'no-daemon' };
     return { root, pid: null, outcome: 'no-daemon' };
   }
   }
   if (!isProcessAlive(pid)) {
   if (!isProcessAlive(pid)) {
-    cleanupDaemonArtifacts(root);
-    return { root, pid, outcome: 'not-running' };
+    const removed = cleanupDaemonArtifacts(root, lockContents);
+    return { root, pid, outcome: removed ? 'not-running' : 'unverified' };
   }
   }
   // Never signal a process merely because it reused a stale daemon PID. The
   // Never signal a process merely because it reused a stale daemon PID. The
   // daemon's immediate hello is the process-identity proof (#1553).
   // daemon's immediate hello is the process-identity proof (#1553).
-  if (!identity || !await probeDaemonIdentity(identity)) {
-    cleanupDaemonArtifacts(root);
-    return { root, pid, outcome: 'not-running' };
+  if (!identity || !canProbeDaemonIdentity(identity)) {
+    return { root, pid, outcome: 'unverified' };
+  }
+  if (!await probeDaemonIdentity(identity)) {
+    const removed = cleanupDaemonArtifacts(root, lockContents);
+    return { root, pid, outcome: removed ? 'not-running' : 'unverified' };
   }
   }
 
 
   // POSIX: SIGTERM runs the daemon's graceful shutdown. Windows: TerminateProcess
   // POSIX: SIGTERM runs the daemon's graceful shutdown. Windows: TerminateProcess
@@ -233,7 +274,7 @@ export async function stopDaemonAt(root: string): Promise<StopResult> {
     await waitForDeath(pid, 2000);
     await waitForDeath(pid, 2000);
     outcome = 'kill';
     outcome = 'kill';
   }
   }
-  cleanupDaemonArtifacts(root);
+  cleanupDaemonArtifacts(root, lockContents);
   return { root, pid, outcome };
   return { root, pid, outcome };
 }
 }
 
 

+ 55 - 27
src/mcp/daemon.ts

@@ -55,7 +55,11 @@ import {
   getDaemonSocketPath,
   getDaemonSocketPath,
 } from './daemon-paths';
 } from './daemon-paths';
 import { CodeGraphPackageVersion } from './version';
 import { CodeGraphPackageVersion } from './version';
-import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
+import {
+  releaseWriterLock,
+  tryAcquireWriterLock,
+  writerLockHeldMessage,
+} from './writer-lock';
 import { registerDaemon, deregisterDaemon } from './daemon-registry';
 import { registerDaemon, deregisterDaemon } from './daemon-registry';
 
 
 /** Default idle linger after the last client disconnects. */
 /** Default idle linger after the last client disconnects. */
@@ -161,9 +165,8 @@ export interface DaemonStartResult {
  *
  *
  * Race-safe: callers must first call `tryAcquireDaemonLock(projectRoot)` and
  * Race-safe: callers must first call `tryAcquireDaemonLock(projectRoot)` and
  * only construct a Daemon if they got the lock (`kind: 'acquired'`). The atomic
  * only construct a Daemon if they got the lock (`kind: 'acquired'`). The atomic
- * `O_EXCL` create inside the acquire helper — which now also writes the full
- * record before returning — is the only synchronization between competing
- * daemons.
+ * create/link inside the acquire helper elects one candidate. The project
+ * writer lock then fences bind/ownership refresh against stale-artifact cleanup.
  */
  */
 export class Daemon {
 export class Daemon {
   private server: net.Server | null = null;
   private server: net.Server | null = null;
@@ -199,10 +202,9 @@ export class Daemon {
   }
   }
 
 
   /**
   /**
-   * Bind the socket, kick off engine init, and register signal handlers. The
-   * lockfile body was already written atomically by `tryAcquireDaemonLock`, so
-   * there is nothing to write here. The promise resolves once the server is
-   * listening — the daemon then sticks around until idle/shutdown.
+   * Bind the socket, refresh the ownership record, kick off engine init, and
+   * register signal handlers. The promise resolves once the server is listening
+   * — the daemon then sticks around until idle/shutdown.
    */
    */
   async start(): Promise<DaemonStartResult> {
   async start(): Promise<DaemonStartResult> {
     // #1740: claim the project writer lock before opening/watching so a
     // #1740: claim the project writer lock before opening/watching so a
@@ -215,10 +217,16 @@ export class Daemon {
       throw new Error(msg);
       throw new Error(msg);
     }
     }
 
 
-    // Engine init is deliberately backgrounded — see #172. The first session
-    // to land waits on `ensureInitialized` either way, and unloaded sessions
-    // (cross-project tool calls only) shouldn't pay any open cost.
-    void this.engine.ensureInitialized(this.projectRoot);
+    let initialLockContents: string;
+    try {
+      initialLockContents = fs.readFileSync(this.pidPath, 'utf8');
+      if (decodeLockInfo(initialLockContents)?.pid !== process.pid) {
+        throw new Error('daemon lock belongs to another process');
+      }
+    } catch {
+      releaseWriterLock(this.projectRoot);
+      throw new Error('Lost daemon lock ownership before startup.');
+    }
 
 
     // Walk the ordered socket candidates and bind the first that works. The
     // Walk the ordered socket candidates and bind the first that works. The
     // in-project path comes first; the deterministic tmpdir path is the fallback
     // in-project path comes first; the deterministic tmpdir path is the fallback
@@ -282,19 +290,27 @@ export class Daemon {
       startedAt: Date.now(),
       startedAt: Date.now(),
     };
     };
 
 
-    // `tryAcquireDaemonLock` wrote the pidfile with the PREFERRED path (candidate
-    // 0) before we knew which one would bind. If we relocated, rewrite it so the
-    // per-project record is honest. Atomic temp+rename; safe because we hold the
-    // lock and we're alive — `clearStaleDaemonLock` pid-verifies, so no racing
-    // candidate clears or clobbers a live daemon's lock.
-    if (this.socketPath !== candidates[0]) {
-      try {
-        const tmpPid = `${this.pidPath}.${process.pid}.relocate`;
-        fs.writeFileSync(tmpPid, encodeLockInfo(lock), { mode: 0o600 });
-        fs.renameSync(tmpPid, this.pidPath);
-      } catch { /* best-effort; the registry record below carries the real path */ }
+    // Refresh the lock on every successful bind, not only relocation. The
+    // writer lock prevents stale-artifact cleanup from racing this ownership
+    // check, and the exact snapshot prevents overwriting a replacement record.
+    try {
+      if (fs.readFileSync(this.pidPath, 'utf8') !== initialLockContents) {
+        throw new Error('Lost daemon lock ownership after binding.');
+      }
+      const tmpPid = `${this.pidPath}.${process.pid}.bound`;
+      fs.writeFileSync(tmpPid, encodeLockInfo(lock), { mode: 0o600 });
+      fs.renameSync(tmpPid, this.pidPath);
+    } catch (err) {
+      try { bound.server.close(); } catch { /* best-effort */ }
+      this.cleanupLockfile();
+      throw err;
     }
     }
 
 
+    // Engine init is deliberately backgrounded — see #172. It starts only
+    // after bind and ownership refresh, so a delayed daemon that lost election
+    // can never open a second watcher or writer.
+    void this.engine.ensureInitialized(this.projectRoot);
+
     // Drop a discovery record so `codegraph list` / `stop --all` can find us.
     // Drop a discovery record so `codegraph list` / `stop --all` can find us.
     // Best-effort; a missing record only means list's liveness prune covers it.
     // Best-effort; a missing record only means list's liveness prune covers it.
     registerDaemon({ root: this.projectRoot, ...lock });
     registerDaemon({ root: this.projectRoot, ...lock });
@@ -531,7 +547,13 @@ export class Daemon {
  */
  */
 export type AcquireResult =
 export type AcquireResult =
   | { kind: 'acquired'; pidPath: string; info: DaemonLockInfo }
   | { kind: 'acquired'; pidPath: string; info: DaemonLockInfo }
-  | { kind: 'taken'; existing: DaemonLockInfo | null; pidPath: string };
+  | {
+      kind: 'taken';
+      existing: DaemonLockInfo | null;
+      /** Exact record read after losing acquisition; null when it was unreadable. */
+      lockContents: string | null;
+      pidPath: string;
+    };
 
 
 /**
 /**
  * Atomically create the daemon pidfile with its full record already in place.
  * Atomically create the daemon pidfile with its full record already in place.
@@ -609,10 +631,12 @@ export function tryAcquireDaemonLock(projectRoot: string): AcquireResult {
   // record — `existing` is null only for a genuinely corrupt leftover, never a
   // record — `existing` is null only for a genuinely corrupt leftover, never a
   // mid-write race.
   // mid-write race.
   let existing: DaemonLockInfo | null = null;
   let existing: DaemonLockInfo | null = null;
+  let lockContents: string | null = null;
   try {
   try {
-    existing = decodeLockInfo(fs.readFileSync(pidPath, 'utf8'));
+    lockContents = fs.readFileSync(pidPath, 'utf8');
+    existing = decodeLockInfo(lockContents);
   } catch { /* unreadable lockfile — treat as malformed */ }
   } catch { /* unreadable lockfile — treat as malformed */ }
-  return { kind: 'taken', existing, pidPath };
+  return { kind: 'taken', existing, lockContents, pidPath };
 }
 }
 
 
 /**
 /**
@@ -655,10 +679,14 @@ export function acquireLockViaExclusiveOpen(pidPath: string, info: DaemonLockInf
 export function clearStaleDaemonLock(
 export function clearStaleDaemonLock(
   pidPath: string,
   pidPath: string,
   expectedDeadPid?: number,
   expectedDeadPid?: number,
-  opts: { allowLivePid?: boolean } = {}
+  opts: { allowLivePid?: boolean; expectedLockContents?: string } = {}
 ): boolean {
 ): boolean {
   try {
   try {
     const raw = fs.readFileSync(pidPath, 'utf8');
     const raw = fs.readFileSync(pidPath, 'utf8');
+    // The identity record changed after the caller inspected it. Even the same
+    // PID may now advertise a newly-bound socket, so this snapshot was never
+    // disproved and must not be deleted.
+    if (opts.expectedLockContents !== undefined && raw !== opts.expectedLockContents) return false;
     const info = decodeLockInfo(raw);
     const info = decodeLockInfo(raw);
     if (info) {
     if (info) {
       // A different pid took over since we read it — not ours to clear.
       // A different pid took over since we read it — not ours to clear.

+ 14 - 1
src/mcp/engine.ts

@@ -47,6 +47,12 @@ export interface MCPEngineOptions {
    * disables it even in daemon mode.
    * disables it even in daemon mode.
    */
    */
   queryPool?: boolean;
   queryPool?: boolean;
+  /**
+   * Project root whose writer slot must be claimed synchronously before this
+   * engine can open the graph. Used by proxy fallback to fence catch-up sync,
+   * not just the later file watcher.
+   */
+  writerLockRoot?: string;
 }
 }
 
 
 /**
 /**
@@ -70,7 +76,7 @@ export class MCPEngine {
   private watcherStarted = false;
   private watcherStarted = false;
   /** Set when this engine holds writer.pid (#1740). */
   /** Set when this engine holds writer.pid (#1740). */
   private writerLockRoot: string | null = null;
   private writerLockRoot: string | null = null;
-  private opts: Required<MCPEngineOptions>;
+  private opts: Required<Omit<MCPEngineOptions, 'writerLockRoot'>>;
   private closed = false;
   private closed = false;
   // Off-loop read-tool pool (daemon mode only). Created lazily once the default
   // Off-loop read-tool pool (daemon mode only). Created lazily once the default
   // project is open — workers each hold their own WAL read connection.
   // project is open — workers each hold their own WAL read connection.
@@ -79,6 +85,13 @@ export class MCPEngine {
   constructor(opts: MCPEngineOptions = {}) {
   constructor(opts: MCPEngineOptions = {}) {
     this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
     this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
     this.toolHandler = new ToolHandler(null);
     this.toolHandler = new ToolHandler(null);
+    if (opts.writerLockRoot) {
+      const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback');
+      if (writer.kind === 'taken') {
+        throw new Error(writerLockHeldMessage(writer.existing, writer.pidPath));
+      }
+      this.writerLockRoot = opts.writerLockRoot;
+    }
   }
   }
 
 
   /**
   /**

+ 77 - 8
src/mcp/index.ts

@@ -47,9 +47,22 @@ import {
   isProcessAlive,
   isProcessAlive,
   tryAcquireDaemonLock,
   tryAcquireDaemonLock,
 } from './daemon';
 } from './daemon';
+import { clearStaleDaemonArtifacts } from './daemon-registry';
 import { connectWithHello, runLocalHandshakeProxy } from './proxy';
 import { connectWithHello, runLocalHandshakeProxy } from './proxy';
-import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
-import { getDaemonSocketCandidates, probeDaemonIdentity } from './daemon-paths';
+import {
+  getWriterPidPath,
+  readWriterLock,
+  releaseWriterLock,
+  tryAcquireWriterLock,
+  writerLockHeldMessage,
+} from './writer-lock';
+import {
+  canProbeDaemonIdentity,
+  decodeLockInfo,
+  getDaemonPidPath,
+  getDaemonSocketCandidates,
+  probeDaemonIdentity,
+} from './daemon-paths';
 import { getTelemetry } from '../telemetry';
 import { getTelemetry } from '../telemetry';
 import { checkForUpdateInBackground } from '../upgrade/update-check';
 import { checkForUpdateInBackground } from '../upgrade/update-check';
 import { EARLY_PPID } from './early-ppid';
 import { EARLY_PPID } from './early-ppid';
@@ -75,6 +88,42 @@ const DAEMON_INTERNAL_ENV = 'CODEGRAPH_DAEMON_INTERNAL';
 const TAKEOVER_MAX_RETRIES = 5;
 const TAKEOVER_MAX_RETRIES = 5;
 const TAKEOVER_RETRY_DELAY_MS = 100;
 const TAKEOVER_RETRY_DELAY_MS = 100;
 
 
+/**
+ * Create an in-process fallback only when it cannot conflict with a live
+ * legacy daemon. Plain-PID locks cannot prove daemon identity, but they still
+ * prove that a process owns the legacy writer slot.
+ */
+function makeFallbackEngine(root: string): MCPEngine {
+  let existing: ReturnType<typeof decodeLockInfo> = null;
+  try {
+    existing = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8'));
+  } catch (err) {
+    const code = (err as NodeJS.ErrnoException).code;
+    if (code !== 'ENOENT') {
+      throw new Error(`The daemon lock could not be read (${code ?? 'unknown error'}); refusing an in-process fallback.`);
+    }
+  }
+  if (
+    existing &&
+    isProcessAlive(existing.pid) &&
+    !canProbeDaemonIdentity(existing)
+  ) {
+    throw new Error(
+      `Cannot start an in-process fallback while live legacy daemon pid ${existing.pid} holds the project lock.`
+    );
+  }
+  const writer = readWriterLock(root);
+  if (writer && writer.pid > 0 && isProcessAlive(writer.pid)) {
+    throw new Error(writerLockHeldMessage(writer, getWriterPidPath(root)));
+  }
+  if (existing && isProcessAlive(existing.pid)) {
+    throw new Error(
+      `Cannot start an in-process fallback while live daemon pid ${existing.pid} holds the project lock.`
+    );
+  }
+  return new MCPEngine({ writerLockRoot: root });
+}
+
 /**
 /**
  * How long a launcher waits for a freshly-spawned daemon to bind its socket
  * How long a launcher waits for a freshly-spawned daemon to bind its socket
  * before giving up and running in-process. The daemon binds the socket *before*
  * before giving up and running in-process. The daemon binds the socket *before*
@@ -449,23 +498,43 @@ export class MCPServer {
       // Taken. If the holder is alive, another daemon already serves (or is
       // Taken. If the holder is alive, another daemon already serves (or is
       // binding) — we're redundant; exit cleanly so the launcher proxies to it.
       // binding) — we're redundant; exit cleanly so the launcher proxies to it.
       const existing = lock.existing;
       const existing = lock.existing;
+      let disprovedLiveIdentity = false;
       if (existing && existing.pid > 0 && isProcessAlive(existing.pid)) {
       if (existing && existing.pid > 0 && isProcessAlive(existing.pid)) {
         // Give a newly-elected daemon time to bind, then require its socket hello
         // Give a newly-elected daemon time to bind, then require its socket hello
         // to match the lock PID/version. PID existence alone accepts an unrelated
         // to match the lock PID/version. PID existence alone accepts an unrelated
         // process after OS PID reuse and permanently wedges startup (#1553).
         // process after OS PID reuse and permanently wedges startup (#1553).
         const age = Date.now() - existing.startedAt;
         const age = Date.now() - existing.startedAt;
-        const stillStarting = existing.startedAt > 0 && age >= 0 && age < 10_000;
-        if (stillStarting || await probeDaemonIdentity(existing)) {
+        const startupGraceMs = 10_000;
+        const stillStarting = existing.startedAt > 0 && age >= 0 && age < startupGraceMs;
+        // Legacy plain-PID locks have no socket identity to test. Preserve those
+        // live holders: an inconclusive probe is not permission to create a
+        // second writer.
+        if (
+          !canProbeDaemonIdentity(existing) ||
+          stillStarting ||
+          await probeDaemonIdentity(existing)
+        ) {
           process.stderr.write(
           process.stderr.write(
             `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n`
             `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n`
           );
           );
           process.exit(0);
           process.exit(0);
         }
         }
+        disprovedLiveIdentity = true;
       }
       }
 
 
-      // Holder is dead (or the record is unreadable) — clear it (pid-verified,
-      // so we never delete a live daemon's lock) and retry the acquire.
-      clearStaleDaemonLock(lock.pidPath, existing?.pid, { allowLivePid: true });
+      // The holder is dead, the record is unreadable, or a completed socket
+      // hello disproved a live PID's identity. Revalidate the exact record and
+      // retry the acquire only after cleanup succeeds safely.
+      if (disprovedLiveIdentity) {
+        // Re-probe and claim writer.pid before cleanup. A daemon that is merely
+        // delayed already owns that writer lock, and a paired live-PID record is
+        // ambiguous under the legacy lock format, so both cases fail closed.
+        await clearStaleDaemonArtifacts(root);
+      } else if (lock.lockContents !== null) {
+        clearStaleDaemonLock(lock.pidPath, existing?.pid, {
+          expectedLockContents: lock.lockContents,
+        });
+      }
       await sleep(TAKEOVER_RETRY_DELAY_MS);
       await sleep(TAKEOVER_RETRY_DELAY_MS);
     }
     }
 
 
@@ -515,7 +584,7 @@ export class MCPServer {
       }
       }
       return null; // never bound — the proxy serves this session in-process
       return null; // never bound — the proxy serves this session in-process
     };
     };
-    await runLocalHandshakeProxy({ getDaemonSocket, makeEngine: () => new MCPEngine(), root });
+    await runLocalHandshakeProxy({ getDaemonSocket, makeEngine: () => makeFallbackEngine(root), root });
   }
   }
 
 
   /** Standard SIGINT/SIGTERM handlers that route to our `stop()` (direct mode). */
   /** Standard SIGINT/SIGTERM handlers that route to our `stop()` (direct mode). */

+ 1 - 0
src/resolution/index.ts

@@ -437,6 +437,7 @@ export class ReferenceResolver {
    */
    */
   private createContext(): ResolutionContext {
   private createContext(): ResolutionContext {
     return {
     return {
+      resolveImport: (ref) => resolveViaImport(ref, this.context),
       getNodesInFile: (filePath: string) => {
       getNodesInFile: (filePath: string) => {
         if (!this.nodeCache.has(filePath)) {
         if (!this.nodeCache.has(filePath)) {
           this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));
           this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));

+ 12 - 0
src/resolution/js-builtins.ts

@@ -6,3 +6,15 @@ export const JS_BUILT_INS = new Set([
   'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
   'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
   'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
   'fetch', 'require', 'module', 'exports', '__dirname', '__filename',
 ]);
 ]);
+
+/**
+ * TypeScript primitive type names. Distinct from JS_BUILT_INS on purpose: those
+ * are runtime globals a receiver can be constructed from, these only ever come
+ * from a type annotation. A receiver typed `string` calls a built-in string
+ * method — never a project method — so the resolver declines rather than
+ * guessing a same-named one (#1840).
+ */
+export const TS_PRIMITIVE_TYPES = new Set([
+  'string', 'number', 'boolean', 'bigint', 'symbol',
+  'void', 'undefined', 'null', 'never', 'unknown', 'any', 'object',
+]);

+ 281 - 8
src/resolution/name-matcher.ts

@@ -8,8 +8,7 @@ import * as path from 'path';
 import { Language, Node } from '../types';
 import { Language, Node } from '../types';
 import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
 import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
 import { blankStringContents, stripCommentsForRegex } from './strip-comments';
 import { blankStringContents, stripCommentsForRegex } from './strip-comments';
-import { JS_BUILT_INS } from './js-builtins';
-import { resolveViaImport } from './import-resolver';
+import { JS_BUILT_INS, TS_PRIMITIVE_TYPES } from './js-builtins';
 
 
 /**
 /**
  * Ceiling on how many same-named definitions a FUZZY name-match strategy will
  * Ceiling on how many same-named definitions a FUZZY name-match strategy will
@@ -1660,6 +1659,19 @@ const PATTERN_MEMO_CAP = 8192;
 type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
 type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
 const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
 const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
 
 
+/** Awaited inference caches are scoped to the resolver's stable-source window.
+ * Negative file eligibility avoids scanning ordinary receiver misses; call-site
+ * keys distinguish shadowed bindings and sibling blocks. Both caches are bounded
+ * and are invalidated with file/import caches on sync. */
+type AwaitedType = { name: string | null; filePath: string };
+type AwaitedFile = {
+  code: string; ready: boolean; offsets: number[]; names: Set<string>;
+  scopes: { start: number; end: number; parent: number }[];
+  declarations: Map<string, { index: number; length: number }[]>;
+};
+const AWAITED_TYPE_MEMO = new WeakMap<ResolutionContext, Map<string, AwaitedType | null>>();
+const AWAITED_FILES = new WeakMap<ResolutionContext, Map<string, AwaitedFile | null>>();
+
 function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
 function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
   let m = INFER_SCAN_STATES.get(context);
   let m = INFER_SCAN_STATES.get(context);
   if (!m) {
   if (!m) {
@@ -1672,11 +1684,14 @@ function getInferScanStates(context: ResolutionContext): Map<string, InferScanSt
 /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
 /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
 export function clearNameMatcherMemos(context: ResolutionContext): void {
 export function clearNameMatcherMemos(context: ResolutionContext): void {
   INFER_SCAN_STATES.delete(context);
   INFER_SCAN_STATES.delete(context);
+  AWAITED_TYPE_MEMO.delete(context);
+  AWAITED_FILES.delete(context);
   C_STATIC_MEMO.delete(context);
   C_STATIC_MEMO.delete(context);
   RUST_TRAIT_IMPL_MEMO.delete(context);
   RUST_TRAIT_IMPL_MEMO.delete(context);
   SEALED_MODULES.delete(context);
   SEALED_MODULES.delete(context);
   LOCAL_BINDING_MEMO.delete(context);
   LOCAL_BINDING_MEMO.delete(context);
   SELECTOR_NAMES.delete(context);
   SELECTOR_NAMES.delete(context);
+  GET_STATE_FILES.delete(context);
 }
 }
 
 
 function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
 function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
@@ -2021,6 +2036,164 @@ function inferLocalReceiverType(
   return null;
   return null;
 }
 }
 
 
+/** Infer only a visible awaited binding and its actual local/imported callee.
+ * The signature already carries the return annotation in both extractors, so
+ * multiline declarations and neighboring declarations cannot donate a type.
+ * `null` means no awaited evidence; a null NAME means an awaited receiver whose
+ * type is unknown, which must not fall back to an unrelated method name. */
+function inferEsmAwaitedCallType(
+  receiverName: string,
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+): AwaitedType | null {
+  if (!/^[A-Za-z_$][\w$]*$/.test(receiverName)) return null;
+  let files = AWAITED_FILES.get(context);
+  if (!files) { files = new Map(); AWAITED_FILES.set(context, files); }
+  let file = files.get(ref.filePath);
+  if (file === undefined) {
+    const source = context.readFile(ref.filePath) ?? '';
+    file = null;
+    // Raw eligibility is cheap; sanitize and index scopes only when a ref
+    // actually uses one of these names. Comments cannot donate a binding:
+    // the names are checked again after sanitizing on the first real lookup.
+    const names = new Set([...source.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*await\s+[\w$]+\s*\(/g)].map(m => m[1]!));
+    if (names.size) file = { code: source, ready: false, names, offsets: [], scopes: [], declarations: new Map() };
+    if (files.size >= 256) files.delete(files.keys().next().value!);
+    files.set(ref.filePath, file);
+  }
+  if (!file?.names.has(receiverName)) return null;
+  if (!file.ready) {
+    const code = blankStringContents(stripCommentsForRegex(file.code, 'typescript'));
+    const names = new Set([...code.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*await\s+[\w$]+\s*\(/g)].map(m => m[1]!));
+    const offsets = [0];
+    const scopes = [{ start: -1, end: code.length, parent: -1 }];
+    const stack = [0];
+    for (let i = 0; i < code.length; i++) {
+      if (code[i] === '\n') offsets.push(i + 1);
+      if (code[i] === '{') {
+        scopes.push({ start: i, end: code.length, parent: stack[stack.length - 1]! });
+        stack.push(scopes.length - 1);
+      } else if (code[i] === '}' && stack.length > 1) scopes[stack.pop()!]!.end = i;
+    }
+    const declarations = new Map<string, { index: number; length: number }[]>();
+    for (const m of code.matchAll(/\b(?:const|let|var)\s+([\w$]+)\s*=\s*/g)) {
+      if (!names.has(m[1]!)) continue;
+      const entries = declarations.get(m[1]!) ?? [];
+      entries.push({ index: m.index!, length: m[0].length });
+      declarations.set(m[1]!, entries);
+    }
+    Object.assign(file, { code, ready: true, names, offsets, scopes, declarations });
+    if (!names.has(receiverName)) return null;
+  }
+  let memo = AWAITED_TYPE_MEMO.get(context);
+  if (!memo) { memo = new Map(); AWAITED_TYPE_MEMO.set(context, memo); }
+  const key = `${ref.filePath}|${ref.line}|${ref.column}|${receiverName}`;
+  if (memo.has(key)) return memo.get(key)!;
+  const result = resolveAwaitedCallType(receiverName, file, ref, context);
+  if (memo.size >= PATTERN_MEMO_CAP) memo.delete(memo.keys().next().value!);
+  memo.set(key, result);
+  return result;
+}
+
+function resolveAwaitedCallType(
+  receiverName: string,
+  file: AwaitedFile,
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+): AwaitedType | null {
+  const unknown: AwaitedType = { name: null, filePath: ref.filePath };
+  const end = (file.offsets[ref.line - 1] ?? file.code.length) + ref.column;
+  const code = file.code.slice(0, end);
+  const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  // Locate scopes in the precomputed brace tree. Rescanning the entire file
+  // for every candidate binding made large test files quadratic in refs.
+  const scopeAt = (offset: number): number => {
+    let lo = 0, hi = file.scopes.length;
+    while (lo + 1 < hi) {
+      const mid = (lo + hi) >>> 1;
+      if (file.scopes[mid]!.start < offset) lo = mid; else hi = mid;
+    }
+    while (lo > 0 && file.scopes[lo]!.end < offset) lo = file.scopes[lo]!.parent;
+    return lo;
+  };
+  const visibleAt = (declaration: number, use: number): boolean => {
+    const ancestor = scopeAt(declaration);
+    for (let scope = scopeAt(use); scope >= 0; scope = file.scopes[scope]!.parent) if (scope === ancestor) return true;
+    return false;
+  };
+  const binding = [...(file.declarations.get(receiverName) ?? [])].reverse()
+    .find(m => m.index < end && visibleAt(m.index, end));
+  if (!binding) return null;
+  const init = code.slice(binding.index + binding.length);
+  if (!/^await\b/.test(init)) return null;
+  // Only a bare call result, not a following member/index/conditional expression.
+  const call = /^await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(init);
+  if (!call) return null;
+  let depth = 1, callEnd = call[0].length;
+  for (; callEnd < init.length && depth; callEnd++) {
+    if (init[callEnd] === '(') depth++;
+    else if (init[callEnd] === ')') depth--;
+  }
+  if (depth) return unknown;
+  const tail = init.slice(callEnd);
+  // A following property/index/call is not the callee's annotated value.
+  if (!/^[ \t]*(?:;|\r?\n(?![ \t]*[.(\[?]))/.test(tail)) return unknown;
+  const rest = tail;
+  if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${escaped}\\b|\\{[^}]*\\b${escaped}\\b)`).test(rest) ||
+      new RegExp(`\\b${escaped}\\s*=(?!=)`).test(rest) || hasParameterBinding(rest, escaped)) return unknown;
+
+  const bindingLine = file.code.slice(0, binding.index!).split('\n').length;
+  const bindingRef = { ...ref, line: bindingLine, column: binding.index! - file.offsets[bindingLine - 1]! };
+  const callee = call[1]!;
+  const calleeEscaped = callee.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  if (context.getNodesInFile(ref.filePath).some(n =>
+    (n.kind === 'function' || n.kind === 'method') && n.startLine <= bindingLine && n.endLine >= bindingLine &&
+    n.signature && hasParameterBinding(`${n.signature} {`, calleeEscaped))) return unknown;
+
+  const imported = context.getImportMappings(ref.filePath, ref.language).some(m => m.localName === callee);
+  let declaring: Node | undefined;
+  if (imported) {
+    if (importShadowedAt(callee, bindingRef, context)) return unknown;
+    const resolved = context.resolveImport?.({ ...bindingRef, referenceName: callee, referenceKind: 'calls' });
+    declaring = resolved ? context.getNodeById?.(resolved.targetNodeId) ?? undefined : undefined;
+  } else {
+    const local = context.getNodesByName(callee).filter(n => n.kind === 'function' &&
+      n.filePath === ref.filePath && ESM_FAMILY.has(n.language) && isLexicallyReachable(n, bindingRef, context));
+    if (local.length === 1) declaring = local[0];
+  }
+  if (!declaring || declaring.kind !== 'function' || !declaring.signature) return unknown;
+  if (!imported) {
+    const beforeBinding = code.slice(0, binding.index!);
+    const shadows = new RegExp(`\\b(?:const|let|var)\\s+${calleeEscaped}\\b`, 'g');
+    for (const shadow of beforeBinding.matchAll(shadows)) {
+      if (!visibleAt(shadow.index!, binding.index)) continue;
+      // A typed arrow function may itself be the declared local factory.
+      const line = file.code.slice(0, shadow.index!).split('\n').length;
+      if (line !== declaring.startLine || shadow.index! - file.offsets[line - 1]! > declaring.startColumn) return unknown;
+    }
+  }
+  const signature = declaring.signature;
+  const annotation = signature.slice(signature.lastIndexOf(')') + 1).match(/^\s*:\s*([\s\S]+)$/)?.[1]?.trim();
+  if (!annotation) return unknown;
+  // Do not turn unions, arrays, object/function types, or conditional types into
+  // a project class. Await recursively unwraps promises, but this narrow path
+  // accepts a single named Promise<T> layer only.
+  const returned = annotation.match(/^Promise\s*<\s*([\w$]+)\s*>$/)?.[1] ?? annotation;
+  if (!/^[A-Za-z_$][\w$]*$/.test(returned)) return unknown;
+  if (TS_PRIMITIVE_TYPES.has(returned)) return { name: returned, filePath: declaring.filePath };
+
+  const typeRef = { ...bindingRef, fromNodeId: declaring.id, filePath: declaring.filePath,
+    language: declaring.language, line: declaring.startLine, column: declaring.startColumn,
+    referenceName: returned, referenceKind: 'references' as const };
+  const typeImport = context.getImportMappings(declaring.filePath, declaring.language).some(m => m.localName === returned);
+  const resolved = typeImport ? context.resolveImport?.(typeRef) : null;
+  const typeNode = resolved ? context.getNodeById?.(resolved.targetNodeId) :
+    context.getNodesByName(returned).find(n => n.filePath === declaring.filePath &&
+      ESM_FAMILY.has(n.language) && (n.kind === 'class' || n.kind === 'interface'));
+  if (!typeNode || (typeNode.kind !== 'class' && typeNode.kind !== 'interface')) return unknown;
+  return { name: typeNode.name, filePath: typeNode.filePath };
+}
+
 /**
 /**
  * Patterns that recover a PHP class property's declared type for a
  * Patterns that recover a PHP class property's declared type for a
  * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
  * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
@@ -2188,10 +2361,16 @@ export function matchMethodCall(
   // shared source-based inferrer. resolveMethodOnType validates the method
   // shared source-based inferrer. resolveMethodOnType validates the method
   // exists on the inferred type, so a mis-inference produces no edge.
   // exists on the inferred type, so a mis-inference produces no edge.
   if (inferableReceiver) {
   if (inferableReceiver) {
-    const inferredType = nmTimedT('mc-infer', ref, () =>
+    let inferredType = nmTimedT('mc-infer', ref, () =>
       ref.language === 'cpp'
       ref.language === 'cpp'
         ? inferCppReceiverType(objectOrClass!, ref, context)
         ? inferCppReceiverType(objectOrClass!, ref, context)
         : inferLocalReceiverType(objectOrClass!, ref, context));
         : inferLocalReceiverType(objectOrClass!, ref, context));
+    const awaited = !inferredType && ESM_FAMILY.has(ref.language)
+      ? inferEsmAwaitedCallType(objectOrClass!, ref, context) : null;
+    if (awaited) {
+      if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null;
+      inferredType = awaited.name;
+    }
     if (inferredType) {
     if (inferredType) {
       // Java/Kotlin: when two classes share the simple name, the file's import
       // Java/Kotlin: when two classes share the simple name, the file's import
       // pins WHICH one (#314). Other languages disambiguate by call-site file.
       // pins WHICH one (#314). Other languages disambiguate by call-site file.
@@ -2204,20 +2383,32 @@ export function matchMethodCall(
       const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
       const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
         inferredType,
         inferredType,
         methodName!,
         methodName!,
-        ref,
+        awaited ? { ...ref, filePath: awaited.filePath } : ref,
         context,
         context,
         0.9,
         0.9,
         'instance-method',
         'instance-method',
         importedFqn,
         importedFqn,
       ));
       ));
       if (typedMatch) {
       if (typedMatch) {
+        if (awaited) {
+          const target = context.getNodeById?.(typedMatch.targetNodeId);
+          if (!target || (target.qualifiedName.startsWith(`${inferredType}::`) && target.filePath !== awaited.filePath)) return null;
+          return { ...typedMatch, original: ref };
+        }
         return typedMatch;
         return typedMatch;
       }
       }
+      if (awaited) return null;
       // A known JS/TS builtin receiver is external when it has no project
       // A known JS/TS builtin receiver is external when it has no project
       // method (#1566). Inference already strips generics (`Map<K, V>` →
       // method (#1566). Inference already strips generics (`Map<K, V>` →
       // `Map`); do not let Strategy 3 guess an unrelated `get`/`set`/`has`.
       // `Map`); do not let Strategy 3 guess an unrelated `get`/`set`/`has`.
       // Keep the validated match above for a project type shadowing a builtin.
       // Keep the validated match above for a project type shadowing a builtin.
-      if (ESM_FAMILY.has(ref.language) && JS_BUILT_INS.has(inferredType)) {
+      // A primitive receiver joins the builtins here: `listed.split()` on a
+      // `string` is the built-in method, and Strategy 3 would otherwise hand
+      // it whichever project class happens to declare a lone `split` (#1840).
+      if (
+        ESM_FAMILY.has(ref.language) &&
+        (JS_BUILT_INS.has(inferredType) || TS_PRIMITIVE_TYPES.has(inferredType))
+      ) {
         return null;
         return null;
       }
       }
     }
     }
@@ -2250,6 +2441,17 @@ export function matchMethodCall(
     return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context);
     return matchRustSelfFieldCall(objectOrClass!.slice('self.'.length), methodName!, ref, context);
   }
   }
 
 
+  // Rust call on the enclosing type itself — `self.reset()`, emitted as
+  // `self.reset` (#1861). Same discipline as the field branch above, and
+  // EXCLUSIVE for the same reason: the owner is written on the `impl` line and
+  // carried in the calling method's qualified name, so it is not a guess.
+  // Letting this shape reach the bare-name strategies below is how
+  // `self.reset()` resolved to a same-named method on an unrelated type
+  // whenever that type's method happened to sit nearer the call site.
+  if (ref.language === 'rust' && dotMatch && objectOrClass === 'self') {
+    return matchRustSelfCall(methodName!, ref, context);
+  }
+
   // TS/JS call through a field of the enclosing class — `this.mailer.send()`,
   // TS/JS call through a field of the enclosing class — `this.mailer.send()`,
   // emitted as `this.mailer.send` (#1496). Same discipline as the Rust branch
   // emitted as `this.mailer.send` (#1496). Same discipline as the Rust branch
   // above, and EXCLUSIVE for the same reason: the field's declared type off
   // above, and EXCLUSIVE for the same reason: the field's declared type off
@@ -2590,6 +2792,56 @@ export function rustFieldTypeName(raw: string): string | null {
   return seg;
   return seg;
 }
 }
 
 
+/**
+ * `self.method()` in Rust — the method on the type the call sits inside.
+ *
+ * The owner is the calling method's qualified-name prefix (`Target::run` →
+ * `Target`), which is where the `impl` block's type ends up. A free function
+ * has no `self`, so a caller whose qualified name carries no owner declines.
+ * Exactly one candidate must belong to that owner: a project with two `impl`
+ * blocks for the same type is normal, two same-named methods on it is not, and
+ * guessing between them is the failure this replaces.
+ */
+function matchRustSelfCall(
+  methodName: string,
+  ref: UnresolvedRef,
+  context: ResolutionContext,
+): ResolvedRef | null {
+  const caller = context.getNodeById?.(ref.fromNodeId);
+  if (!caller?.qualifiedName) 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);
+
+  let owned = context
+    .getNodesByQualifiedName(`${owner}::${methodName}`)
+    .filter(
+      (n) =>
+        n.kind === 'method' &&
+        n.language === 'rust' &&
+        n.qualifiedName === `${owner}::${methodName}`,
+    );
+  // Rust's extracted qualified names omit module paths. Two modules can
+  // each declare `Target`; matching just `Target::reset` does not establish
+  // ownership. In that case require a single owner declaration in the
+  // caller's file and a method in that file. Otherwise leave it unresolved.
+  // A unique owner still permits ordinary impl blocks split across files.
+  const owners = context.getNodesByQualifiedName(owner).filter((n) =>
+    n.language === 'rust' && ['struct', 'enum', 'union', 'trait', 'class'].includes(n.kind));
+  if (owners.length > 1) {
+    if (owners.filter((n) => n.filePath === caller.filePath).length !== 1) return null;
+    owned = owned.filter((n) => n.filePath === caller.filePath);
+  }
+  if (owned.length !== 1) return null;
+
+  return {
+    original: ref,
+    targetNodeId: owned[0]!.id,
+    confidence: 0.9,
+    resolvedBy: 'qualified-name',
+  };
+}
+
 /**
 /**
  * Resolve a Rust call through a field of the enclosing type —
  * Resolve a Rust call through a field of the enclosing type —
  * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585).
  * `self.inner.run()`, emitted by the extractor as `self.inner.run` (#1585).
@@ -2800,7 +3052,7 @@ function resolveStoreAction(inner: string, member: string, ref: UnresolvedRef, c
   } else {
   } else {
     const name = inner.slice(0, -'.getState'.length);
     const name = inner.slice(0, -'.getState'.length);
     if (!/^[\w$]+$/.test(name)) return null;
     if (!/^[\w$]+$/.test(name)) return null;
-    const imported = resolveViaImport({ ...ref, referenceName: name, referenceKind: 'references' }, context);
+    const imported = context.resolveImport?.({ ...ref, referenceName: name, referenceKind: 'references' });
     const node = imported && context.getNodeById?.(imported.targetNodeId);
     const node = imported && context.getNodeById?.(imported.targetNodeId);
     if (node && importShadowedAt(name, ref, context)) return null;
     if (node && importShadowedAt(name, ref, context)) return null;
     holders = node ? [node] : context.getNodesByName(name).filter((n) =>
     holders = node ? [node] : context.getNodesByName(name).filter((n) =>
@@ -2820,11 +3072,32 @@ function resolveStoreAction(inner: string, member: string, ref: UnresolvedRef, c
   return resolveObjectLiteralMember(holder, member, ref, context, 0.9, 'instance-method');
   return resolveObjectLiteralMember(holder, member, ref, context, 0.9, 'instance-method');
 }
 }
 
 
+// Eligibility is a file property, not a call-site property. Cache both answers
+// within the same stable-source window as the resolver's file cache; sync drops
+// it via clearNameMatcherMemos. Keep only booleans, FIFO-capped like PATTERN_MEMO
+// to avoid per-hit LRU churn. Eviction merely repeats the source scan.
+const GET_STATE_FILES = new WeakMap<ResolutionContext, Map<string, boolean>>();
+const GET_STATE_FILES_CAP = 8192;
+
 /** A const destructuring is a bound reference, so it is eligible even though
 /** A const destructuring is a bound reference, so it is eligible even though
  * arbitrary locally-bound bare calls must never guess a cross-file target. */
  * arbitrary locally-bound bare calls must never guess a cross-file target. */
 function matchDestructuredStoreCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
 function matchDestructuredStoreCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
-  const source = context.readFile(ref.filePath);
-  if (!source?.includes('.getState')) return null;
+  let files = GET_STATE_FILES.get(context);
+  if (!files) { files = new Map(); GET_STATE_FILES.set(context, files); }
+  let eligible = files.get(ref.filePath);
+  let source: string | null | undefined;
+  if (eligible === undefined) {
+    source = context.readFile(ref.filePath);
+    eligible = source?.includes('.getState') ?? false;
+    if (files.size >= GET_STATE_FILES_CAP) {
+      const oldest = files.keys().next().value;
+      if (oldest !== undefined) files.delete(oldest);
+    }
+    files.set(ref.filePath, eligible);
+  }
+  if (!eligible) return null;
+  source ??= context.readFile(ref.filePath);
+  if (!source) return null;
   const lines = source.split('\n');
   const lines = source.split('\n');
   const start = enclosingScopeStartLine(ref, context) - 1;
   const start = enclosingScopeStartLine(ref, context) - 1;
   const before = lines.slice(start, ref.line - 1).concat(lines[ref.line - 1]!.slice(0, ref.column)).join('\n');
   const before = lines.slice(start, ref.line - 1).concat(lines[ref.line - 1]!.slice(0, ref.column)).join('\n');

+ 4 - 0
src/resolution/types.ts

@@ -154,6 +154,10 @@ export interface ResolutionContext {
   getNodeById?(id: string): Node | null;
   getNodeById?(id: string): Node | null;
   /** Get cached import mappings for a file */
   /** Get cached import mappings for a file */
   getImportMappings(filePath: string, language: Language): ImportMapping[];
   getImportMappings(filePath: string, language: Language): ImportMapping[];
+  /** Import lookup supplied by the coordinator, keeping name matching from
+   * importing the import resolver (which itself uses name-matching helpers).
+   * Minimal contexts without import resolution may omit this capability. */
+  resolveImport?(ref: UnresolvedRef): ResolvedRef | null;
   /**
   /**
    * Project import-path aliases (tsconfig/jsconfig `paths`). Returns
    * Project import-path aliases (tsconfig/jsconfig `paths`). Returns
    * `null` when the project doesn't define any. Cached per resolver
    * `null` when the project doesn't define any. Cached per resolver

+ 76 - 38
src/telemetry/index.ts

@@ -5,7 +5,7 @@
  * (and user-facing TELEMETRY.md); the ingest endpoint that enforces it is
  * (and user-facing TELEMETRY.md); the ingest endpoint that enforces it is
  * public at telemetry-worker/. This module honors four invariants:
  * public at telemetry-worker/. This module honors four invariants:
  *
  *
- * 1. Zero hot-path cost: recording is an in-memory increment. Disk writes are
+ * 1. Recording rechecks the small consent file, then increments in memory. Disk writes are
  *    a tiny synchronous append at process exit (works under `process.exit()`,
  *    a tiny synchronous append at process exit (works under `process.exit()`,
  *    where `beforeExit` never fires); network sends happen opportunistically
  *    where `beforeExit` never fires); network sends happen opportunistically
  *    (startup of long-running commands, daemon interval, bounded await at the
  *    (startup of long-running commands, daemon interval, bounded await at the
@@ -88,7 +88,7 @@ export interface ClientInfo {
 
 
 interface ConfigFile {
 interface ConfigFile {
   enabled: boolean;
   enabled: boolean;
-  machine_id: string;
+  machine_id: string | null;
   consent_source: 'installer' | 'default-notice' | 'cli';
   consent_source: 'installer' | 'default-notice' | 'cli';
   first_run_notice_shown?: boolean;
   first_run_notice_shown?: boolean;
   updated_at: string;
   updated_at: string;
@@ -159,7 +159,7 @@ export class Telemetry {
   private events: EventLine[] = [];
   private events: EventLine[] = [];
   private readonly installExitHook: boolean;
   private readonly installExitHook: boolean;
   private exitHookInstalled = false;
   private exitHookInstalled = false;
-  private configCache: ConfigFile | null | undefined; // undefined = not read yet
+  private configCache: ConfigFile | null | undefined; // last observed identity, not a lifetime cache
   private intervalHandle: NodeJS.Timeout | null = null;
   private intervalHandle: NodeJS.Timeout | null = null;
 
 
   constructor(opts: TelemetryOptions = {}) {
   constructor(opts: TelemetryOptions = {}) {
@@ -189,14 +189,17 @@ export class Telemetry {
     const machineId = config?.machine_id ?? null;
     const machineId = config?.machine_id ?? null;
     const dnt = this.env.DO_NOT_TRACK;
     const dnt = this.env.DO_NOT_TRACK;
     if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
     if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
+      this.clearPending();
       return { enabled: false, decidedBy: 'DO_NOT_TRACK', machineId, configPath: this.configPath };
       return { enabled: false, decidedBy: 'DO_NOT_TRACK', machineId, configPath: this.configPath };
     }
     }
     const forced = this.env.CODEGRAPH_TELEMETRY;
     const forced = this.env.CODEGRAPH_TELEMETRY;
     if (forced !== undefined && forced !== '') {
     if (forced !== undefined && forced !== '') {
       const on = forced !== '0' && forced.toLowerCase() !== 'false';
       const on = forced !== '0' && forced.toLowerCase() !== 'false';
+      if (!on) this.clearPending();
       return { enabled: on, decidedBy: 'CODEGRAPH_TELEMETRY', machineId, configPath: this.configPath };
       return { enabled: on, decidedBy: 'CODEGRAPH_TELEMETRY', machineId, configPath: this.configPath };
     }
     }
     if (config) {
     if (config) {
+      if (!config.enabled) this.clearPending();
       return { enabled: config.enabled, decidedBy: 'config', machineId, configPath: this.configPath };
       return { enabled: config.enabled, decidedBy: 'config', machineId, configPath: this.configPath };
     }
     }
     return { enabled: true, decidedBy: 'default', machineId, configPath: this.configPath };
     return { enabled: true, decidedBy: 'default', machineId, configPath: this.configPath };
@@ -215,13 +218,21 @@ export class Telemetry {
     const existing = this.readConfig();
     const existing = this.readConfig();
     this.writeConfig({
     this.writeConfig({
       enabled,
       enabled,
-      machine_id: existing?.machine_id ?? randomUUID(),
+      machine_id: enabled ? (existing?.enabled && existing.machine_id ? existing.machine_id : randomUUID()) : null,
       consent_source: source,
       consent_source: source,
       first_run_notice_shown: true,
       first_run_notice_shown: true,
       updated_at: this.now().toISOString(),
       updated_at: this.now().toISOString(),
     });
     });
     if (!enabled) {
     if (!enabled) {
-      try { fs.rmSync(this.queuePath, { force: true }); } catch { /* fail silent */ }
+      this.clearPending();
+      try {
+        // Claimed data must not reappear on a later opt-in/crash recovery.
+        for (const name of fs.readdirSync(this.dir)) {
+          if (name === 'telemetry-queue.jsonl' || /^telemetry-queue\.sending\.\d+\.jsonl$/.test(name)) {
+            try { fs.rmSync(path.join(this.dir, name), { force: true }); } catch { /* fail silent */ }
+          }
+        }
+      } catch { /* fail silent */ }
     }
     }
   }
   }
 
 
@@ -232,7 +243,7 @@ export class Telemetry {
 
 
   // -------------------------------------------------------------- recording
   // -------------------------------------------------------------- recording
 
 
-  /** In-memory increment — safe on the MCP tool-call hot path. */
+  /** Recheck shared consent, then increment in memory; no network or writes. */
   recordUsage(kind: UsageKind, name: string, ok: boolean, client?: ClientInfo): void {
   recordUsage(kind: UsageKind, name: string, ok: boolean, client?: ClientInfo): void {
     if (!this.isEnabled()) return;
     if (!this.isEnabled()) return;
     const day = this.utcDay();
     const day = this.utcDay();
@@ -279,6 +290,7 @@ export class Telemetry {
     try {
     try {
       this.persistSync();
       this.persistSync();
       this.recoverStaleClaims();
       this.recoverStaleClaims();
+      let identity = this.getStatus().machineId;
       const claim = this.claimQueue();
       const claim = this.claimQueue();
       if (!claim) return;
       if (!claim) return;
       const { claimPath, lines } = claim;
       const { claimPath, lines } = claim;
@@ -298,13 +310,18 @@ export class Telemetry {
         // its explicit consent toggle before any notice can fire, instead of
         // its explicit consent toggle before any notice can fire, instead of
         // the preAction usage count pre-empting it. An explicit installer/CLI
         // the preAction usage count pre-empting it. An explicit installer/CLI
         // choice sets first_run_notice_shown and suppresses this permanently.
         // choice sets first_run_notice_shown and suppresses this permanently.
+        if (!this.canUseIdentity(identity)) {
+          try { fs.rmSync(claimPath, { force: true }); } catch { /* fail silent */ }
+          return;
+        }
         this.firstRunNotice();
         this.firstRunNotice();
-        failed = await this.send(sendable, timeoutMs);
+        identity = this.getStatus().machineId;
+        failed = await this.send(sendable, timeoutMs, identity);
       }
       }
       // Whatever didn't go out returns to the queue (append — writers may
       // Whatever didn't go out returns to the queue (append — writers may
       // have created a fresh queue file while we held the claim).
       // have created a fresh queue file while we held the claim).
       const back = [...failed, ...keep];
       const back = [...failed, ...keep];
-      if (back.length > 0) this.appendLines(back);
+      if (back.length > 0) this.appendLines(back, identity);
       try { fs.rmSync(claimPath, { force: true }); } catch { /* fail silent */ }
       try { fs.rmSync(claimPath, { force: true }); } catch { /* fail silent */ }
     } catch {
     } catch {
       /* fail silent */
       /* fail silent */
@@ -335,24 +352,44 @@ export class Telemetry {
     return this.now().toISOString().slice(0, 10);
     return this.now().toISOString().slice(0, 10);
   }
   }
 
 
+  private clearPending(): void {
+    this.counts.clear();
+    this.events = [];
+  }
+
+  private canUseIdentity(identity: string | null): boolean {
+    const status = this.getStatus();
+    return status.enabled && status.machineId === identity;
+  }
+
   private readConfig(): ConfigFile | null {
   private readConfig(): ConfigFile | null {
-    if (this.configCache !== undefined) return this.configCache;
+    // A process-lifetime cache misses another CLI's opt-out. Read the tiny file
+    // before recording, persistence and sending, including between HTTP chunks.
+    let config: ConfigFile | null = null;
     try {
     try {
       const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8')) as ConfigFile;
       const raw = JSON.parse(fs.readFileSync(this.configPath, 'utf8')) as ConfigFile;
-      this.configCache = typeof raw.machine_id === 'string' && typeof raw.enabled === 'boolean' ? raw : null;
-    } catch {
-      this.configCache = null;
-    }
-    return this.configCache;
+      if (typeof raw.enabled === 'boolean' && (typeof raw.machine_id === 'string' ||
+        (!raw.enabled && raw.machine_id === null))) config = raw;
+    } catch { /* absent config retains the documented default */ }
+    if (this.configCache !== undefined &&
+      (this.configCache?.machine_id !== config?.machine_id ||
+        (this.configCache?.enabled && !config?.enabled))) this.clearPending();
+    this.configCache = config;
+    return config;
   }
   }
 
 
   private writeConfig(config: ConfigFile): void {
   private writeConfig(config: ConfigFile): void {
+    const temp = `${this.configPath}.${process.pid}.${randomUUID()}.tmp`;
     try {
     try {
       fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
       fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
-      fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n');
+      // Readers must see either complete choice, never a truncated JSON file.
+      fs.writeFileSync(temp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
+      fs.renameSync(temp, this.configPath);
       this.configCache = config;
       this.configCache = config;
     } catch {
     } catch {
       /* fail silent */
       /* fail silent */
+    } finally {
+      try { fs.rmSync(temp, { force: true }); } catch { /* fail silent */ }
     }
     }
   }
   }
 
 
@@ -361,19 +398,19 @@ export class Telemetry {
    * installs record their choice explicitly and never reach this).
    * installs record their choice explicitly and never reach this).
    */
    */
   private firstRunNotice(): void {
   private firstRunNotice(): void {
+    if (!this.isEnabled()) return;
     const config = this.readConfig();
     const config = this.readConfig();
+    if (config?.first_run_notice_shown && config.machine_id) return;
+    this.writeConfig({
+      enabled: config?.enabled ?? true,
+      machine_id: config?.machine_id ?? randomUUID(),
+      consent_source: config?.consent_source ?? 'default-notice',
+      first_run_notice_shown: true,
+      updated_at: this.now().toISOString(),
+    });
+    // An explicit env-on override can mint a new identity while the stored
+    // choice stays off for every other process. It does not need a new notice.
     if (config?.first_run_notice_shown) return;
     if (config?.first_run_notice_shown) return;
-    if (!config) {
-      this.writeConfig({
-        enabled: true,
-        machine_id: randomUUID(),
-        consent_source: 'default-notice',
-        first_run_notice_shown: true,
-        updated_at: this.now().toISOString(),
-      });
-    } else {
-      this.writeConfig({ ...config, first_run_notice_shown: true, updated_at: this.now().toISOString() });
-    }
     this.writeStderr(
     this.writeStderr(
       `codegraph collects anonymous usage stats (no code, paths, or names) — ` +
       `codegraph collects anonymous usage stats (no code, paths, or names) — ` +
       `"codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: ${TELEMETRY_DOCS}\n`,
       `"codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: ${TELEMETRY_DOCS}\n`,
@@ -385,17 +422,16 @@ export class Telemetry {
    * Runs on `process.on('exit')`, so it must never be async or slow.
    * Runs on `process.on('exit')`, so it must never be async or slow.
    */
    */
   persistSync(): void {
   persistSync(): void {
+    const status = this.getStatus();
+    if (!status.enabled) return;
     if (this.counts.size === 0 && this.events.length === 0) return;
     if (this.counts.size === 0 && this.events.length === 0) return;
     const lines: BufferLine[] = [...this.counts.values(), ...this.events];
     const lines: BufferLine[] = [...this.counts.values(), ...this.events];
-    this.counts.clear();
-    this.events = [];
-    // Re-check at persist time: `codegraph telemetry off` mid-process must not
-    // have its own invocation resurrect the queue file at exit.
-    if (!this.isEnabled()) return;
-    this.appendLines(lines);
+    this.clearPending();
+    this.appendLines(lines, status.machineId);
   }
   }
 
 
-  private appendLines(lines: BufferLine[]): void {
+  private appendLines(lines: BufferLine[], identity: string | null): void {
+    if (!this.canUseIdentity(identity)) return;
     try {
     try {
       fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
       fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
       const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
       const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
@@ -445,6 +481,8 @@ export class Telemetry {
 
 
   private recoverStaleClaims(): void {
   private recoverStaleClaims(): void {
     try {
     try {
+      const identity = this.getStatus().machineId;
+      if (!this.canUseIdentity(identity)) return;
       const cutoff = this.now().getTime() - STALE_CLAIM_MS;
       const cutoff = this.now().getTime() - STALE_CLAIM_MS;
       for (const name of fs.readdirSync(this.dir)) {
       for (const name of fs.readdirSync(this.dir)) {
         if (!name.startsWith('telemetry-queue.sending.')) continue;
         if (!name.startsWith('telemetry-queue.sending.')) continue;
@@ -453,7 +491,7 @@ export class Telemetry {
           if (fs.statSync(full).mtimeMs < cutoff) {
           if (fs.statSync(full).mtimeMs < cutoff) {
             const content = fs.readFileSync(full, 'utf8');
             const content = fs.readFileSync(full, 'utf8');
             fs.rmSync(full, { force: true });
             fs.rmSync(full, { force: true });
-            if (content.trim()) fs.appendFileSync(this.queuePath, content.endsWith('\n') ? content : content + '\n');
+            if (content.trim() && this.canUseIdentity(identity)) fs.appendFileSync(this.queuePath, content.endsWith('\n') ? content : content + '\n');
           }
           }
         } catch {
         } catch {
           /* fail silent */
           /* fail silent */
@@ -465,9 +503,8 @@ export class Telemetry {
   }
   }
 
 
   /** Returns the lines that did NOT make it out (to be re-queued). */
   /** Returns the lines that did NOT make it out (to be re-queued). */
-  private async send(lines: BufferLine[], timeoutMs: number): Promise<BufferLine[]> {
-    const config = this.readConfig();
-    if (!config) return [];
+  private async send(lines: BufferLine[], timeoutMs: number, identity: string | null): Promise<BufferLine[]> {
+    if (!identity || !this.canUseIdentity(identity)) return [];
     const events = lines.map((line) =>
     const events = lines.map((line) =>
       'ev' in line
       'ev' in line
         ? { event: line.ev, ts: line.ts, props: line.props }
         ? { event: line.ev, ts: line.ts, props: line.props }
@@ -485,7 +522,7 @@ export class Telemetry {
           },
           },
     );
     );
     const envelope = {
     const envelope = {
-      machine_id: config.machine_id,
+      machine_id: identity,
       codegraph_version: this.packageVersion(),
       codegraph_version: this.packageVersion(),
       os: process.platform,
       os: process.platform,
       arch: process.arch,
       arch: process.arch,
@@ -495,6 +532,7 @@ export class Telemetry {
     };
     };
     const endpoint = this.env.CODEGRAPH_TELEMETRY_ENDPOINT || TELEMETRY_ENDPOINT;
     const endpoint = this.env.CODEGRAPH_TELEMETRY_ENDPOINT || TELEMETRY_ENDPOINT;
     for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
     for (let i = 0; i < events.length; i += MAX_EVENTS_PER_REQUEST) {
+      if (!this.canUseIdentity(identity)) return [];
       const chunk = events.slice(i, i + MAX_EVENTS_PER_REQUEST);
       const chunk = events.slice(i, i + MAX_EVENTS_PER_REQUEST);
       const body = JSON.stringify({ ...envelope, events: chunk });
       const body = JSON.stringify({ ...envelope, events: chunk });
       this.debug(`POST ${endpoint} (${chunk.length} events)`);
       this.debug(`POST ${endpoint} (${chunk.length} events)`);