Bladeren bron

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 dagen geleden
bovenliggende
commit
464e8cf38e
41 gewijzigde bestanden met toevoegingen van 2985 en 200 verwijderingen
  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
 
+- 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.
 - 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.
 - Direct React Native bridge calls retain their native implementations and cross-platform relationships.
 - 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)
 - `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)
 
 #### 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)
 
 - 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.
 
 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
 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
 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)
-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
 

+ 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()));
     }
   });
+
+  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.');
   });
 
+  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 () => {
     const h1 = harness([rec('/p/a', 1, 1)], [CANCEL]);
     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,
   listDaemons,
   listVerifiedDaemons,
+  clearStaleDaemonArtifacts,
   stopDaemonAt,
   type DaemonRecord,
 } from '../src/mcp/daemon-registry';
 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. */
 async function deadPid(): Promise<number> {
@@ -155,4 +157,87 @@ describe('daemon-registry', () => {
     expect(isProcessAlive(process.pid)).toBe(true);
     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 {
   acquireLockViaExclusiveOpen,
   bindFirstUsableSocket,
+  clearStaleDaemonLock,
   tryAcquireDaemonLock,
 } from '../src/mcp/daemon';
 
@@ -244,3 +245,44 @@ describe('lock acquisition without hard links (#997)', () => {
     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);
   });
 
-  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 = `
 pub struct Outer { pub inner: Inner, pub deep: Deep }
 impl Outer {
@@ -1500,15 +1500,22 @@ impl Outer {
     const calls = result.unresolvedReferences
       .filter((r) => r.referenceKind === 'calls')
       .map((r) => r.referenceName);
-    // Exactly one call keeps the `self.<field>` prefix — the single-hop field
-    // receiver whose type the resolver can read off the owner struct.
-    expect(calls.filter((c) => c.startsWith('self.'))).toEqual(['self.inner.run']);
+    // 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…
     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');
     expect(outerRun).toBeDefined();
     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 };
 
   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();
     process.chdir(tmpCwd);
     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);
   }, 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 first = spawnServer(tempDir, env);
     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
     // 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);
     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);
+    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);
+
+    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);
 
+  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 () => {
     const net = await import('net');
     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() }),
     );
     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()));
 
@@ -402,6 +493,18 @@ describe('Shared MCP daemon (issue #411)', () => {
         () => server.stderr.some((l) => l.includes('serving this session in-process')),
         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 {
       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 childPid: number | null = null;
   let stdinHolderPid: number | null = null;
+  let tmpDir: string | null = null;
 
   afterEach(() => {
     if (wrapper && !wrapper.killed) {
@@ -69,6 +70,8 @@ describe.skipIf(process.platform === 'win32')('MCP PPID watchdog (#277)', () =>
     wrapper = null;
     childPid = 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 () => {
@@ -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
     // 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
     // child has time to finish its async start() (dynamic import + transport
     // 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], {
       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;
 
     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(); }
   `);
   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';
   import { fake } from './not-a-store';
   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::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) => {
     const calls = targets(node(name, 'selectors.ts').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']);
     });
 
+    // ── 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 () => {
       // The #1588 repro's second half: `UsesFile::go` / `UsesBuf::go` each
       // 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 () => {
       fs.writeFileSync(path.join(tempDir, 'shadow.ts'), `
 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');
   });
 
+  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,
    * 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);
   });
 });
+
+// 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 { spawn, type ChildProcess } from 'child_process';
 import * as fs from 'fs';
 import * as os from 'os';
 import * as path from 'path';
