Procházet zdrojové kódy

fix: restore call graph relationships and Steps effects (#1862)

* docs: record regression audit and validation

* test: cover audited call and extraction regressions

* test: cover audited calls and selector guards

* test: cover audited calls and selector guards

* test: cover audited calls and selector guards

* test: cover audited calls and selector guards

* test: cover audited calls and selector guards

* fix: retain qualified call sites and restore language-specific getters

* fix: resolve typed calls and bound store actions

* fix: retain qualified call sites in the native kernel

* docs: explain restored call and Steps coverage

* test: isolate watchdog and cover imported store aliases

* refactor: expose import lookup through resolver context

* refactor: remove import resolver cycle

* test: canonicalize Claude config temp paths

* docs: record review fixes and large TypeScript timings

* test: cover store cache edits and document verification

* perf: cache per-file store eligibility

* fix: observe large benchmarks without a wall timeout

* docs: explain completed indexing benchmarks

docs: explain completed indexing benchmarks
Colby Mchenry před 5 dny
rodič
revize
4297b8e2ed

+ 6 - 0
CHANGELOG.md

@@ -145,6 +145,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- 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.
+
 - 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)

+ 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);

+ 6 - 1
__tests__/kernel-tsjs-parity.test.ts

@@ -115,7 +115,12 @@ function nested(holder) {
     const nested = result.nodes.find((n) => n.name === 'nested' && n.kind === 'function');
     expect(nested).toBeDefined();
     expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === nested!.id)
-      .map((r) => r.referenceName)).toEqual(['readKey', 'readKey', 'readKey', 'readKey']);
+      // Qualified sites are retained for effects; computed keys still make no
+      // receiver claim. All four calls inside arguments must also survive.
+      .map((r) => r.referenceName)).toEqual([
+        'holder.values.get', 'readKey', 'holder.values.get', 'readKey',
+        'readKey', 'holder.deep.values.get', 'readKey',
+      ]);
     expect(result.unresolvedReferences.some((r) => r.referenceName === 'values.get')).toBe(true);
   });
 

+ 8 - 3
__tests__/mcp-callers-truncation.test.ts

