diff --git a/CHANGELOG.md b/CHANGELOG.md index f8992f2bc..6503fe778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/__tests__/destructured-call-result.test.ts b/__tests__/destructured-call-result.test.ts new file mode 100644 index 000000000..02b50e22d --- /dev/null +++ b/__tests__/destructured-call-result.test.ts @@ -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 = { + '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'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 42f98a0cc..b9fb3180d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -796,7 +796,8 @@ const ESM_EXPORT_LISTS = new WeakMap()]*>)?\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 {