mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
Merge remote-tracking branch 'origin/fix/regression-audit'
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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,164 @@
|
||||
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('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(['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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,85 @@
|
||||
# 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.
|
||||
@@ -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';
|
||||
@@ -472,7 +472,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 +495,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 +943,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 +1020,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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
import { JS_BUILT_INS } from './js-builtins';
|
||||
import { resolveViaImport } from './import-resolver';
|
||||
|
||||
/**
|
||||
* Ceiling on how many same-named definitions a FUZZY name-match strategy will
|
||||
@@ -751,6 +752,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 +1072,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 +1676,7 @@ 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);
|
||||
}
|
||||
|
||||
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,178 @@ 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 = resolveViaImport({ ...ref, referenceName: name, referenceKind: 'references' }, context);
|
||||
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');
|
||||
}
|
||||
|
||||
/** A const destructuring is a bound reference, so it is eligible even though
|
||||
* arbitrary locally-bound bare calls must never guess a cross-file target. */
|
||||
function matchDestructuredStoreCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
const source = context.readFile(ref.filePath);
|
||||
if (!source?.includes('.getState')) return null;
|
||||
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 +3330,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-
|
||||
|
||||
Reference in New Issue
Block a user