mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 20:42:56 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }));
|
||||
}
|
||||
})();
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user