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
This commit is contained in:
Colby Mchenry
2026-09-15 05:30:09 +00:00
committed by GitHub
parent 3ed73bc127
commit 4297b8e2ed
19 changed files with 1209 additions and 44 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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