+import { MCPEngine } from '../src/mcp/engine';
 import {
   decodeWriterLockInfo,
   getWriterPidPath,
@@ -17,8 +19,11 @@ import {
 
 describe('writer lock (#1740)', () => {
   let dir: string;
+  let holder: ChildProcess | null = null;
 
   afterEach(() => {
+    try { holder?.kill('SIGKILL'); } catch { /* already gone */ }
+    holder = null;
     if (dir) {
       releaseWriterLock(dir);
       try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
@@ -84,4 +89,29 @@ describe('writer lock (#1740)', () => {
     expect(r.kind).toBe('acquired');
     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(),
                                 }
                             }
+                            // `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();
                             }
                         }

+ 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,
 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.
+
+## 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
    validates against the same allowlist and drops anything else. Adding a field = PR that
    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.
    Every failure mode is silence.
 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
 
-Common envelope on every batch (computed once per process):
+Common envelope on every batch (identity revalidated before each request):
 
 | 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`
 - **CLI:** `codegraph telemetry status|on|off` (status prints the machine ID, current
   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`:
 
@@ -154,7 +159,8 @@ other filenames.)
 New module `src/telemetry/` (single small module, no deps):
 
 - **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.
 - **Buffer** — counters persist (debounced, async) to `~/.codegraph/telemetry-queue.jsonl`.
   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;
   }
 
+  /**
+   * 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
    * 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;
   durationMs: number;
   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
    * 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
  * 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 {
+    // `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: [] };
     // Custom extension → language overrides from the project's codegraph.json,
     // so change detection sees the same custom-extension files the full index does.
     const overrides = loadExtensionOverrides(rootDir);
-    collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir));
+    collectGitStatus(rootDir, '', changes, overrides, loadIncludeIgnoredMatcher(rootDir), loadExcludeMatcher(rootDir), sinceCommit ?? undefined);
     return changes;
   } catch {
     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(
     'git',
     // `-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
     // with `-uall` — git never crosses a repo boundary — so the recursion
     // 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 }
   );
 
@@ -1325,46 +1400,66 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over
   // parent's. (#766)
   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);
-    if (!isSourceFile(filePath, overrides)) continue;
+    if (!isSourceFile(filePath, overrides)) return;
 
     if (statusCode.includes('D')) {
       // Deletions stay unfiltered: getChangedFiles acts on one only when the
       // 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)
       out.deleted.push(filePath);
-      continue;
+      return;
     }
 
     // Added (`??`) / modified files inside an excluded dir must not enter the
     // 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
     // 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
     // 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 === '??') {
       out.added.push(filePath);
     } 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);
     }
+  };
+
+  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
@@ -2906,8 +3001,7 @@ export class ExtractionOrchestrator {
     // 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
     // duplication.
-    this.queries.deleteEdgesByIds(edgeIds);
-    this.queries.insertUnresolvedRefsBatch(refs);
+    this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
     return refs.length;
   }
 
@@ -2958,6 +3052,7 @@ export class ExtractionOrchestrator {
     });
 
     const filesToIndex: string[] = [];
+    const failedFilePaths: string[] = [];
     // === Filesystem reconcile (git-independent) ===
     // The source of truth for "what changed" is the filesystem vs the indexed
     // state — never git. We enumerate the current source files and reconcile
@@ -3083,6 +3178,7 @@ export class ExtractionOrchestrator {
           }
         } catch (error) {
           logDebug('Skipping unstattable file during sync', { filePath, error: String(error) });
+          failedFilePaths.push(filePath);
           continue;
         }
       }
@@ -3093,6 +3189,7 @@ export class ExtractionOrchestrator {
         content = fs.readFileSync(fullPath, 'utf-8');
       } catch (error) {
         logDebug('Skipping unreadable file during sync', { filePath, error: String(error) });
+        failedFilePaths.push(filePath);
         continue;
       }
       const contentHash = hashContent(content);
@@ -3134,6 +3231,7 @@ export class ExtractionOrchestrator {
       });
 
       const result = await this.indexFile(filePath);
+      if (result.errors.some(e => e.severity === 'error')) failedFilePaths.push(filePath);
       nodesUpdated += result.nodes.length;
 
       const pause = backpressure?.();
@@ -3167,16 +3265,67 @@ export class ExtractionOrchestrator {
       nodesUpdated,
       durationMs: Date.now() - startTime,
       changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined,
+      ...(failedFilePaths.length > 0 ? { failedFilePaths } : {}),
       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.
    * Uses git status as a fast path when available, falling back to full scan.
    */
   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) {
       // === Git fast path ===
@@ -3184,36 +3333,27 @@ export class ExtractionOrchestrator {
       const modified: 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);
-        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);
+        if (!isSourceFile(filePath, overrides) || scope.ignores(filePath) || !fs.existsSync(fullPath)) {
+          if (tracked) removed.push(filePath);
+          continue;
+        }
         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) });
           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 };

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

