Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions __tests__/bare-call-no-method.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,34 @@ describe('a receiver-less JS/TS call never binds to a method (#1714)', () => {
expect(names).toContain('test');
});

it('a const that picks a same-named store action out of a hook is a re-binding, not a local definition', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1714-'));
fs.writeFileSync(path.join(tempDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n');
fs.writeFileSync(
path.join(tempDir, 'store.ts'),
"import { create } from 'zustand'\nexport const useStore = create((set) => ({\n setZipUri: (zipUri: string) => set({ zipUri }),\n reset: () => set({}),\n}))\n"
);
fs.writeFileSync(
path.join(tempDir, 'screen.ts'),
[
"import { useStore } from './store'",
'export function onZipComplete(uri: string) {',
' const setZipUri = useStore((s) => s.setZipUri)',
' const { reset } = useStore.getState()',
' setZipUri(uri)',
' reset()',
'}',
'',
].join('\n')
);
cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();
const from = cg.getNodesByKind('function').find((n) => n.name === 'onZipComplete')!;
const targets = cg.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls').map((e) => cg!.getNode(e.target)).map((n) => `${n?.filePath}:${n?.name}`);
expect(targets).toContain('store.ts:setZipUri');
expect(targets).toContain('store.ts:reset');
});

it('keeps `other.serialize()` — a call through a receiver', async () => {
const callees = await callsFromMethod(
[
Expand Down
30 changes: 25 additions & 5 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,9 +685,17 @@ function isBareJsCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
const LOCAL_BINDING_MEMO = new WeakMap<ResolutionContext, Map<string, boolean>>();

/**
* Whether a JS/TS file binds `name` itself — as a `const`/`let`/`var`/
* `function`/`class` declaration (destructuring included) or as a parameter
* of a function or arrow. Such a binding shadows every same-named symbol in
* Whether a JS/TS file binds `name` itself — as a plain `const`/`let`/`var`/
* `function`/`class` declaration or as a parameter of a function or arrow.
* A binding that only re-names a same-named member of something defined
* elsewhere is NOT one: `const { fetchUser } = useStore.getState()` and the
* selector `const setZipUri = useStore((s) => s.setZipUri)` are how a store
* action reaches its caller, and the store-action resolution follows exactly
* those shapes — treating them as local would drop the `loginFlow → fetchUser`
* edge the graph is built to hold. A plain alias with a fallback (`const now =
* opts.now || Date.now`) is still local: on a Kotlin+JS app it otherwise
* landed 24 `now()` calls on a Kotlin test's `private val now`. A definition
* shadows every same-named symbol in
* other files, so a bare call to it has no cross-file candidate: the
* `resolve` of `new Promise((resolve, reject) => …)`, a spec's
* `const transform = await makeTransform()`, a factory's `const now =
Expand All @@ -711,12 +719,24 @@ function isLocallyBoundJsName(name: string, filePath: string, context: Resolutio
// `const { name } = require('./m')` / `= await import('./m')` binds an IMPORT,
// not a shadow: the symbol lives in the other file and the call means it.
const declRe = new RegExp(
'\\b(?:const|let|var)\\s+(?:' + n + '\\b|[{\\[][^;=]*?\\b' + n + '\\b[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?',
'\\b(?:const|let|var)\\s+' + n + '\\b\\s*(?:=\\s*([^;\\n]*))?',
'g'
);
let bound = false;
// A selector: an arrow whose body is the same-named member of its own
// argument — `useStore((s) => s.setZipUri)`, `useSelector((st) => st.now)`.
// `() => Date.now()` is not one: the member is not picked off a parameter.
const selector = new RegExp('\\(?\\s*([\\w$]+)\\s*\\)?\\s*=>\\s*[({]?\\s*\\1\\.' + n + '\\b');
for (const m of source.matchAll(declRe)) {
if (!/^\s*(?:await\s+)?(?:require|import)\s*\(/.test(m[1] ?? '')) { bound = true; break; }
const init = m[1] ?? '';
// `const x = require(…)` is an import; `const setZipUri = useStore((s) =>
// s.setZipUri)` picks a same-named member out of something defined
// elsewhere. Neither defines the name — the graph's symbol is what it means.
// A plain alias with a fallback, `const now = opts.now || Date.now`, IS a
// local binding: nothing in the graph is what that call means.
if (/^\s*(?:await\s+)?(?:require|import)\s*\(/.test(init) || selector.test(init)) continue;
bound = true;
break;
}
if (!bound) {
bound =
Expand Down