Merge remote-tracking branch 'origin/fix/regression-audit'

This commit is contained in:
Colby McHenry
2026-09-14 18:10:50 -05:00
14 changed files with 492 additions and 38 deletions
+6
View File
@@ -145,6 +145,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Calls between JavaScript, JSX and TypeScript files keep their callers and callback flows.
- Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook.
- Steps diagrams retain database operations made through external client chains without inventing internal dependencies.
- Direct React Native bridge calls retain their native implementations and cross-platform relationships.
- Dart extension-type getters remain searchable when using the WebAssembly parser.
- Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461)
- `codegraph callers`, `codegraph callees` and `codegraph impact` now resolve qualified names, group results and JSON edges by definition, and accept `--file` to narrow ambiguous names; thanks @ferrine. (#1512, #1656)
- `codegraph callers`, `codegraph callees` and `codegraph impact` (CLI and MCP) now report missing names with did-you-mean suggestions instead of another symbol's results, and exact matches with no callers stay empty; thanks @uvmplus. (#1473, #1481)
+6 -1
View File
@@ -115,7 +115,12 @@ function nested(holder) {
const nested = result.nodes.find((n) => n.name === 'nested' && n.kind === 'function');
expect(nested).toBeDefined();
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'calls' && r.fromNodeId === nested!.id)
.map((r) => r.referenceName)).toEqual(['readKey', 'readKey', 'readKey', 'readKey']);
// Qualified sites are retained for effects; computed keys still make no
// receiver claim. All four calls inside arguments must also survive.
.map((r) => r.referenceName)).toEqual([
'holder.values.get', 'readKey', 'holder.values.get', 'readKey',
'readKey', 'holder.deep.values.get', 'readKey',
]);
expect(result.unresolvedReferences.some((r) => r.referenceName === 'values.get')).toBe(true);
});
+8 -3
View File
@@ -35,7 +35,8 @@ beforeAll(async () => {
Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
'\n'
);
// `hot` shares its name with its file, so the answer groups per definition.
// Two real `hot` functions exercise per-definition truncation. A filename
// is not an overload of its exact-named function (#1809).
fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
fs.writeFileSync(
path.join(tmpDir, 'src', 'hot-callers.ts'),
@@ -43,6 +44,10 @@ beforeAll(async () => {
Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
'\n'
);
fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot.ts'), 'export function hot(n: number): number { return n + 1; }\n');
fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot-callers.ts'),
"import { hot } from './other-hot';\n" +
Array.from({ length: CALLERS }, (_, i) => `export function otherHotCaller${i}(): number { return hot(${i}); }`).join('\n') + '\n');
fs.writeFileSync(
path.join(tmpDir, 'src', 'fan.ts'),
Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
@@ -76,8 +81,8 @@ describe('codegraph_callers truncation', () => {
it('marks the cut inside each per-definition section too', async () => {
const out = await text('codegraph_callers', { symbol: 'hot' });
expect(out).toContain('distinct definitions');
expect(out).toMatch(/- … \+\d+ more \(pass `limit` to widen\)/);
expect(out).toContain('2 distinct definitions');
expect(out.match(/- … \+\d+ more \(pass `limit` to widen\)/g)).toHaveLength(2);
expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
});
});
+3 -5
View File
@@ -9,11 +9,9 @@
* MobX/handler maps) real nodes, so `codegraph_node`/`callers` on them resolve
* instead of returning "not found" and forcing the agent to Read the store.
*
* Keyed purely on AST shape — no library names in the implementation — so any
* same-shaped store is covered. Resolution then falls out of the existing
* exact-name matcher: every call form (`const {foo}=useStore.getState(); foo()`,
* `useStore.getState().foo()`, in-store `get().foo()`) reduces to a bare `foo`
* call that resolves to the action node once it exists.
* Extraction is keyed on AST shape. The store-accessor resolver follows
* destructured bindings, `useStore.getState().foo()`, and in-store `get().foo()`
* to implementations within the store, excluding interface declarations.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
+4 -1
View File
@@ -306,7 +306,8 @@ describe('React Native cross-platform pairing — end to end', () => {
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
fs.writeFileSync(path.join(dir, 'index.ts'),
"import { NativeModules } from 'react-native';\n" +
"export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n");
"export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n" +
"export function wrongModule() { return NativeModules.Missing.uniquePingMethod(); }\n");
fs.writeFileSync(path.join(dir, 'RNThing.java'),
"public class RNThing extends ReactContextBaseJavaModule {\n" +
" @Override public String getName() { return \"RNThing\"; }\n" +
@@ -336,6 +337,8 @@ describe('React Native cross-platform pairing — end to end', () => {
AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%'
AND s.language != t.language`
).get();
const wrong = cg.getNodesByKind('function').find(n => n.name === 'wrongModule')!;
expect(cg.getOutgoingEdges(wrong.id).filter(e => e.kind === 'calls')).toEqual([]);
cg.close?.();
expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
});
+164
View File
@@ -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);
}
});
});
+2 -1
View File
@@ -3,7 +3,8 @@
* .get(k)`, `document.body.querySelector(s)` — ends in a platform API. Emitting
* the bare method name for it let every such call exact-match whatever project
* symbol shared the name, so a storage wrapper's `get` called itself (#1707).
* Those are dropped, as are untyped identifier chains (#1566). The existing
* Those calls stay unresolved, as do untyped identifier chains (#1566);
* their qualified source references remain available for effect reporting. The existing
* `window.MyNs.run()` and `this.<field>.m()` paths remain outside that guard.
*/
+1 -1
View File
@@ -37,7 +37,7 @@ beforeAll(async () => {
'}\n'
);
// Plain JS: the field's type is only known from its `new` initializer.
// (resolveMethodOnType matches within one language, so the JS wrapper gets a JS Mailer.)
// Its sibling-extension cases are covered by release-main-regressions.test.ts.
w('legacy-mailer.js', 'class LegacyMailer {\n send(msg) { return msg; }\n}\nmodule.exports = { LegacyMailer };\n');
w(
'legacy.js',
+11 -9
View File
@@ -1174,14 +1174,6 @@ impl<'t> Walker<'t> {
if is_literal_receiver(r.kind()) {
return;
}
// `holder.values.get()` has no inferred property type
// (#1566). Dropping the receiver or merely preserving it
// would allow unrelated same-name method guesses. Emit
// nothing, as for host chains (#1707); argument calls are
// visited independently. Mirrors extractCall in TS.
if self.is_unresolved_member_chain(r) {
return;
}
}
let recv_ident = receiver.filter(|r| {
matches!(r.kind(), "identifier" | "simple_identifier" | "field_identifier")
@@ -1193,6 +1185,12 @@ impl<'t> Walker<'t> {
} else {
callee_name = method_name.to_string();
}
} else if receiver.is_some_and(|r| self.is_unresolved_member_chain(r)) {
// Retain the call site for effects without guessing a
// project method. Mirrors the TS extraction path.
let chain = self.text(func).replace("?.", ".");
let Some(chain) = Self::plain_member_name(&chain) else { return };
callee_name = chain;
} else if let Some(field) = receiver.and_then(|r| self.this_field_of(r)) {
// `this.<field>.<method>()` — keep the field so the
// resolver can read its declared type (#1496). Mirrors
@@ -1245,7 +1243,11 @@ impl<'t> Walker<'t> {
/// or member chain (`make`, `d.setdefault`), whitespace stripped (#1683).
fn plain_inner_callee(&self, call: Node<'t>) -> Option<String> {
let inner = call.child_by_field_name("function")?;
let text: String = self.text(inner).chars().filter(|c| !c.is_whitespace()).collect();
Self::plain_member_name(self.text(inner))
}
fn plain_member_name(source: &str) -> Option<String> {
let text: String = source.chars().filter(|c| !c.is_whitespace()).collect();
if text.is_empty() {
return None;
}
@@ -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.
+8 -9
View File
@@ -1091,7 +1091,8 @@ export class TreeSitterExtractor {
// SIGNATURE_METHOD_NODE_TYPES for what falling through would otherwise mint.
else if (
this.extractor.methodTypes.includes(nodeType)
&& (!SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
&& (!(TS_JS_CHAIN_LANGUAGES.has(this.language) || this.language === 'arkts')
|| !SIGNATURE_METHOD_NODE_TYPES.has(nodeType) || this.isInsideClassLikeNode())
) {
// TS/JS class fields parse as a methodTypes node; only function-valued
// fields are methods — a plain field (`public fonts: Fonts;`) is a
@@ -4870,14 +4871,12 @@ export class TreeSitterExtractor {
TS_JS_CHAIN_RECEIVER_TYPES.has(receiver.type) &&
isUnresolvedTsJsChain(receiver, this.source)
) {
// `holder.values.get()` has no inferred property type (#1566).
// Emitting bare `get` exact-matches an unrelated project method;
// preserving the chain alone would still allow receiver guessing.
// Emit nothing until the property type can be established. This
// also covers host chains such as `chrome.storage.local.get()`
// (#1707). Calls inside arguments are visited independently.
// Mirrored in the kernel's extract_call (tsjs/extractors.rs).
return;
// Keep the source call for effect reporting, but never collapse
// it to a guessed method. The resolver only lets frameworks
// with receiver evidence handle these qualified chains.
const chain = getNodeText(func, this.source).replace(/\s+/g, '').replace(/\?\./g, '.');
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*){2,}$/.test(chain)) return;
calleeName = chain;
} else {
calleeName = methodName;
}
+7 -4
View File
@@ -19,7 +19,7 @@ import {
isInheritanceRef,
isImportableKind,
} from './types';
import { isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver';
import { ResolverPool, minRefsForPool } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
@@ -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)
+186 -3
View File
@@ -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;
+1 -1
View File
@@ -119,7 +119,7 @@ export interface ResolutionContext {
*/
getFileLines?(filePath: string): string[] | null;
/**
* The method-definition nodes matching `typeName::methodName` in `language` —
* The method-definition nodes matching `typeName::methodName` in the language family —
* exactly `resolveMethodOnType`'s kind/language/qualifiedName-suffix filter,
* LRU-cached per (language, type, method). The uncached path re-fetches every
* node sharing the METHOD name (unbounded — tens of thousands on a collision-