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
107 changes: 107 additions & 0 deletions __tests__/alias-binding-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<void> => {
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');
}
});
});
125 changes: 125 additions & 0 deletions src/resolution/alias-binding.ts
Original file line number Diff line number Diff line change
@@ -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<string>(['constant', 'variable', 'property']);

/** Kinds an alias may usefully forward a CALL to. */
const CALLABLE_KINDS = new Set<string>(['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;
}
16 changes: 16 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, Node>();
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;
Expand Down
29 changes: 29 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down