fix(resolution): a name destructured from a composable reaches what it returns (#2163)

`const { getDefaultActivityRoute } = useDefaultActivity(); getDefaultActivityRoute()`
bound the name locally, which ruled out every cross-file candidate, so the
call resolved to nothing. A bare JS-family call through a name destructured
(`{ key: alias }` included) from a call's result now resolves the callee —
through the file's imports, else the same file, else the project's one
function of that name (Nuxt's `~/` alias) — checks its source returns the
key, and takes the function declared in its body, else the module-level one
it returns. A later declaration at the call's scope shadows the binding.
Returned object literals now count as exporting their names for #2162's
unexported-binding rule.

A/B edges added (removed): mealie +176 (-4), excalidraw +156 (`const { t } =
useI18n()`), halo +58, kit +34 (-14, `validate` now the validator's own),
elk +19; trpc byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 12:15:50 +00:00
committed by GitHub
parent d03e4dbf6f
commit cf449fdad7
3 changed files with 146 additions and 4 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In JavaScript, TypeScript and Vue, a call through a name destructured from a composable or custom hook, like `const { getDefaultActivityRoute } = useDefaultActivity()` or `const { t } = useI18n()`, now reaches the function the hook returns. These calls used to resolve to nothing. mealie, elk, halo and excalidraw each gained dozens to over a hundred such links.
- In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports.
- Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading.
- In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`.
@@ -0,0 +1,70 @@
/**
* A call through a name destructured from a call's result — a composable or
* a custom hook — reaches the function that callee returns under that key:
* `const { getDefaultActivityRoute } = useDefaultActivity()` (mealie, where the
* function is a module-level one the composable returns), `const { t } =
* useI18n()`, `const { login } = useAuth()` (declared in the hook's body).
* The local binding used to rule out every cross-file candidate.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-destructured-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }),
'src/composables/use-default-activity.ts': `function getDefaultActivityRoute(key?: string): string {
return key ?? '/';
}
export default function useDefaultActivity() {
return { getDefaultActivityRoute };
}
`,
'src/hooks/useAuth.ts': `export function useAuth() {
function login(user: string) {
return user;
}
const logout = () => null;
return { login, signOut: logout };
}
`,
'src/pages/index.ts': `import useDefaultActivity from '../composables/use-default-activity';
import { useAuth } from '../hooks/useAuth';
export function go() {
const { getDefaultActivityRoute } = useDefaultActivity();
const { login, signOut: leave } = useAuth();
login('ada');
leave();
return getDefaultActivityRoute('x');
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('names destructured from a call', () => {
it('reach what the callee returns under that key', () => {
const go = cg.getNodesInFile('src/pages/index.ts').find((n) => n.name === 'go')!;
const targets = cg
.getOutgoingEdges(go.id)
.filter((e) => e.kind === 'calls')
.map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`);
expect(targets).toContain('src/composables/use-default-activity.ts:getDefaultActivityRoute');
expect(targets).toContain('src/hooks/useAuth.ts:login');
});
});
+75 -4
View File
@@ -796,7 +796,8 @@ const ESM_EXPORT_LISTS = new WeakMap<ResolutionContext, Map<string, { module: bo
* symbol: sveltekit's `generate_manifest.js` keeps an unexported `resolve`
* that twenty other files' `resolve(…)` calls went to. Classic scripts,
* CommonJS, `declare global` and `.d.ts` files, members of a class or
* namespace (qualified names), names a default-exported object literal lists,
* namespace (qualified names), names a default-exported or returned object
* literal lists,
* and anything not declared by a statement of its own (an object literal's
* member, `proto.x = function x() {}`) are exempt.
*/
@@ -820,9 +821,11 @@ function isUnexportedModuleBinding(candidate: Node, context: ResolutionContext):
}
}
for (const m of source.matchAll(/^[ \t]*export\s+(?:default|=)\s+([A-Za-z_$][\w$]*)\s*;?\s*$/gm)) names.add(m[1]!);
// `export default { getAdapter, adapters: known }` exposes its shorthand and value names.
for (const m of code.matchAll(/^[ \t]*export\s+default\s+\{([^}]*)\}/gm)) {
for (const item of m[1]!.split(',')) {
// `export default { getAdapter, adapters: known }` exposes its shorthand and
// value names; so does a `return { getDefaultActivityRoute, … }` (a
// composable hands the function out through its result).
for (const m of code.matchAll(/^[ \t]*export\s+default\s+\{([^}]*)\}|\breturn\s*\{([^{}]*)\}/gm)) {
for (const item of (m[1] ?? m[2])!.split(',')) {
const value = item.includes(':') ? item.split(':').pop()! : item;
const id = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(value)?.[1];
if (id) names.add(id);
@@ -2602,6 +2605,8 @@ export function matchByExactName(
if (bareJs) {
const storeAction = matchJsStoreBindingCall(ref, context);
if (storeAction) return storeAction;
const returned = matchDestructuredCallResult(ref, context);
if (returned) return returned;
// `import { useQuery } from '@tanstack/react-query'`: the call means the
// package's, and no same-named project symbol.
if (isOutOfRepoBinding(ref.referenceName, ref, context)) return null;
@@ -5466,6 +5471,72 @@ function matchDestructuredStoreCall(ref: UnresolvedRef, context: ResolutionConte
return null;
}
/**
* A bare call through a name destructured from a call's result — a composable
* or custom hook, `const { getDefaultActivityRoute } = useDefaultActivity()`
* (mealie), `const { login } = useAuth()` — is the function the callee returns
* under that key: one declared in the callee's own body, else a top-level one
* of the callee's module (returned as `{ getDefaultActivityRoute, … }`). The
* callee is resolved through the file's imports (or found in the same file),
* and its source must return the key; a later declaration of the name at the
* call's scope shadows the binding. The local binding otherwise ruled out
* every cross-file candidate, so the call resolved to nothing.
*/
function matchDestructuredCallResult(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const source = context.readFile(ref.filePath);
if (!source || !/\b(?:const|let|var)\s*\{/.test(source)) 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 = /\b(?:const|let|var)\s*\{([^{}]*)\}\s*=\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\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()) {
let key: string | null = null;
for (const part of m[1]!.split(',')) {
const [k, v] = part.split(':').map((x) => x.trim().replace(/\s*=.*$/, ''));
if ((v ?? k) === ref.referenceName && /^[A-Za-z_$][\w$]*$/.test(k ?? '')) key = k!;
}
if (!key) 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)) return null;
const calleeName = m[2]!;
const imported = context.resolveImport?.({ ...ref, referenceName: calleeName, referenceKind: 'calls' });
// Through the import; else the same file's; else the one function of that
// name in the project (an alias the import resolver can't follow, like
// Nuxt's `~/composables/…`) — the returned key is checked below either way.
const holders = context.getNodesByName(calleeName).filter((n) =>
(n.kind === 'function' || n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language));
const callee = (imported && context.getNodeById?.(imported.targetNodeId)) ??
holders.find((n) => n.filePath === ref.filePath) ??
(holders.length === 1 ? holders[0] : undefined);
if (!callee || !sameLanguageFamily(callee.language, ref.language)) return null;
const calleeText = (context.getFileLines?.(callee.filePath) ?? context.readFile(callee.filePath)?.split('\n') ?? [])
.slice(callee.startLine - 1, callee.endLine).join('\n');
if (!new RegExp(`\\breturn\\s*\\{[^]*?\\b${key}\\b`).test(calleeText)) return null;
const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'constant' || n.kind === 'variable';
const inFile = context.getNodesInFile(callee.filePath);
const inner = inFile.filter((n) => n.name === key && callable(n) && n.id !== callee.id && rangeWithin(n, callee) &&
!inFile.some((f) => f.id !== callee.id && f.id !== n.id && (f.kind === 'function' || f.kind === 'method') &&
rangeWithin(f, callee) && rangeWithin(n, f) && !sameRange(f, n)));
const top = inner.length > 0 ? inner : inFile.filter((n) => n.name === key && callable(n) && !n.qualifiedName.includes('::') &&
!inFile.some((f) => (f.kind === 'function' || f.kind === 'method') && f.id !== n.id && rangeWithin(n, f) && !sameRange(f, n)));
const target = top.sort((a, b) => Number(b.kind === 'function') - Number(a.kind === 'function'))[0];
if (!target) return null;
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'instance-method' };
}
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 {