mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 20:12:11 +08:00
A named class expression, like register(class Widget extends Base {})
or module.exports = class Router {}, was not indexed as a class, as
issue #2499 found. Its methods became free functions, its fields and
supertypes were dropped, and calls in it had no class to start from,
so its this-calls, super calls and own static calls went unresolved or
to a by-name guess, and calls elsewhere guessed into its methods.
Both engines now index a named class expression as a class, with its
methods, properties, extends and implements. An anonymous one is still
passed over. Its head (class Widget extends Base) is its signature,
which marks it as an expression, and it is never exported by itself.
JavaScript binds the name only inside the expression, and the resolver
now scopes it the same way. A reference written outside every class
expression resolves in a view of the graph that hides class
expressions and their members, so new Widget() elsewhere links
nothing, and a same-named class, function or binding keeps its links.
A name only class expressions carry is unknown there. A reference
inside one sees the whole graph, unless it names another class
expression. A class a CommonJS module exports, module.exports = class
Router {} or exports.Store = class Store {}, is the module's value,
which require hands to every importer, so it is not scoped: it reads
as a declaration does, and a require of it reaches its static methods
and its instances' methods. The reader of CommonJS named exports takes
exports.X = class Y {} too. Super calls in a class expression now
resolve through the super-call matcher.
Validation: the new test fails 11 of 13 on main d5fc1fbb and passes 13
of 13 on the fix, and a mutation of each of 7 parts (the view, the
inside check, the name rule, the expression marker, the class node,
the CommonJS export rule, the named-export reader) fails a test.
A/B against main d5fc1fbb on 22 repos (20 TS/JS, flask and gin): 193
classes are added in 10 repos with their members, and 801 edge sites
change in 7 repos, each bucketed and sampled by hand. 563 are jest's
calls to its global test(), 0.4 guesses that move from one wrong
namesake to another: the old one, a class expression's test() in an
e2e fixture, is now a method. Of the rest, 85 are new extends,
implements and type references of class expressions; 74 calls, news
and references move to the member they are written in; 65 calls and
news gain or change a target, inside class expressions (this-calls,
own static calls) and through require (static calls, typed instance
calls, new); and 14 guesses are removed, among them 3 super calls
whose parent the index can't name. 12 repos' dumps, the controls among
them, are byte-identical. Kernel parity holds on 22 repos. Three sync
scenarios match a fresh index. Index time, median of 5 interleaved
runs: lit +0.8%, webpack +2.7%, jest +0.6%; insomnia +12.8%, though it
indexes byte-identically, holds no class expression, and its resolver
time is the same on both arms, so that gap is other jobs' load.
Fixes #2499
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
7.3 KiB
TypeScript
163 lines
7.3 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`,
|
|
// and a named class expression, `exports.Router = class Router {…}` (#2499).
|
|
const memberRe = /^[ \t]*(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=\s*(?:(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|class\s+(?!extends\b)([A-Za-z_$][\w$]*)|([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] ?? member[4]! });
|
|
}
|
|
// `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;
|
|
}
|
|
|
|
/**
|
|
* The binding a module's default export is as a whole: `export default
|
|
* Page;`, `export { Page as default }`. Null for a declaration or an
|
|
* expression: `export default withAuth(Page)` hands over a call, whose first
|
|
* identifier is no binding of the module's.
|
|
*/
|
|
export function defaultExportBinding(content: string): string | null {
|
|
return /(?:^|;)\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*(?:;|$)/m.exec(content)?.[1] ??
|
|
extractLocalExportAliases(content).find((a) => a.exportedName === 'default')?.localName ?? null;
|
|
}
|