@@ -4709,6 +4709,17 @@ export class TreeSitterExtractor {
               } else {
                 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 (
               this.language === 'rust' &&
               receiver &&

+ 11 - 1
src/index.ts

@@ -517,6 +517,7 @@ export class CodeGraph {
         walValve.start();
       }
       try {
+        const gitState = this.orchestrator.beginGitIndexState(true);
         const before = this.queries.getNodeAndEdgeCount();
         // Mark the index as in-flight BEFORE any writes: a run killed
         // 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 */ }
         }
 
+        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
         // accounted for. A shortfall means files were silently dropped
         // (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
         // pin frames while the WAL grew without a bound.
         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);
 
         // 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
         // missing files, pending refs, and (on open) dropped indexes, so a
         // successful recovery must also close the metadata state (#1556).
-        const fullReconcile = !options.paths || options.paths.length === 0;
         if (fullReconcile && this.getIndexState() === 'indexing') {
           try { this.queries.setMetadata('index_state', 'complete'); } catch { /* advisory */ }
         }
 
+        this.orchestrator.finishGitIndexState(gitState, fullReconcile, result.failedFilePaths);
+
         return result;
       } finally {
         // 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));
+    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' : '';
     deps.note(`Stopped daemon (pid ${result.pid}${forced}) — ${choice}`);
     // 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;
 }
 
+/** 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
  * its socket. A bare PID liveness probe is insufficient because OSes reuse PIDs
  * after an OOM/SIGKILL (#1553).
  */
 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) => {
     let socket: net.Socket;
     let buffer = '';
@@ -178,12 +188,12 @@ export function decodeLockInfo(raw: string): DaemonLockInfo | null {
     ) {
       return parsed as DaemonLockInfo;
     }
-    return null;
   } catch {
     // Fall through to legacy plain-pid handling.
   }
+  if (!/^[1-9]\d*$/.test(trimmed)) return null;
   const pid = Number(trimmed);
-  if (Number.isFinite(pid) && pid > 0) {
+  if (Number.isSafeInteger(pid)) {
     return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
   }
   return null;

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

@@ -26,9 +26,11 @@ import {
   getDaemonPidPath,
   getDaemonSocketCandidates,
   decodeLockInfo,
+  canProbeDaemonIdentity,
   probeDaemonIdentity,
   type DaemonLockInfo,
 } from './daemon-paths';
+import { readWriterLock, releaseWriterLock, tryAcquireWriterLock } from './writer-lock';
 
 export interface DaemonRecord {
   /** Realpath'd project root the daemon serves. */
@@ -140,18 +142,45 @@ export async function listVerifiedDaemons(opts: { prune?: boolean } = {}): Promi
   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. */
@@ -162,10 +191,17 @@ export async function clearStaleDaemonArtifacts(root: string): Promise<boolean>
   );
   if (!hadArtifacts) return false;
   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));
