diff --git a/__tests__/alias-binding-resolution.test.ts b/__tests__/alias-binding-resolution.test.ts new file mode 100644 index 000000000..b7105ce42 --- /dev/null +++ b/__tests__/alias-binding-resolution.test.ts @@ -0,0 +1,107 @@ +/** + * Calls through an alias binding. + * + * A name bound to nothing but another symbol — `export const alias = fn`, + * `export { fn as alias }`, `export const api = { run: fn }`, or a same-file + * `const local = fn` — used to resolve to the BINDING, one hop short of the + * function. The edge existed, so nothing looked broken, but `callers fn` omitted + * every caller that went through the alias and reported a confident zero while + * `callers alias` found them. + * + * Specifiers here are extensionless so these cases stand independently of + * `.js`-specifier resolution. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +describe('calls through an alias binding reach the aliased symbol', () => { + let cg: CodeGraph; + let dir: string; + + afterEach(() => { + if (cg) cg.destroy(); + if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const index = async (files: Record): Promise => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alias-')); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), content); + } + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + }; + + const callersOf = (name: string): string[] => { + const target = cg.getNodesByKind('function').find((n) => n.name === name); + expect(target, `fixture symbol ${name} was not indexed`).toBeDefined(); + return cg.getCallers(target!.id).map((c) => c.node.name); + }; + + it('follows `export const alias = fn`', async () => { + await index({ + 'impl.ts': 'export function realImpl(): number { return 1; }\nexport const aliasName = realImpl;\n', + 'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n", + }); + expect(callersOf('realImpl')).toContain('consumerFn'); + }); + + it('follows a local `export { fn as alias }` clause', async () => { + // The declaration carries no `export` keyword, so extraction does not flag + // it exported — the export index must still bind the renamed export to it. + await index({ + 'impl.ts': 'function realImpl(): number { return 1; }\nexport { realImpl as aliasName };\n', + 'consumer.ts': "import { aliasName } from './impl';\nexport function consumerFn(): number { return aliasName(); }\n", + }); + expect(callersOf('realImpl')).toContain('consumerFn'); + }); + + it('follows a function reference held in an object-literal property', async () => { + await index({ + 'impl.ts': 'export function realImpl(): number { return 1; }\nexport const api = { run: realImpl };\n', + 'consumer.ts': "import { api } from './impl';\nexport function consumerFn(): number { return api.run(); }\n", + }); + expect(callersOf('realImpl')).toContain('consumerFn'); + }); + + it('follows a same-file alias binding', async () => { + await index({ + 'impl.ts': + 'function realImpl(): number { return 1; }\n' + + 'const localAlias = realImpl;\n' + + 'export function consumerFn(): number { return localAlias(); }\n', + }); + expect(callersOf('realImpl')).toContain('consumerFn'); + }); + + it('leaves a genuine wrapper pointing at the wrapper, not the wrapped function', async () => { + // `wrapper` is a real function, not an alias: the call site calls IT. + await index({ + 'impl.ts': + 'export function realImpl(): number { return 1; }\n' + + 'export const wrapper = (): number => realImpl();\n', + 'consumer.ts': "import { wrapper } from './impl';\nexport function consumerFn(): number { return wrapper(); }\n", + }); + expect(callersOf('realImpl')).not.toContain('consumerFn'); + }); + + it('does not hop when the aliased name is ambiguous across files', async () => { + // Two same-named callables and no same-file declaration to prefer: a hop + // would have to guess, and a wrong edge is worse than a missing one. + await index({ + 'one.ts': 'export function shared(): number { return 1; }\n', + 'two.ts': 'export function shared(): number { return 2; }\n', + 'alias.ts': "import { shared } from './one';\nexport const aliasName = shared;\n", + 'consumer.ts': "import { aliasName } from './alias';\nexport function consumerFn(): number { return aliasName(); }\n", + }); + + const sharedNodes = cg.getNodesByKind('function').filter((n) => n.name === 'shared'); + expect(sharedNodes).toHaveLength(2); + for (const node of sharedNodes) { + expect(cg.getCallers(node.id).map((c) => c.node.name)).not.toContain('consumerFn'); + } + }); +}); diff --git a/src/resolution/alias-binding.ts b/src/resolution/alias-binding.ts new file mode 100644 index 000000000..408f9ba73 --- /dev/null +++ b/src/resolution/alias-binding.ts @@ -0,0 +1,125 @@ +/** + * Alias bindings: a name bound to nothing but another symbol. + * + * export const alias = realImpl; // p1 — cross-file through an import + * const local = realImpl; // p6 — same file + * export const api = { run: impl }; // p5 — property holding a function ref + * export { realImpl as alias }; // p2 — a local export clause + * + * Extraction records the binding itself as a `constant`/`variable` node and + * stores its initializer in `signature` (`"= realImpl"`), so a call through the + * alias resolves to the BINDING, not the function. `callers realImpl` then + * omits every caller that went through the alias and reports a confident zero, + * while `callers alias` finds them — the edge exists, it just terminates one + * hop short. A local `export { X as Y }` clause is worse: the exported name + * matches no declaration at all, so resolution fails outright. + * + * A call through an alias IS a call to the aliased function, so these hops are + * only ever applied to `calls` refs. A `references` edge to the binding is + * correct as-is — reading the alias as a value is a genuine use of the alias. + */ + +import type { Node } from '../types'; +import type { ResolutionContext } from './types'; + +/** Kinds that can be a pure alias for another symbol. */ +const ALIAS_BINDING_KINDS = new Set(['constant', 'variable', 'property']); + +/** Kinds an alias may usefully forward a CALL to. */ +const CALLABLE_KINDS = new Set(['function', 'method', 'class', 'component']); + +/** `= identifier`, optionally with a cast or trailing semicolon, and nothing else. */ +const BARE_ALIAS_RE = /^=\s*([A-Za-z_$][\w$]*)\s*(?:as\s+[\w.<>[\]]+\s*)?;?$/; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The symbol name an alias binding forwards to, or null when the initializer is + * anything else (a call, a literal, an expression — all genuine definitions). + * + * `memberName` targets a property of an object-literal initializer + * (`= { run: impl }` for `api.run()`), including ES shorthand (`= { impl }`). + */ +export function aliasTargetName( + signature: string | undefined | null, + memberName: string | null +): string | null { + if (!signature) return null; + const initializer = signature.trim(); + + if (memberName) { + const key = escapeRegExp(memberName); + const explicit = new RegExp(`[{,]\\s*${key}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`).exec(initializer); + if (explicit) return explicit[1]!; + // `{ impl }` — shorthand binds the property to the same-named symbol. + const shorthand = new RegExp(`[{,]\\s*(${key})\\s*[,}]`).exec(initializer); + if (shorthand) return shorthand[1]!; + return null; + } + + const bare = BARE_ALIAS_RE.exec(initializer); + return bare ? bare[1]! : null; +} + +/** + * The callable an alias binding forwards to. + * + * Prefers a declaration in the alias's own file: an alias almost always names a + * local symbol or one it imported, and a same-file hit needs no disambiguation. + * Cross-file is accepted only when the name is unique in the project, so an + * ambiguous name yields no hop rather than an invented edge — a wrong edge is + * worse than a missing one. + */ +export function resolveAliasBinding( + aliasNode: Node, + memberName: string | null, + context: ResolutionContext +): Node | null { + if (!ALIAS_BINDING_KINDS.has(aliasNode.kind)) return null; + + const targetName = aliasTargetName(aliasNode.signature, memberName); + if (!targetName || targetName === aliasNode.name) return null; + + const candidates = context.getNodesByName(targetName).filter((n) => CALLABLE_KINDS.has(n.kind)); + if (candidates.length === 0) return null; + + const sameFile = candidates.filter((n) => n.filePath === aliasNode.filePath); + if (sameFile.length === 1) return sameFile[0]!; + if (sameFile.length > 1) return null; + return candidates.length === 1 ? candidates[0]! : null; +} + +/** + * Local export clauses: `export { realImpl as alias }` / `export { realImpl }` + * with no `from` source. + * + * `extractReExports` only models the `export … from './other'` form, so a local + * clause leaves the exported name bound to nothing the export index knows — + * importing `alias` matches no declaration and resolution falls through to the + * name-matcher, which cannot cross the rename (a false 0 callers). + * + * Type-only specifiers are skipped: they carry no runtime call. + */ +export function extractLocalExportAliases(content: string): Array<{ exportedName: string; localName: string }> { + const out: Array<{ exportedName: string; localName: string }> = []; + // `export { … }` NOT followed by `from` — the `from` form is a re-export. + const clauseRe = /export\s*\{([^}]*)\}\s*(?!\s*from)[;\n]/g; + let clause: RegExpExecArray | null; + while ((clause = clauseRe.exec(content)) !== null) { + for (const raw of clause[1]!.split(',')) { + const specifier = raw.trim(); + if (!specifier || /^type\s/.test(specifier)) continue; + const renamed = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(specifier); + if (renamed) { + out.push({ localName: renamed[1]!, exportedName: renamed[2]! }); + continue; + } + if (/^[A-Za-z_$][\w$]*$/.test(specifier)) { + out.push({ localName: specifier, exportedName: specifier }); + } + } + } + return out; +} diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..3ac66f499 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; +import { extractLocalExportAliases } from './alias-binding'; import { resolveWorkspaceImport } from './workspace-packages'; import { resolveMethodOnType, @@ -90,12 +91,27 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE let idx = perFile.get(filePath); if (!idx) { idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined }; + // Every declaration, exported or not: a local `export { impl as alias }` + // clause exports a declaration the extractor never flagged isExported. + const declared = new Map(); for (const n of context.getNodesInFile(filePath)) { + if (!declared.has(n.name)) declared.set(n.name, n); if (!n.isExported) continue; if (!idx.byName.has(n.name)) idx.byName.set(n.name, n); if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n; if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n; } + // Bind names introduced by a local export clause to their declarations, so + // an importer asking for the renamed name gets the real symbol instead of + // falling through to the name-matcher (which cannot cross the rename). + const content = context.readFile(filePath); + if (content && content.includes('export')) { + for (const { exportedName, localName } of extractLocalExportAliases(content)) { + if (idx.byName.has(exportedName)) continue; + const decl = declared.get(localName); + if (decl) idx.byName.set(exportedName, decl); + } + } perFile.set(filePath, idx); } return idx; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 598e5e479..fb4984652 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -19,6 +19,7 @@ import { import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; +import { resolveAliasBinding } from './alias-binding'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; @@ -852,7 +853,35 @@ export class ReferenceResolver { /** * Resolve a single reference */ + /** + * Resolve one reference, then forward calls that landed on an alias binding + * to the symbol the alias names (see ./alias-binding). + * + * The strategies below resolve `alias()` to the BINDING, which is one hop + * short of the truth: the real function then reports zero callers even though + * the edge was built. Applied here rather than inside each strategy because + * every strategy can produce such a target — import, name-match, or framework. + */ resolveOne(ref: UnresolvedRef): ResolvedRef | null { + const resolved = this.resolveOneStrategies(ref); + if (!resolved || ref.referenceKind !== 'calls') return resolved; + + const target = this.queries.getNodeById(resolved.targetNodeId); + if (!target) return resolved; + + const dot = ref.referenceName.lastIndexOf('.'); + const memberName = dot >= 0 ? ref.referenceName.slice(dot + 1) : null; + const forwarded = resolveAliasBinding(target, memberName, this.context); + if (!forwarded || forwarded.id === resolved.targetNodeId) return resolved; + + return { + ...resolved, + targetNodeId: forwarded.id, + confidence: Math.min(resolved.confidence, 0.85), + }; + } + + private resolveOneStrategies(ref: UnresolvedRef): ResolvedRef | null { // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { return null;