@@ -35,7 +35,8 @@ beforeAll(async () => {
       Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
       '\n'
   );
-  // `hot` shares its name with its file, so the answer groups per definition.
+  // Two real `hot` functions exercise per-definition truncation. A filename
+  // is not an overload of its exact-named function (#1809).
   fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
   fs.writeFileSync(
     path.join(tmpDir, 'src', 'hot-callers.ts'),
@@ -43,6 +44,10 @@ beforeAll(async () => {
       Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
       '\n'
   );
+  fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot.ts'), 'export function hot(n: number): number { return n + 1; }\n');
+  fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot-callers.ts'),
+    "import { hot } from './other-hot';\n" +
+    Array.from({ length: CALLERS }, (_, i) => `export function otherHotCaller${i}(): number { return hot(${i}); }`).join('\n') + '\n');
   fs.writeFileSync(
     path.join(tmpDir, 'src', 'fan.ts'),
     Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
@@ -76,8 +81,8 @@ describe('codegraph_callers truncation', () => {
 
   it('marks the cut inside each per-definition section too', async () => {
     const out = await text('codegraph_callers', { symbol: 'hot' });
-    expect(out).toContain('distinct definitions');
-    expect(out).toMatch(/- … \+\d+ more \(pass `limit` to widen\)/);
+    expect(out).toContain('2 distinct definitions');
+    expect(out.match(/- … \+\d+ more \(pass `limit` to widen\)/g)).toHaveLength(2);
     expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
   });
 });

+ 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) => {

+ 3 - 5
__tests__/object-literal-methods.test.ts

@@ -9,11 +9,9 @@
  * MobX/handler maps) real nodes, so `codegraph_node`/`callers` on them resolve
  * instead of returning "not found" and forcing the agent to Read the store.
  *
- * Keyed purely on AST shape — no library names in the implementation — so any
- * same-shaped store is covered. Resolution then falls out of the existing
- * exact-name matcher: every call form (`const {foo}=useStore.getState(); foo()`,
- * `useStore.getState().foo()`, in-store `get().foo()`) reduces to a bare `foo`
- * call that resolves to the action node once it exists.
+ * Extraction is keyed on AST shape. The store-accessor resolver follows
+ * destructured bindings, `useStore.getState().foo()`, and in-store `get().foo()`
+ * to implementations within the store, excluding interface declarations.
  */
 import { describe, it, expect, beforeAll, afterEach } from 'vitest';
 import * as fs from 'fs';

+ 4 - 1
__tests__/react-native-bridge.test.ts

@@ -306,7 +306,8 @@ describe('React Native cross-platform pairing — end to end', () => {
     fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
     fs.writeFileSync(path.join(dir, 'index.ts'),
       "import { NativeModules } from 'react-native';\n" +
-      "export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n");
+      "export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n" +
+      "export function wrongModule() { return NativeModules.Missing.uniquePingMethod(); }\n");
     fs.writeFileSync(path.join(dir, 'RNThing.java'),
       "public class RNThing extends ReactContextBaseJavaModule {\n" +
       "  @Override public String getName() { return \"RNThing\"; }\n" +
@@ -336,6 +337,8 @@ describe('React Native cross-platform pairing — end to end', () => {
          AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%'
          AND s.language != t.language`
     ).get();
+    const wrong = cg.getNodesByKind('function').find(n => n.name === 'wrongModule')!;
+    expect(cg.getOutgoingEdges(wrong.id).filter(e => e.kind === 'calls')).toEqual([]);
     cg.close?.();
     expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
   });

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

@@ -0,0 +1,175 @@
+import { afterAll, beforeAll, 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';
+
+let dir: string;
+let cg: CodeGraph;
+beforeAll(async () => {
+  dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-release-regressions-'));
+  const write = (name: string, source: string) => fs.writeFileSync(path.join(dir, name), source);
+  write('scene.ts', `export class Scene {
+    callbacks = new Set<() => void>();
+    onUpdate(cb: () => void) { this.callbacks.add(cb); }
+    triggerUpdate() { for (const cb of this.callbacks) { cb(); } }
+  }`);
+  write('app.tsx', `import { Scene } from './scene';
+  export class App {
+    scene: Scene = new Scene();
+    componentDidMount() { this.scene.onUpdate(this.triggerRender); }
+    triggerRender() { return 1; }
+  }`);
+  for (const [name, targetExt, callerExt] of [['Reverse', 'tsx', 'ts'], ['Legacy', 'js', 'jsx'], ['LegacyReverse', 'jsx', 'js']]) {
+    write(`${name}.${targetExt}`, `export class ${name} { send() { return 1; } }`);
+    write(`${name}Caller.${callerExt}`, `import { ${name} } from './${name}';
+    export class ${name}Caller {
+      service = new ${name}();
+      send() { return this.service.send(); }
+    }`);
+  }
+  write('store.ts', `import { create } from 'zustand';
+  interface S { fetchUser(): Promise<void>; reset(): void }
+  export const useStore = create<S>((set, get, api) => ({
+    fetchUser: async () => { get().reset(); },
+    reset: () => set({}),
+  }));
+  export const anotherStore = create((set, get) => ({
+    reset: () => set({}),
+  }));`);
+  write('consumer.ts', `import { useStore as current, anotherStore } from './store';
+  function fetchUser() { return 'local'; }
+  export async function loginFlow() {
+    const { fetchUser } = current.getState();
+    await fetchUser();
+  }
+  export function hardReset() { current.getState().reset(); }
+  export function multipleBindings() {
+    const { fetchUser, reset } = current.getState();
+    fetchUser(); reset();
+  }
+  export function otherReset() { anotherStore.getState().reset(); }
+  export function shadowed() {
+    const { fetchUser } = current.getState();
+    { const fetchUser = () => 'shadow'; fetchUser(); }
+  }
+  export function siblingScope(flag: boolean) {
+    if (flag) { const { fetchUser } = current.getState(); }
+    return fetchUser();
+  }
+  export function unknownStore(unknown: any) { unknown.getState().reset(); }
+  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(); }
+  export function rootBlockShadow() { const current = fake; const selected = current(s => s.reset); selected(); }
+  export function fakeSelector() { const selected = fake(s => s.reset); selected(); }
+  export function Screen() {
+    const selected = current((s) => s.reset);
+    const otherSelected = anotherStore(s => s.reset);
+    function captured() { selected(); }
+    function otherCaptured() { otherSelected(); }
+    function parameterShadow(selected: () => void) { selected(); }
+    const arrowShadow = (selected: () => void) => { selected(); };
+    function localShadow() { const selected = () => 1; selected(); }
+    return { captured, otherCaptured, parameterShadow, arrowShadow, localShadow };
+  }
+  export function sibling() { const selected = current(s => s.reset); }
+  export function outside() { selected(); }
+  export function wrongSelector(other: any) {
+    const selected = current(s => other.reset);
+    selected();
+  }
+  export function unknownSelector(unknown: any) {
+    const selected = unknown(s => s.reset);
+    selected();
+  }
+  `);
+  write('effects.ts', `import { client } from './client';
+  export function create() { return 1; }
+  export function effects() {
+    client.user.create({ data: {} });
+    client?.user?.create({ data: {} });
+  }
+  `);
+  write('client.ts', `export const client = {};`);
+  cg = CodeGraph.initSync(dir);
+  await cg.indexAll();
+}, 60000);
+afterAll(() => {
+  cg?.close();
+  if (dir) fs.rmSync(dir, { recursive: true, force: true });
+});
+
+function node(name: string, file?: string, line?: number) {
+  const nodes = [...cg.getNodesByKind('function'), ...cg.getNodesByKind('method')];
+  const found = nodes.find(n => n.qualifiedName === name && (!file || n.filePath === file) && (!line || n.startLine === line));
+  expect(found, `${file ?? ''}:${name}`).toBeDefined();
+  return found!;
+}
+const targets = (id: string) => cg.getOutgoingEdges(id).filter(e => e.kind === 'calls').map(e => e.target);
+
+describe('release-to-main correctness regressions', () => {
+  it('keeps the TSX → TS observer registration and resulting callback flow', () => {
+    expect(targets(node('App::componentDidMount').id)).toContain(node('Scene::onUpdate').id);
+    expect(targets(node('Scene::triggerUpdate').id)).toContain(node('App::triggerRender').id);
+  });
+  it.each(['Reverse', 'Legacy', 'LegacyReverse'])('resolves %s across sibling JS/TS extensions', (name) => {
+    expect(targets(node(`${name}Caller::send`).id)).toEqual([node(`${name}::send`).id]);
+  });
+  it('traces a destructured imported store action ahead of a same-named local function', () => {
+    expect(targets(node('loginFlow').id)).toContain(node('fetchUser', 'store.ts').id);
+    expect(targets(node('loginFlow').id)).not.toContain(node('fetchUser', 'consumer.ts').id);
+  });
+  it('resolves both accessor forms to implementations even with interface signatures and a second store', () => {
+    const reset = node('reset', 'store.ts', 5).id;
+    const other = node('reset', 'store.ts', 8).id;
+    expect(targets(node('fetchUser', 'store.ts').id)).toContain(reset);
+    expect(targets(node('hardReset').id)).toContain(reset);
+    expect(targets(node('hardReset').id)).not.toContain(other);
+    expect(targets(node('otherReset').id)).toContain(other);
+    expect(targets(node('otherReset').id)).not.toContain(reset);
+  });
+  it('traces each action in a declaration with multiple named bindings', () => {
+    const calls = targets(node('multipleBindings').id);
+    expect(calls).toContain(node('fetchUser', 'store.ts').id);
+    expect(calls).toContain(node('reset', 'store.ts', 5).id);
+  });
+  it.each(['shadowed', 'siblingScope'])('does not leak a destructured action into %s', (name) => {
+    expect(targets(node(name).id)).not.toContain(node('fetchUser', 'store.ts').id);
+  });
+  it('follows selectors captured by closures to their own store action', () => {
+    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);
+    expect(calls).not.toContain(node('reset', 'store.ts', 8).id);
+  });
+  it('retains external call sites without binding them to an import or same-named function', () => {
+    const caller = node('effects', 'effects.ts');
+    expect(targets(caller.id)).toEqual([]);
+    expect(targets(node('fakeSelector', 'selectors.ts').id)).not.toContain(node('reset', 'not-a-store.ts').id);
+    const refs = cg.getUnresolvedReferencesFrom(caller.id).filter(r => r.referenceKind === 'calls');
+    expect(refs.map(r => r.referenceName)).toEqual(['client.user.create', 'client.user.create']);
+  });
+  it.each(['unknownStore', 'unknownFactory'])('does not guess an action for %s', (name) => {
+    for (const action of cg.getNodesByKind('function').filter(n => n.filePath === 'store.ts')) {
+      expect(targets(node(name).id)).not.toContain(action.id);
+    }
+  });
+});

+ 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);
+});

+ 2 - 1
__tests__/ts-chained-receiver.test.ts

@@ -3,7 +3,8 @@
  * .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
  * the bare method name for it let every such call exact-match whatever project
  * symbol shared the name, so a storage wrapper's `get` called itself (#1707).
- * Those are dropped, as are untyped identifier chains (#1566). The existing
+ * Those calls stay unresolved, as do untyped identifier chains (#1566);
+ * their qualified source references remain available for effect reporting. The existing
  * `window.MyNs.run()` and `this.<field>.m()` paths remain outside that guard.
  */
 

+ 1 - 1
__tests__/ts-this-field-call.test.ts

@@ -37,7 +37,7 @@ beforeAll(async () => {
       '}\n'
   );
   // Plain JS: the field's type is only known from its `new` initializer.
-  // (resolveMethodOnType matches within one language, so the JS wrapper gets a JS Mailer.)
+  // Its sibling-extension cases are covered by release-main-regressions.test.ts.
   w('legacy-mailer.js', 'class LegacyMailer {\n  send(msg) { return msg; }\n}\nmodule.exports = { LegacyMailer };\n');
   w(
     'legacy.js',

+ 11 - 9
codegraph-kernel/src/tsjs/extractors.rs

@@ -1174,14 +1174,6 @@ impl<'t> Walker<'t> {
                         if is_literal_receiver(r.kind()) {
                             return;
                         }
-                        // `holder.values.get()` has no inferred property type
-                        // (#1566). Dropping the receiver or merely preserving it
-                        // would allow unrelated same-name method guesses. Emit
-                        // nothing, as for host chains (#1707); argument calls are
-                        // visited independently. Mirrors extractCall in TS.
-                        if self.is_unresolved_member_chain(r) {
-                            return;
-                        }
                     }
                     let recv_ident = receiver.filter(|r| {
                         matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
@@ -1193,6 +1185,12 @@ impl<'t> Walker<'t> {
                         } else {
                             callee_name = method_name.to_string();
                         }
+                    } else if receiver.is_some_and(|r| self.is_unresolved_member_chain(r)) {
+                        // Retain the call site for effects without guessing a
+                        // project method. Mirrors the TS extraction path.
+                        let chain = self.text(func).replace("?.", ".");
+                        let Some(chain) = Self::plain_member_name(&chain) else { return };
+                        callee_name = chain;
                     } else if let Some(field) = receiver.and_then(|r| self.this_field_of(r)) {
                         // `this.<field>.<method>()` — keep the field so the
                         // resolver can read its declared type (#1496). Mirrors
@@ -1245,7 +1243,11 @@ impl<'t> Walker<'t> {
     /// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
     fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
         let inner = call.child_by_field_name("function")?;
-        let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
+        Self::plain_member_name(self.text(inner))
+    }
+
+    fn plain_member_name(source: &str) -> Option<String> {
+        let text: String = source.chars().filter(|c| !c.is_whitespace()).collect();
         if text.is_empty() {
             return None;
         }

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

@@ -0,0 +1,448 @@
+# Release-to-main correctness repairs (September 2026)
+
+Compared the installed `@colbymchenry/codegraph@1.6.0` npm bundle (release
+`dfccdf62547fcd76d343344d823a0e1998d3a89f`) with main
+`3ed73bc127323e63153bf6ec8354afa82ce36aaf`. Both ran with the bundle's Node
+24.16.0 on Linux x64, identical fixture revisions/settings and separate
+indexes. Native and forced-WASM probes were kept separate. Main was fetched
+again before this change; the comparison base had not advanced.
+
+## Confirmed losses and repairs
+
+| Loss | Introducing change | Repair |
+|---|---|---|
+| Typed TSX-to-TS field calls, including Excalidraw's observer registration and mutation-to-render flow | `cece072` (#1792) | Use the same JS/TS language family in cached and uncached method lookup. |
+| Destructured Zustand actions lose their callers | `cd4e65b` (#1759) | Trace the actual state binding before rejecting locally bound names. |
+| Adding interface signatures makes store accessor calls ambiguous | `ee83636` (#1780) | Find the implementation inside the identified store, not a globally unique name. |
+| Direct React Native bridge calls disappear | `de5adba` (#1790) | Retain qualified call sites and let the framework validate the module. |
+| Dart extension-type getters disappear in WASM | `ee83636` (#1780) | Apply the bodyless-signature guard only to its intended JS/TS grammars. |
+
+Each introducing commit was checked against its parent with the same minimal
+fixture. The lost relationships were checked against source wiring rather
+than inferred from edge-count differences. The Excalidraw path is
+`Scene.mutateElement → Scene.triggerUpdate → App.triggerRender → App.render
+→ StaticCanvas → renderStaticScene`.
+
+## Remaining suite failures
+
+The nine failing Steps assertions had two causes: external member-chain call
+sites had been discarded before effect classification, and valid Zustand
+selector bindings were blocked as opaque local calls. Qualified external
+references now survive without becoming guessed internal call edges. Store
+selectors require a Zustand factory import, resolve the selected member in
+that store, and respect lexical scope and shadowing. Renamed selections and
+closure captures are covered, including negative cases for unrelated
+factories, stores, parameters, and local declarations.
+
+The tenth failure was a stale callers-truncation fixture: it counted a filename
+as an overload of its exact-named function. The fixture now contains two real
+functions and checks that **both** truncated sections carry their markers.
+The extraction parity expectations now assert the exact retained qualified
+references and all argument calls. No assertion was removed or replaced by a
+skip.
+
+## Validation
+
+- All ten formerly failing assertions pass on native and forced WASM.
+- The final forced-WASM run passes 148 tests across eleven affected suites.
+- Native resolver, framework, graph, context, sync-convergence, and explore
+  budget checks pass. The final expanded guard/parity run passes 94 tests.
+- All 655 extraction tests pass in seven sequential fresh-process batches;
+  the union of passing test names is checked against all 655 original cases.
+- Native/WASM TS/JS parity passes for the torture fixtures, CRLF forms, real
+  source files, and optional/ordinary member chains. Dart parity also passes.
+- The C deep-brace guard, shallow-file checks, worker checks, and built CLI
+  stress checks pass. The two all-language 60,000-level cases remain outside
+  the completed stress result, as explained below.
+
+The original fresh-index corpus checks cover Express, Gin, Django and
+Excalidraw. Source-grounded paths pass 11/12 on release, 9/12 on original main,
+and 11/12 after repair. The remaining Django compiler path is absent in both
+original versions. Original integrity, foreign-key and orphan checks pass on
+all 44 retained corpus indexes; no-op sync preserves all fingerprinted edge
+sets. Existing Gin and Django edit/restore drift is not repaired by this
+change.
+
+## Runtime limitations
+
+The monolithic extraction suite's worker receives `SIGKILL` as its resident
+memory grows to roughly 1.6–1.7 GB; sampled JS heap use is only 70–95 MB. Dense
+C++ fixtures and subsequent indexing setup produce substantial native memory
+growth. Lowering the JS heap or worker count does not resolve it. Fresh
+batches keep peak child RSS below approximately 0.8 GB and run every assertion
+successfully. This distinguishes the resource/lifetime sensitivity from the
+ten reproducible assertion failures, but does not identify the exact native
+allocation-retention cause.
+
+Standalone Scala expressions nested 60,000 levels did not finish within a
+45-second diagnostic budget in either native or WASM; equally deep block
+expressions did not avoid the parser limitation. The existing stress tests
+are unchanged. There is no claim of a full green monolithic suite or a repair
+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.

+ 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())

+ 8 - 9
src/extraction/tree-sitter.ts

@@ -1091,7 +1091,8 @@ export class TreeSitterExtractor {
     // SIGNATURE_METHOD_NODE_TYPES for what falling through would otherwise mint.
     else if (
       this.extractor.methodTypes.includes(nodeType)
-      && (!SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
+      && (!(TS_JS_CHAIN_LANGUAGES.has(this.language) || this.language === 'arkts')
+        || !SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
     ) {
       // TS/JS class fields parse as a methodTypes node; only function-valued
       // fields are methods — a plain field (`public fonts: Fonts;`) is a
@@ -4870,14 +4871,12 @@ export class TreeSitterExtractor {
               TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) &&
               isUnresolvedTsJsChain(receiver, this.source)
             ) {
-              // `holder.values.get()` has no inferred property type (#1566).
-              // Emitting bare `get` exact-matches an unrelated project method;
-              // preserving the chain alone would still allow receiver guessing.
-              // Emit nothing until the property type can be established. This
-              // also covers host chains such as `chrome.storage.local.get()`
-              // (#1707). Calls inside arguments are visited independently.
-              // Mirrored in the kernel's extract_call (tsjs/extractors.rs).
-              return;
+              // Keep the source call for effect reporting, but never collapse
+              // it to a guessed method. The resolver only lets frameworks
+              // with receiver evidence handle these qualified chains.
+              const chain = getNodeText(func, this.source).replace(/\s+/g, '').replace(/\?\./g, '.');
+              if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(chain)) return;
+              calleeName = chain;
             } else {
               calleeName = methodName;
             }

+ 8 - 4
src/resolution/index.ts

@@ -19,7 +19,7 @@ import {
   isInheritanceRef,
   isImportableKind,
 } from './types';
-import { isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
+import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
 import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver';
 import { ResolverPool, minRefsForPool } from './resolver-pool';
 import { resolveAliasBinding } from './alias-binding';
@@ -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));
@@ -472,7 +473,7 @@ export class ReferenceResolver {
           matches = [];
           for (const m of candidates) {
             if (m.kind !== 'method') continue;
-            if (m.language !== language) continue;
+            if (!sameLanguageFamily(m.language, language)) continue;
             const qn = m.qualifiedName;
             if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
           }
@@ -495,7 +496,7 @@ export class ReferenceResolver {
             ownerIndex = new Map<string, Node[]>();
             for (const m of candidates) {
               if (m.kind !== 'method') continue;
-              if (m.language !== language) continue;
+              if (!sameLanguageFamily(m.language, language)) continue;
               const qn = m.qualifiedName;
               const i2 = qn.lastIndexOf('::');
               if (i2 < 0) continue; // single-segment qn can never match `T::m`
@@ -943,7 +944,7 @@ export class ReferenceResolver {
       this.frameworks.some((f) => f.claimsReference?.(ref.referenceName));
     if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre);
     if (!preFilterPass) {
-      return null;
+      return this.gateLanguage(matchJsStoreBindingCall(ref, this.context), ref);
     }
 
     // Function-as-value refs (#756) get a dedicated, strictly-gated path:
@@ -1020,6 +1021,9 @@ export class ReferenceResolver {
     }
     if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw);
     if (fwEarly) return fwEarly;
+    // A retained untyped chain supplies effect/call-site evidence only. In
+    // particular, importing its root does not make the root its call target.
+    if (isUnresolvedJsMemberCall(ref)) return null;
 
     // Strategy 2: Try import-based resolution
     // A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683)

+ 207 - 3
src/resolution/name-matcher.ts

@@ -751,6 +751,10 @@ export function matchByExactName(
   // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
   // large import-heavy (front-end + back-end) repos (#915).
   const bareJs = isBareJsCall(ref, context);
+  if (bareJs) {
+    const storeAction = matchJsStoreBindingCall(ref, context);
+    if (storeAction) return storeAction;
+  }
   const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
     .filter((n) => n.kind !== 'import')
     // Nested locals are only reachable from inside their container (#1230).
@@ -1067,7 +1071,7 @@ export function resolveMethodOnType(
     matches = [];
     for (const m of methodCandidates) {
       if (m.kind !== 'method') continue;
-      if (m.language !== ref.language) continue;
+      if (!sameLanguageFamily(m.language, ref.language)) continue;
       const qn = m.qualifiedName;
       if (qn === want || qn.endsWith(`::${want}`)) {
         matches.push(m);
@@ -1671,6 +1675,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
   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[] {
@@ -2754,8 +2760,8 @@ function matchTsThisFieldCall(
  * The one fallback a TS/JS/Python call-receiver chain keeps (#1683): a STORE
  * ACCESSOR. Zustand's `get()` inside the store factory and
  * `useStore.getState()` outside it hand back the store whose actions are
- * indexed as functions (#1573), so a unique callable of the method's name in
- * the same language family is what `get().reset()` reaches. Nothing else
+ * indexed as functions (#1573). JS/TS resolves the member within that store;
+ * the existing Python fallback still requires a unique callable. Nothing else
  * qualifies: a chain rooted in a project value still says nothing about what
  * the inner call RETURNS — `db.prepare(sql).all()` would bind to any project
  * function named `all` — so it resolves to nothing, exactly like a chain
@@ -2767,6 +2773,9 @@ function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext)
   const inner = m[1];
   const method = m[2];
   if (!(inner === 'get' || inner === 'getState' || inner.endsWith('.getState'))) return null;
+  if (JS_FAMILY.has(ref.language)) {
+    return resolveStoreAction(inner, method, ref, context);
+  }
   const callables = context
     .getNodesByName(method)
     .filter((n) => (n.kind === 'function' || n.kind === 'method') && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId);
@@ -2774,6 +2783,199 @@ function matchStoreAccessorChain(ref: UnresolvedRef, context: ResolutionContext)
   return { original: ref, targetNodeId: callables[0]!.id, confidence: 0.6, resolvedBy: 'exact-match' };
 }
 
+/** Resolve the implementation inside the identified store, not a namesake or
+ * an interface signature elsewhere in the project. Import resolution already
+ * follows aliases/barrels; containment already excludes nested action locals. */
+function resolveStoreAction(inner: string, member: string, ref: UnresolvedRef, context: ResolutionContext, selector = false): ResolvedRef | null {
+  let holders: Node[];
+  if (inner === 'get' || inner === 'getState') {
+    const caller = context.getNodeById?.(ref.fromNodeId);
+    if (!caller) return null;
+    holders = context.getNodesInFile(ref.filePath).filter((n) => {
+      if ((n.kind !== 'constant' && n.kind !== 'variable') || !rangeWithin(caller, n)) return false;
+      const source = context.readFile(n.filePath)?.split('\n').slice(n.startLine - 1, caller.startLine).join('\n') ?? '';
+      // The accessor must actually be a parameter of the enclosing factory.
+      return new RegExp(`\\(\\s*[\\w$]+\\s*,\\s*${inner}\\s*(?:,\\s*[\\w$]+\\s*)?\\)\\s*=>`).test(source);
+    });
+  } else {
+    const name = inner.slice(0, -'.getState'.length);
+    if (!/^[\w$]+$/.test(name)) return null;
+    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) =>
+      n.filePath === ref.filePath && isLexicallyReachable(n, ref, context));
+  }
+  if (holders.length !== 1) return null;
+  const holder = holders[0]!;
+  if (selector) {
+    // Only a Zustand hook promises to return the selector's result. An
+    // arbitrary function accepting that callback is not a store binding.
+    const text = context.readFile(holder.filePath)?.split('\n').slice(holder.startLine - 1, holder.endLine).join('\n') ?? '';
+    const escaped = holder.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const factory = new RegExp(`\\b(?:const|let)\\s+${escaped}\\s*=\\s*([\\w$]+)\\s*[<(]`).exec(text)?.[1];
+    if (!factory || !context.getImportMappings(holder.filePath, holder.language).some(m =>
+      m.localName === factory && m.source === 'zustand' && (m.exportedName === 'create' || m.isDefault))) return null;
+  }
+  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 {
+  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');
+  const code = blankStringContents(stripCommentsForRegex(before, 'typescript'));
+  const name = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  const binding = /\bconst\s*\{([^{}]*)\}\s*=\s*([\w$]+)\.getState\s*\(\s*\)/g;
+  // Compare block identities, not just nesting depth: a binding in a sibling
+  // or already-closed block is not in scope at this call.
+  const stackAt = (end: number): number[] => {
+    const stack: number[] = [];
+    for (let i = 0; i < end; i++) {
+      if (code[i] === '{') stack.push(i);
+      else if (code[i] === '}') stack.pop();
+    }
+    return stack;
+  };
+  const callScope = stackAt(code.length);
+  for (const m of [...code.matchAll(binding)].reverse()) {
+    // Plain named bindings only; defaults, rest and computed keys need their
+    // own value tracing rather than a same-name guess.
+    if (!m[1]!.split(',').some(part => part.trim() === ref.referenceName)) continue;
+    const scope = stackAt(m.index!);
+    if (!scope.every((pos, i) => callScope[i] === pos)) continue;
+    const rest = code.slice(m.index! + m[0].length);
+    // Keep the guard when another declaration shadows the captured const.
+    if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(rest)) return null;
+    return resolveStoreAction(`${m[2]}.getState`, ref.referenceName, ref, context);
+  }
+  return null;
+}
+
+/** Bound action names need not have a same-named definition (selectors may
+ * rename them). The resolver's symbol-existence prefilter must allow them. */
+export function matchJsStoreBindingCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+  if (!isBareJsCall(ref, context)) return null;
+  return matchDestructuredStoreCall(ref, context) ?? matchSelectedStoreCall(ref, context);
+}
+
+/** A qualified untyped chain is useful source evidence, not permission to
+ * infer a property type. Framework resolution runs before this guard. */
+export function isUnresolvedJsMemberCall(ref: UnresolvedRef): boolean {
+  return ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) &&
+    !/^(?:this|window)\./.test(ref.referenceName) &&
+    /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(ref.referenceName);
+}
+
+const SELECTOR_NAMES = new WeakMap<ResolutionContext, Map<string, Set<string>>>();
+
+/** A selector returns the named action from one identified store. Keep the
+ * lexical block identity so closures may capture it but sibling scopes and
+ * shadowing parameters/declarations cannot donate a binding. */
+function matchSelectedStoreCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+  const source = context.readFile(ref.filePath);
+  if (!source?.includes('=>')) return null;
+  let files = SELECTOR_NAMES.get(context);
+  if (!files) { files = new Map(); SELECTOR_NAMES.set(context, files); }
+  let names = files.get(ref.filePath);
+  if (!names) {
+    names = new Set([...source.matchAll(/\bconst\s+([\w$]+)\s*=\s*[\w$]+\s*\(\s*(?:\(\s*[\w$]+\s*\)|[\w$]+)\s*=>/g)].map(m => m[1]!));
+    files.set(ref.filePath, names);
+  }
+  if (!names.has(ref.referenceName)) return null;
+  const name = ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  const lines = source.split('\n');
+  const before = lines.slice(0, ref.line - 1).concat(lines[ref.line - 1]!.slice(0, ref.column)).join('\n');
+  const code = blankStringContents(stripCommentsForRegex(before, 'typescript'));
+  const binding = new RegExp(`\\bconst\\s+${name}\\s*=\\s*([\\w$]+)\\s*\\(\\s*(?:\\(\\s*([\\w$]+)\\s*\\)|([\\w$]+))\\s*=>\\s*([\\w$]+)\\.([\\w$]+)\\s*\\)`, 'g');
+  const stackAt = (end: number): number[] => {
+    const stack: number[] = [];
+    for (let i = 0; i < end; i++) {
+      if (code[i] === '{') stack.push(i);
+      else if (code[i] === '}') stack.pop();
+    }
+    return stack;
+  };
+  const callScope = stackAt(code.length);
+  for (const m of [...code.matchAll(binding)].reverse()) {
+    if ((m[2] ?? m[3]) !== m[4]) continue;
+    if (!stackAt(m.index!).every((pos, i) => callScope[i] === pos)) continue;
+    const rest = code.slice(m.index! + m[0].length);
+    if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(rest) ||
+        hasParameterBinding(rest, name)) return null;
+    return resolveStoreAction(`${m[1]}.getState`, m[5]!, ref, context, true);
+  }
+  return null;
+}
+
+/** Import resolution names the module binding; a nearer parameter or block
+ * declaration can shadow that binding at this particular call site. */
+function importShadowedAt(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
+  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  for (const fn of context.getNodesInFile(ref.filePath)) {
+    if ((fn.kind === 'function' || fn.kind === 'method') && fn.startLine <= ref.line && fn.endLine >= ref.line &&
+        fn.signature && hasParameterBinding(`${fn.signature} {`, escaped)) return true;
+  }
+  const lines = (context.readFile(ref.filePath) ?? '').split('\n');
+  const before = lines.slice(0, ref.line - 1).concat(lines[ref.line - 1]?.slice(0, ref.column) ?? '').join('\n');
+  const code = blankStringContents(stripCommentsForRegex(before, 'typescript'));
+  const stackAt = (end: number): number[] => {
+    const stack: number[] = [];
+    for (let i = 0; i < end; i++) {
+      if (code[i] === '{') stack.push(i);
+      else if (code[i] === '}') stack.pop();
+    }
+    return stack;
+  };
+  const scope = stackAt(code.length);
+  const declarations = new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${escaped}\\b|\\{[^}]*\\b${escaped}\\b)`, 'g');
+  return [...code.matchAll(declarations)].some(m => stackAt(m.index!).every((p, i) => scope[i] === p));
+}
+
+/** Balanced parameter lists also cover function-typed parameters, whose own
+ * parentheses must not make the outer shadow invisible. Conservative when a
+ * parameter's type mentions the same name: leave that call unresolved. */
+function hasParameterBinding(code: string, escapedName: string): boolean {
+  const name = new RegExp(`\\b${escapedName}\\b`);
+  if (new RegExp(`\\b${escapedName}\\s*=>`).test(code)) return true;
+  for (let i = 0; i < code.length; i++) {
+    if (code[i] !== '(' || /\b(?:if|while|for|switch|with)\s*$/.test(code.slice(0, i))) continue;
+    let depth = 1, j = i + 1;
+    for (; j < code.length && depth; j++) {
+      if (code[j] === '(') depth++;
+      else if (code[j] === ')') depth--;
+    }
+    if (depth === 0 && name.test(code.slice(i + 1, j - 1)) &&
+        /^\s*(?::[^=;{]*)?(?:=>|\{)/.test(code.slice(j))) return true;
+  }
+  return false;
+}
+
 /**
  * Split a camelCase or PascalCase string into words.
  */
@@ -3149,6 +3351,8 @@ export function matchReference(
     }
   }
 
+  if (isUnresolvedJsMemberCall(ref)) return null;
+
   // Try strategies in order of confidence
   let result: ResolvedRef | null;
 

+ 5 - 1
src/resolution/types.ts

@@ -119,7 +119,7 @@ export interface ResolutionContext {
    */
   getFileLines?(filePath: string): string[] | null;
   /**
-   * The method-definition nodes matching `typeName::methodName` in `language`
+   * The method-definition nodes matching `typeName::methodName` in the language family
    * exactly `resolveMethodOnType`'s kind/language/qualifiedName-suffix filter,
    * LRU-cached per (language, type, method). The uncached path re-fetches every
    * node sharing the METHOD name (unbounded — tens of thousands on a collision-
@@ -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