@@ -182,8 +218,8 @@ async function waitForDeath(pid: number, timeoutMs: number): Promise<boolean> {
 export interface StopResult {
   root: string;
   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> {
   let pid: number | null = null;
   let identity: DaemonLockInfo | null = null;
+  let lockContents: string | null = null;
   try {
-    identity = decodeLockInfo(fs.readFileSync(getDaemonPidPath(root), 'utf8'));
+    lockContents = fs.readFileSync(getDaemonPidPath(root), 'utf8');
+    identity = decodeLockInfo(lockContents);
     pid = identity?.pid ?? null;
   } catch {
     /* no lockfile */
@@ -210,18 +248,21 @@ export async function stopDaemonAt(root: string): Promise<StopResult> {
   }
 
   if (pid == null) {
-    cleanupDaemonArtifacts(root);
+    cleanupDaemonArtifacts(root, lockContents);
     return { root, pid: null, outcome: 'no-daemon' };
   }
   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
   // 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
@@ -233,7 +274,7 @@ export async function stopDaemonAt(root: string): Promise<StopResult> {
     await waitForDeath(pid, 2000);
     outcome = 'kill';
   }
-  cleanupDaemonArtifacts(root);
+  cleanupDaemonArtifacts(root, lockContents);
   return { root, pid, outcome };
 }
 

+ 55 - 27
src/mcp/daemon.ts

@@ -55,7 +55,11 @@ import {
   getDaemonSocketPath,
 } from './daemon-paths';
 import { CodeGraphPackageVersion } from './version';
-import { releaseWriterLock, tryAcquireWriterLock, writerLockHeldMessage } from './writer-lock';
+import {
+  releaseWriterLock,
+  tryAcquireWriterLock,
+  writerLockHeldMessage,
+} from './writer-lock';
 import { registerDaemon, deregisterDaemon } from './daemon-registry';
 
 /** Default idle linger after the last client disconnects. */
@@ -161,9 +165,8 @@ export interface DaemonStartResult {
  *
  * Race-safe: callers must first call `tryAcquireDaemonLock(projectRoot)` and
  * 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 {
   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> {
     // #1740: claim the project writer lock before opening/watching so a
@@ -215,10 +217,16 @@ export class Daemon {
       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
     // in-project path comes first; the deterministic tmpdir path is the fallback
@@ -282,19 +290,27 @@ export class Daemon {
       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.
     // Best-effort; a missing record only means list's liveness prune covers it.
     registerDaemon({ root: this.projectRoot, ...lock });
@@ -531,7 +547,13 @@ export class Daemon {
  */
 export type AcquireResult =
   | { 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.
@@ -609,10 +631,12 @@ export function tryAcquireDaemonLock(projectRoot: string): AcquireResult {
   // record — `existing` is null only for a genuinely corrupt leftover, never a
   // mid-write race.
   let existing: DaemonLockInfo | null = null;
+  let lockContents: string | null = null;
   try {
-    existing = decodeLockInfo(fs.readFileSync(pidPath, 'utf8'));
+    lockContents = fs.readFileSync(pidPath, 'utf8');
+    existing = decodeLockInfo(lockContents);
   } 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(
   pidPath: string,
   expectedDeadPid?: number,
-  opts: { allowLivePid?: boolean } = {}
+  opts: { allowLivePid?: boolean; expectedLockContents?: string } = {}
 ): boolean {
   try {
     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);
     if (info) {
       // 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.
    */
   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;
   /** Set when this engine holds writer.pid (#1740). */
   private writerLockRoot: string | null = null;
-  private opts: Required<MCPEngineOptions>;
+  private opts: Required<Omit<MCPEngineOptions, 'writerLockRoot'>>;
   private closed = false;
   // Off-loop read-tool pool (daemon mode only). Created lazily once the default
   // project is open — workers each hold their own WAL read connection.
@@ -79,6 +85,13 @@ export class MCPEngine {
   constructor(opts: MCPEngineOptions = {}) {
     this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
     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,
   tryAcquireDaemonLock,
 } from './daemon';
+import { clearStaleDaemonArtifacts } from './daemon-registry';
 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 { checkForUpdateInBackground } from '../upgrade/update-check';
 import { EARLY_PPID } from './early-ppid';
@@ -75,6 +88,42 @@ const DAEMON_INTERNAL_ENV = 'CODEGRAPH_DAEMON_INTERNAL';
 const TAKEOVER_MAX_RETRIES = 5;
 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
  * 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
       // binding) — we're redundant; exit cleanly so the launcher proxies to it.
       const existing = lock.existing;
+      let disprovedLiveIdentity = false;
       if (existing && existing.pid > 0 && isProcessAlive(existing.pid)) {
         // 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
         // process after OS PID reuse and permanently wedges startup (#1553).
         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(
             `[CodeGraph daemon] Another daemon (pid ${existing.pid}) already holds the lock; exiting.\n`
           );
           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);
     }
 
@@ -515,7 +584,7 @@ export class MCPServer {
       }
       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). */

+ 1 - 0
src/resolution/index.ts

@@ -437,6 +437,7 @@ export class ReferenceResolver {
    */
   private createContext(): ResolutionContext {
     return {
+      resolveImport: (ref) => resolveViaImport(ref, this.context),
       getNodesInFile: (filePath: string) => {
         if (!this.nodeCache.has(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',
   '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 { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
 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
@@ -1660,6 +1659,19 @@ const PATTERN_MEMO_CAP = 8192;
 type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
 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> {
   let m = INFER_SCAN_STATES.get(context);
   if (!m) {
@@ -1672,11 +1684,14 @@ function getInferScanStates(context: ResolutionContext): Map<string, InferScanSt
 /** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
 export function clearNameMatcherMemos(context: ResolutionContext): void {
   INFER_SCAN_STATES.delete(context);
+  AWAITED_TYPE_MEMO.delete(context);
+  AWAITED_FILES.delete(context);
   C_STATIC_MEMO.delete(context);
   RUST_TRAIT_IMPL_MEMO.delete(context);
   SEALED_MODULES.delete(context);
   LOCAL_BINDING_MEMO.delete(context);
   SELECTOR_NAMES.delete(context);
+  GET_STATE_FILES.delete(context);
 }
 
 function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
@@ -2021,6 +2036,164 @@ function inferLocalReceiverType(
   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
  * `$this->prop` receiver. Deliberately NOT localReceiverTypePatterns: only
@@ -2188,10 +2361,16 @@ export function matchMethodCall(
   // shared source-based inferrer. resolveMethodOnType validates the method
   // exists on the inferred type, so a mis-inference produces no edge.
   if (inferableReceiver) {
-    const inferredType = nmTimedT('mc-infer', ref, () =>
+    let inferredType = nmTimedT('mc-infer', ref, () =>
       ref.language === 'cpp'
         ? inferCppReceiverType(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) {
       // Java/Kotlin: when two classes share the simple name, the file's import
       // 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(
         inferredType,
         methodName!,
-        ref,
+        awaited ? { ...ref, filePath: awaited.filePath } : ref,
         context,
         0.9,
         'instance-method',
         importedFqn,
       ));
       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;
       }
+      if (awaited) return null;
       // A known JS/TS builtin receiver is external when it has no project
       // method (#1566). Inference already strips generics (`Map<K, V>` →
       // `Map`); do not let Strategy 3 guess an unrelated `get`/`set`/`has`.
       // 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;
       }
     }
@@ -2250,6 +2441,17 @@ export function matchMethodCall(
     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()`,
   // 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
@@ -2590,6 +2792,56 @@ export function rustFieldTypeName(raw: string): string | null {
   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 —
  * `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 {
     const name = inner.slice(0, -'.getState'.length);
     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);
     if (node && importShadowedAt(name, ref, context)) return null;
     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');
 }
 
+// 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
  * arbitrary locally-bound bare calls must never guess a cross-file target. */
 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 start = enclosingScopeStartLine(ref, context) - 1;
   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;
   /** Get cached import mappings for a file */
   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
    * `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
  * 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()`,
  *    where `beforeExit` never fires); network sends happen opportunistically
  *    (startup of long-running commands, daemon interval, bounded await at the
@@ -88,7 +88,7 @@ export interface ClientInfo {
 
 interface ConfigFile {
   enabled: boolean;
-  machine_id: string;
+  machine_id: string | null;
   consent_source: 'installer' | 'default-notice' | 'cli';
   first_run_notice_shown?: boolean;
   updated_at: string;
@@ -159,7 +159,7 @@ export class Telemetry {
   private events: EventLine[] = [];
   private readonly installExitHook: boolean;
   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;
 
   constructor(opts: TelemetryOptions = {}) {
@@ -189,14 +189,17 @@ export class Telemetry {
     const machineId = config?.machine_id ?? null;
     const dnt = this.env.DO_NOT_TRACK;
     if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') {
+      this.clearPending();
       return { enabled: false, decidedBy: 'DO_NOT_TRACK', machineId, configPath: this.configPath };
     }
     const forced = this.env.CODEGRAPH_TELEMETRY;
     if (forced !== undefined && forced !== '') {
       const on = forced !== '0' && forced.toLowerCase() !== 'false';
+      if (!on) this.clearPending();
       return { enabled: on, decidedBy: 'CODEGRAPH_TELEMETRY', machineId, configPath: this.configPath };
     }
     if (config) {
+      if (!config.enabled) this.clearPending();
       return { enabled: config.enabled, decidedBy: 'config', machineId, configPath: this.configPath };
     }
     return { enabled: true, decidedBy: 'default', machineId, configPath: this.configPath };
@@ -215,13 +218,21 @@ export class Telemetry {
     const existing = this.readConfig();
     this.writeConfig({
       enabled,
-      machine_id: existing?.machine_id ?? randomUUID(),
+      machine_id: enabled ? (existing?.enabled && existing.machine_id ? existing.machine_id : randomUUID()) : null,
       consent_source: source,
       first_run_notice_shown: true,
       updated_at: this.now().toISOString(),
     });
     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
 
-  /** 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 {
     if (!this.isEnabled()) return;
     const day = this.utcDay();
@@ -279,6 +290,7 @@ export class Telemetry {
     try {
       this.persistSync();
       this.recoverStaleClaims();
+      let identity = this.getStatus().machineId;
       const claim = this.claimQueue();
       if (!claim) return;
       const { claimPath, lines } = claim;
@@ -298,13 +310,18 @@ export class Telemetry {
         // its explicit consent toggle before any notice can fire, instead of
         // the preAction usage count pre-empting it. An explicit installer/CLI
         // 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();
-        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
       // have created a fresh queue file while we held the claim).
       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 */ }
     } catch {
       /* fail silent */
@@ -335,24 +352,44 @@ export class Telemetry {
     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 {
-    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 {
       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 {
+    const temp = `${this.configPath}.${process.pid}.${randomUUID()}.tmp`;
     try {
       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;
     } catch {
       /* 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).
    */
   private firstRunNotice(): void {
+    if (!this.isEnabled()) return;
     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) {
-      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(
       `codegraph collects anonymous usage stats (no code, paths, or names) — ` +
       `"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.
    */
   persistSync(): void {
+    const status = this.getStatus();
+    if (!status.enabled) return;
     if (this.counts.size === 0 && this.events.length === 0) return;
     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 {
       fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
       const payload = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
@@ -445,6 +481,8 @@ export class Telemetry {
 
   private recoverStaleClaims(): void {
     try {
+      const identity = this.getStatus().machineId;
+      if (!this.canUseIdentity(identity)) return;
       const cutoff = this.now().getTime() - STALE_CLAIM_MS;
       for (const name of fs.readdirSync(this.dir)) {
         if (!name.startsWith('telemetry-queue.sending.')) continue;
@@ -453,7 +491,7 @@ export class Telemetry {
           if (fs.statSync(full).mtimeMs < cutoff) {
             const content = fs.readFileSync(full, 'utf8');
             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 {
           /* fail silent */
@@ -465,9 +503,8 @@ export class Telemetry {
   }
 
   /** 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) =>
       'ev' in line
         ? { event: line.ev, ts: line.ts, props: line.props }
@@ -485,7 +522,7 @@ export class Telemetry {
           },
     );
     const envelope = {
-      machine_id: config.machine_id,
+      machine_id: identity,
       codegraph_version: this.packageVersion(),
       os: process.platform,
       arch: process.arch,
@@ -495,6 +532,7 @@ export class Telemetry {
     };
     const endpoint = this.env.CODEGRAPH_TELEMETRY_ENDPOINT || TELEMETRY_ENDPOINT;
     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 body = JSON.stringify({ ...envelope, events: chunk });
       this.debug(`POST ${endpoint} (${chunk.length} events)`);