mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 14:32:59 +08:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user