mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 12:00:02 +08:00
fix(js,ts): a super.method() call links to the method of the class's parent (#2818)
A TS/JS call written super.run() is recorded under its bare name, as
issue #2497 found. Inside an override of run every strategy found the
calling method itself, and the self-edge gate declined it, so the call
was unlinked; elsewhere it went to whatever method had the name, often
the class's own or a sibling class's in the same file, and, for a
parent outside the project like HTMLElement, Set or Promise, to a
project namesake.
A call written on super where it starts is now resolved through the
class's resolved extends edges, which the resolver persists before it
resolves calls: the nearest ancestor's method of that name, a static
one for a call in a static member, or nothing when no ancestor in the
project has it. The call may sit in a method, a class field, or an
arrow function inside either. One the index can't place in a class (a
class expression's method, an object literal's) keeps the old path, as
does one after a class keyword in the member, outside comments and
strings. The self-edge gate now reads a TS/JS call at its own column,
so a this.run() on the line of a super.run() keeps its edge.
The issue's first part, a method call on the receiver of an aliased
constructor, already resolves on main since PR #2661 and is unchanged.
Validation: the new test fails 3 of 4 on main 68fcf2f1 and passes 4 of
4 on the fix; the fourth pins the class-expression guard, and a
mutation of each guard fails a test. A/B against main on 22 repos (20
TS/JS, flask and gin): 660 call sites change, all super calls, checked
against each class's extends chain and sampled by hand. 643 gain an
edge to an ancestor's method (304 in three.js, like super.copy(source)
reaching Object3D.copy; 210 in webpack, 85 in lit); 2 move off the
class's own method to the parent's; 4 keep their target, now by the
parent; 11 lose a guess, where the parent is a builtin or a library's
class, like webpack's SortableSet extending Set, or a mixin's
parameter. Nodes and all other edges are
unchanged; 14 repos' dumps, the controls among them, are byte-identical.
A sync that moves a parent, swaps a parent or adds an override to an
intermediate class matches a fresh index. Index time, median of 3
interleaved runs on three.js, webpack, jest and insomnia: +1.7% total,
noise; 7 interleaved rounds on three.js alone: -1.5%.
Fixes #2497
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
53f72fec90
commit
ab92c90a54
@@ -39,6 +39,7 @@ import { clearCppIncluderMemos } from './cpp-includers';
|
||||
import { clearCFeatureVariantMemos } from './c-feature-variants';
|
||||
import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules';
|
||||
import { matchShopifyThemeFile } from './shopify-themes';
|
||||
import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls';
|
||||
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver';
|
||||
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
|
||||
import { resolveAliasBinding } from './alias-binding';
|
||||
@@ -306,6 +307,8 @@ export class ReferenceResolver {
|
||||
private supertypeMemo = new Map<string, { gen: number; supers: string[] }>();
|
||||
// getSupertypeNodes' memo, by node id, under the same generation tag.
|
||||
private supertypeNodeMemo = new Map<string, { gen: number; nodes: Node[] }>();
|
||||
// getSuperclassNodes' memo (`extends` edges alone), the same way.
|
||||
private superclassNodeMemo = new Map<string, { gen: number; nodes: Node[] }>();
|
||||
|
||||
/** Invalidate the getSupertypes memo — call when resolved edges may have advanced. */
|
||||
private advanceSupertypeGeneration(): void {
|
||||
@@ -314,6 +317,7 @@ export class ReferenceResolver {
|
||||
// batches doesn't accrete dead entries.
|
||||
if (this.supertypeMemo.size > 50_000) this.supertypeMemo.clear();
|
||||
if (this.supertypeNodeMemo.size > 50_000) this.supertypeNodeMemo.clear();
|
||||
if (this.superclassNodeMemo.size > 50_000) this.superclassNodeMemo.clear();
|
||||
}
|
||||
// Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
|
||||
// getNodesByKind returns the FULL node list for a kind; it was previously
|
||||
@@ -584,6 +588,7 @@ export class ReferenceResolver {
|
||||
this.methodOwnerIndexCache.clear();
|
||||
this.supertypeMemo.clear();
|
||||
this.supertypeNodeMemo.clear();
|
||||
this.superclassNodeMemo.clear();
|
||||
this.supertypeGen++;
|
||||
this.nodesByKindCache.clear();
|
||||
this.fileExistsMemo.clear();
|
||||
@@ -990,6 +995,18 @@ export class ReferenceResolver {
|
||||
return nodes;
|
||||
},
|
||||
|
||||
getSuperclassNodes: (id: string) => {
|
||||
const hit = this.superclassNodeMemo.get(id);
|
||||
if (hit && hit.gen === this.supertypeGen) return hit.nodes;
|
||||
const nodes: Node[] = [];
|
||||
for (const edge of this.queries.getOutgoingEdges(id, ['extends'])) {
|
||||
const target = this.nodeById(edge.target);
|
||||
if (target) nodes.push(target);
|
||||
}
|
||||
this.superclassNodeMemo.set(id, { gen: this.supertypeGen, nodes });
|
||||
return nodes;
|
||||
},
|
||||
|
||||
getImportMappings: (filePath: string, language) => {
|
||||
const cacheKey = filePath;
|
||||
const cached = this.importMappingCache.get(cacheKey);
|
||||
@@ -1499,6 +1516,12 @@ export class ReferenceResolver {
|
||||
const themeFile = matchShopifyThemeFile(ref, this.context);
|
||||
if (themeFile !== undefined) return themeFile;
|
||||
|
||||
// A TS/JS `super.run(…)` runs the method of the class's parent or a
|
||||
// nearer ancestor, or one outside the project: never a method found by
|
||||
// its name alone (see ./js-super-calls).
|
||||
const superCall = matchJsSuperCall(ref, this.context);
|
||||
if (superCall !== undefined) return superCall;
|
||||
|
||||
// Skip built-in/external references
|
||||
if (this.isBuiltInOrExternal(ref)) {
|
||||
return null;
|
||||
@@ -3810,6 +3833,9 @@ export class ReferenceResolver {
|
||||
*/
|
||||
private gateSuperSelfCall(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result || ref.referenceKind !== 'calls' || result.targetNodeId !== ref.fromNodeId) return result;
|
||||
// A TS/JS call is read where it starts, so a `this.run()` that shares its
|
||||
// line with a `super.run()` keeps its edge (#2497).
|
||||
if (isJsSuperCallLanguage(ref.language)) return isJsSuperCallSite(ref, this.context) ? null : result;
|
||||
const name = ref.referenceName.slice(Math.max(ref.referenceName.lastIndexOf('.'), ref.referenceName.lastIndexOf(':')) + 1);
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return result;
|
||||
const line = (this.context.getFileLines?.(ref.filePath) ?? this.context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* A TS/JS call written `super.run(…)` runs the method of the class's parent
|
||||
* (#2497): the one the class `extends`, or the nearest ancestor of that one
|
||||
* that has the method, and a static method's `super` reaches the parent's
|
||||
* static methods. The extractor keeps the call under its bare name, so the
|
||||
* name strategies answered for it: inside an override of `run` they found the
|
||||
* calling method itself, a self-edge gateSuperSelfCall then declined, and
|
||||
* elsewhere they took any method of the name, like a sibling class's `run` in
|
||||
* the same file, or a project `connectedCallback` for a web component whose
|
||||
* parent is `HTMLElement`.
|
||||
*
|
||||
* The parent comes from the class's resolved `extends` edge, which the
|
||||
* resolver persists before it resolves calls. A parent outside the project,
|
||||
* or an ancestry without the method, links nothing: never a guess by name.
|
||||
*/
|
||||
import type { Node } from '../types';
|
||||
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types';
|
||||
import { escapeRegExp } from '../utils';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
|
||||
const SUPER_CALL_LANGUAGES: ReadonlySet<string> = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
|
||||
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
||||
/** How far up a class's ancestry a `super` call is looked for. */
|
||||
const MAX_ANCESTRY = 8;
|
||||
|
||||
/** Whether `super` calls of `language` are read where each call starts (isJsSuperCallSite). */
|
||||
export function isJsSuperCallLanguage(language: string): boolean {
|
||||
return SUPER_CALL_LANGUAGES.has(language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the call `ref` records is written on `super` where it starts:
|
||||
* `super.run(…)`, the member on the next line or two included. Only the
|
||||
* call's own position counts, so a `this.run()` on the same line is not one.
|
||||
*/
|
||||
export function isJsSuperCallSite(ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
if (ref.referenceKind !== 'calls' || !SUPER_CALL_LANGUAGES.has(ref.language) || !IDENTIFIER.test(ref.referenceName)) return false;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
|
||||
const line = lines?.[ref.line - 1];
|
||||
if (!line || !line.startsWith('super', ref.column)) return false;
|
||||
const written = new RegExp(String.raw`^super\s*\.\s*${escapeRegExp(ref.referenceName)}(?![\w$])`);
|
||||
if (written.test(line.slice(ref.column))) return true;
|
||||
return written.test(lines!.slice(ref.line - 1, ref.line + 2).join('\n').slice(ref.column));
|
||||
}
|
||||
|
||||
/**
|
||||
* The method a TS/JS `super.name(…)` call runs (see the file comment).
|
||||
* Undefined when the call isn't written on `super`, or the index can't place
|
||||
* it in a member of a class (an object literal's method, a class
|
||||
* expression's), which leaves it to the other strategies; null when no
|
||||
* ancestor the project declares has the method.
|
||||
*/
|
||||
export function matchJsSuperCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
|
||||
if (!isJsSuperCallSite(ref, context) || !context.getSuperclassNodes) return undefined;
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
const placed = caller ? classMemberAround(caller, context) : undefined;
|
||||
if (!placed || writesClassBefore(placed.member, ref, context)) return undefined;
|
||||
const target = inheritedMethod(placed.cls, ref.referenceName, placed.member.isStatic === true, context);
|
||||
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'instance-method' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class member a call made in `caller` belongs to, and its class: the
|
||||
* caller itself when it is a method or a field of a class, or the member an
|
||||
* arrow function nested in it is written in (`run() { const go = () =>
|
||||
* super.run(); }`), whose `super` is the member's. A `function` can't use
|
||||
* `super`, so a function node named under a member is an arrow function.
|
||||
*/
|
||||
function classMemberAround(caller: Node, context: ResolutionContext): { member: Node; cls: Node } | undefined {
|
||||
let member = caller;
|
||||
for (let depth = 0; member.kind === 'function' && depth < 4; depth++) {
|
||||
const outer = ownerOf(member, context, (n) => n.kind === 'function' || n.kind === 'method' || n.kind === 'property');
|
||||
if (!outer) return undefined;
|
||||
member = outer;
|
||||
}
|
||||
if (member.kind !== 'method' && member.kind !== 'property') return undefined;
|
||||
const cls = ownerOf(member, context, (n) => n.kind === 'class');
|
||||
return cls ? { member, cls } : undefined;
|
||||
}
|
||||
|
||||
/** The node `node` is declared in, by its qualified name, in its file and around it. */
|
||||
function ownerOf(node: Node, context: ResolutionContext, accept: (n: Node) => boolean): Node | undefined {
|
||||
const cut = node.qualifiedName.lastIndexOf('::');
|
||||
if (cut <= 0) return undefined;
|
||||
return context.getNodesByQualifiedName(node.qualifiedName.slice(0, cut)).find((n) => accept(n) &&
|
||||
n.filePath === node.filePath && n.startLine <= node.startLine && n.endLine >= node.endLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `class` is written between where `member` starts and the call,
|
||||
* outside comments and strings: a class expression's methods have no nodes,
|
||||
* so a call in one is recorded from the member around it (`make() { return
|
||||
* class extends Other { run() { super.run(); } }; }`), and its `super` is
|
||||
* that class's.
|
||||
*/
|
||||
function writesClassBefore(member: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
|
||||
if (!lines || member.startLine > ref.line) return false;
|
||||
const span = lines.slice(member.startLine - 1, ref.line);
|
||||
span[span.length - 1] = (span[span.length - 1] ?? '').slice(0, ref.column);
|
||||
span[0] = (span[0] ?? '').slice(member.startLine === ref.line ? Math.min(member.startColumn, ref.column) : member.startColumn);
|
||||
const text = span.join('\n');
|
||||
if (!/(?<![\w$.])class(?![\w$])/.test(text)) return false;
|
||||
return /(?<![\w$.])class(?![\w$])/.test(blankStringContents(stripCommentsForRegex(text, 'javascript')));
|
||||
}
|
||||
|
||||
/** The nearest method named `name` of `cls`'s ancestors, static or instance as asked. */
|
||||
function inheritedMethod(cls: Node, name: string, isStatic: boolean, context: ResolutionContext): Node | undefined {
|
||||
const seen = new Set<string>([cls.id]);
|
||||
let level = superclassesOf(cls, context);
|
||||
for (let depth = 0; depth < MAX_ANCESTRY && level.length > 0; depth++) {
|
||||
const next: Node[] = [];
|
||||
for (const parent of level) {
|
||||
if (seen.has(parent.id)) continue;
|
||||
seen.add(parent.id);
|
||||
const own = ownMethod(parent, name, isStatic, context);
|
||||
if (own) return own;
|
||||
next.push(...superclassesOf(parent, context));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function superclassesOf(cls: Node, context: ResolutionContext): Node[] {
|
||||
return (context.getSuperclassNodes?.(cls.id) ?? []).filter((n) => n.kind === 'class' && SUPER_CALL_LANGUAGES.has(n.language));
|
||||
}
|
||||
|
||||
/**
|
||||
* A method `cls` itself declares under `name`, static or instance as asked.
|
||||
* Of a TypeScript method's overload signatures and its body, the body, which
|
||||
* comes last.
|
||||
*/
|
||||
function ownMethod(cls: Node, name: string, isStatic: boolean, context: ResolutionContext): Node | undefined {
|
||||
const own = context.getNodesByQualifiedName(`${cls.qualifiedName}::${name}`).filter((m) =>
|
||||
m.kind === 'method' && m.filePath === cls.filePath && m.startLine >= cls.startLine && m.endLine <= cls.endLine &&
|
||||
(m.isStatic === true) === isStatic);
|
||||
return own.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn).pop();
|
||||
}
|
||||
@@ -175,6 +175,13 @@ export interface ResolutionContext {
|
||||
* Optional so external/test contexts compile without it.
|
||||
*/
|
||||
getSupertypeNodes?(id: string): Node[];
|
||||
/**
|
||||
* The classes one class declaration extends, through its own resolved
|
||||
* `extends` edges alone: the parent a `super.m()` call reaches, never an
|
||||
* interface or a class it only implements (#2497). Optional so
|
||||
* external/test contexts compile without it.
|
||||
*/
|
||||
getSuperclassNodes?(id: string): Node[];
|
||||
/**
|
||||
* Look up a node by its id. Lets matchers derive the FROM-symbol's
|
||||
* enclosing-class scope (Swift implicit-self method scoping, `this.X`
|
||||
|
||||
Reference in New Issue
Block a user