mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
Four gaps left CommonJS calls on the local alias variable - or on the wrong
function:
- `require('../')` up to the project root produced an empty relative path, so
its `/index.js` candidate was never found;
- a default import never followed `module.exports = X` /
`exports = module.exports = X` / `module.exports = require('./lib/x')`;
- only the first declarator of `var a = require('x'), b = require('y')` was a
binding, so `b()` - a package call - stayed on the local variable;
- `require('./utils').member` was read as the module's DEFAULT export: every
such alias in express resolved to utils' first exported function
(`normalizeType`). It is now the named member, and `exports.name =
function impl(...)`, `module.exports.name = impl` and `module.exports = { a,
b: c }` bind names the export index could not see.
express -1087 +727 (707 `express()` calls reach createApplication; supertest /
node:assert calls no longer edge to the local variable), eslint -750 +827
(`new RuleTester()` reaches the class, not another test's variable), mocha
-83 +90 (`run(...)` no longer lands in another spec file), koa 65 wrong
`request` edges -> `response`, body-parser +20. zod, trpc, hono, typeorm,
axios unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
6.6 KiB
TypeScript
151 lines
6.6 KiB
TypeScript
/**
|
|
* 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';
|
|
import { resolveObjectLiteralBinding } from './name-matcher';
|
|
|
|
/** 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;
|
|
|
|
if (memberName && ['typescript', 'tsx', 'javascript', 'jsx', 'arkts'].includes(aliasNode.language)) {
|
|
const resolved = resolveObjectLiteralBinding(aliasNode, memberName, {
|
|
fromNodeId: aliasNode.id, referenceName: memberName, referenceKind: 'calls',
|
|
filePath: aliasNode.filePath, language: aliasNode.language,
|
|
line: aliasNode.startLine, column: aliasNode.startColumn,
|
|
}, context);
|
|
return resolved ? context.getNodeById?.(resolved.targetNodeId) ?? null : 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 });
|
|
}
|
|
}
|
|
}
|
|
// CommonJS: `exports.setCharset = function setCharset(…)`, `module.exports.x = impl`.
|
|
const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|([A-Za-z_$][\w$]*)\s*;?[ \t]*$)/gm;
|
|
let member: RegExpExecArray | null;
|
|
while ((member = memberRe.exec(content)) !== null) {
|
|
out.push({ exportedName: member[1]!, localName: member[2] ?? member[3]! });
|
|
}
|
|
// `module.exports = { parse, format: formatDate }`.
|
|
const objectRe = /^[ \t]*module\.exports\s*=\s*\{([^{}]*)\}/gm;
|
|
let object: RegExpExecArray | null;
|
|
while ((object = objectRe.exec(content)) !== null) {
|
|
for (const raw of object[1]!.split(',')) {
|
|
const entry = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*)\s*)?$/.exec(raw);
|
|
if (entry) out.push({ exportedName: entry[1]!, localName: entry[2] ?? entry[1]! });
|
|
}
|
|
}
|
|
return out;
|
|
}
|