From ab92c90a540f419ab9d0eef841925aa1a85df43d Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 22 Oct 2026 22:11:29 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/js-super-calls.test.ts | 203 +++++++++++++++++++++++++++++++ src/resolution/index.ts | 26 ++++ src/resolution/js-super-calls.ts | 139 +++++++++++++++++++++ src/resolution/types.ts | 7 ++ 5 files changed, 376 insertions(+) create mode 100644 __tests__/js-super-calls.test.ts create mode 100644 src/resolution/js-super-calls.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 644ee1d6..82873bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -196,6 +196,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. - In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading. - In JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761) +- In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) - In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading. - In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading. - In Go, a struct now links to the types its fields are declared with, like etcd's `PutResponse` holding a `*ResponseHeader` or gin's `Context` holding a `HandlersChain`, so impact on `ResponseHeader` or `HandlersChain` reaches every struct that holds one and the code that uses those structs, and `codegraph_node` lists a struct's field types. Before, a struct linked only to the types it embeds. A field written through another package, like `*pb.ResponseHeader`, links to that package's type, one from outside your project, like `context.Context`, links to nothing, the types an embedded generic is filled in with, like the `Pod` in client-go's `listers.ResourceIndexer[*v1.Pod]`, link too, and a struct that holds itself, like a list node's `next *node`, doesn't link to itself. `codegraph_explore` still answers a question about a flow with the code that runs it, not with the structs that merely hold one of its types. Re-index Go projects after upgrading. diff --git a/__tests__/js-super-calls.test.ts b/__tests__/js-super-calls.test.ts new file mode 100644 index 00000000..357ce97d --- /dev/null +++ b/__tests__/js-super-calls.test.ts @@ -0,0 +1,203 @@ +/** + * A TS/JS call written `super.run(…)` runs the method of the class's parent, + * or of the nearest ancestor that has it (#2497). The extractor keeps such a + * call under its bare name, so inside an override of `run` every strategy + * found the calling method itself and the self-edge was declined, leaving the + * call unlinked; elsewhere the call went to whatever method had the name, like + * a sibling class's in the same file. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BASE = `export class Base { + run() { return 1; } + init() {} + static make() { return new Base(); } + make() { return 2; } +} +`; + +const CHILDREN = `import { Base } from './base'; +export class Decoy { run() { return 'decoy'; } missing() {} } +export class Child extends Base { + run() { return super.run(); } +} +export class Worker extends Base { + work() { return super.run(); } +} +export class Lonely extends Base { + go() { return super.missing(); } +} +export class Mid extends Base {} +export class Leaf extends Mid { + run() { return super.run(); } +} +export class Mid2 extends Base { + run() { return 3; } +} +export class Leaf2 extends Mid2 { + run() { return super.run(); } +} +export class Maker extends Base { + static make() { return super.make(); } + make() { return super.make(); } + constructor() { + super(); + super.init(); + } + later = () => super.init(); + each() { return [1].map(() => super.run()); } + named() { + const go = () => super.run(); + return go(); + } + noted() { + // runs the parent class's run, as webpack writes "class-static-block" + const kind = 'class-static-block'; + return super.run(kind); + } + split() { + return super + .run(); + } +} +export class Counter extends Base { + run(n) { return n > 0 ? this.run(n - 1) : super.run(); } +} +`; + +const OUTSIDE = `export class Widget extends HTMLElement { + connectedCallback() { super.connectedCallback(); } + attach() { return super.connectedCallback(); } +} +export class Panel { + connectedCallback() {} +} +`; + +describe.each(['js', 'ts'])('a super call reaches the parent class (#2497): %s', (ext) => { + let root = ''; + let graph: CodeGraph | null = null; + + afterEach(() => { + graph?.close(); + graph = null; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + /** Every `calls` edge the index resolved (synthesized dispatch left out), by qualified names. */ + function calls(g: CodeGraph): string[] { + const out: string[] = []; + const label = (id: string) => { + const n = g.getNode(id)!; + return `${n.isStatic ? 'static ' : ''}${n.qualifiedName}`; + }; + for (const kind of ['method', 'function'] as const) { + for (const node of g.getNodesByKind(kind)) { + for (const e of g.getOutgoingEdges(node.id)) { + if (e.kind !== 'calls' || e.metadata?.synthesizedBy) continue; + out.push(`${label(e.source)} -> ${label(e.target)} [${e.metadata?.resolvedBy}]`); + } + } + } + return out.sort(); + } + + it('links the parent\'s or the nearest ancestor\'s method, of the caller\'s kind, and nothing else', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-super-call-')); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, `src/base.${ext}`), BASE); + fs.writeFileSync(path.join(root, `src/children.${ext}`), CHILDREN); + fs.writeFileSync(path.join(root, `src/outside.${ext}`), OUTSIDE); + graph = await CodeGraph.init(root, { index: true }); + + expect(calls(graph)).toEqual([ + // The issue's override, and its renamed form: not the sibling Decoy's + // or Child's `run`, which share the file. + 'Child::run -> Base::run [instance-method]', + // Real recursion keeps its edge beside a super call on the same line. + 'Counter::run -> Base::run [instance-method]', + 'Counter::run -> Counter::run [exact-match]', + // A grandparent's method, and a nearer ancestor's over it. + 'Leaf2::run -> Mid2::run [instance-method]', + 'Leaf::run -> Base::run [instance-method]', + // A constructor, an arrow field, an arrow callback, a named arrow and + // a call split over two lines are the instance method's; a static + // one the static's. + 'Maker::constructor -> Base::init [instance-method]', + 'Maker::each -> Base::run [instance-method]', + 'Maker::later -> Base::init [instance-method]', + 'Maker::make -> Base::make [instance-method]', + 'Maker::named -> Maker::named::go [exact-match]', + 'Maker::named::go -> Base::run [instance-method]', + // A `class` in a comment or a string is no class expression. + 'Maker::noted -> Base::run [instance-method]', + 'Maker::split -> Base::run [instance-method]', + 'Worker::work -> Base::run [instance-method]', + 'static Maker::make -> static Base::make [instance-method]', + // A method no ancestor has (Decoy's `missing`), and a parent outside + // the project (HTMLElement, not Panel), link nothing. + ]); + }, 120_000); +}); + +it('a super call in a class expression is not read as the enclosing class\'s (#2497)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-super-call-expr-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/base.js'), BASE); + fs.writeFileSync(path.join(root, 'src/holder.js'), [ + "import { Base } from './base.js';", + 'export class Other { run() { return 0; } }', + 'export class Holder extends Base {', + ' make() {', + ' return class extends Other { run() { return super.run(); } };', + ' }', + '}', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + // The class expression's methods have no nodes, so its call is recorded + // from `make`; its `super` is Other, never Holder's parent. + const make = graph.getNodesByQualifiedName('Holder::make')[0]!; + const targets = graph.getOutgoingEdges(make.id).filter((e) => e.kind === 'calls').map((e) => graph!.getNode(e.target)!.qualifiedName); + expect(targets).not.toContain('Base::run'); + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); + +it('a TypeScript class reaches the class it extends, not an interface it implements (#2497)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-super-call-ts-')); + let graph: CodeGraph | null = null; + try { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' })); + fs.mkdirSync(path.join(root, 'src')); + fs.writeFileSync(path.join(root, 'src/base.ts'), 'export class Base { run(): number { return 1; } }\n'); + fs.writeFileSync(path.join(root, 'src/both.ts'), [ + "import { Base as Parent } from './base';", + 'export interface Runner { run(): number }', + 'export class Both extends Parent implements Runner {', + ' run(): number { return super.run(); }', + '}', + '', + ].join('\n')); + graph = await CodeGraph.init(root, { index: true }); + const both = graph.getNodesByQualifiedName('Both::run')[0]!; + const targets = graph.getOutgoingEdges(both.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${graph!.getNode(e.target)!.qualifiedName} [${e.metadata?.resolvedBy}]`); + expect(targets).toEqual(['Base::run [instance-method]']); + } finally { + graph?.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 6a381191..2f7f9cb7 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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(); // getSupertypeNodes' memo, by node id, under the same generation tag. private supertypeNodeMemo = new Map(); + // getSuperclassNodes' memo (`extends` edges alone), the same way. + private superclassNodeMemo = new Map(); /** 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]; diff --git a/src/resolution/js-super-calls.ts b/src/resolution/js-super-calls.ts new file mode 100644 index 00000000..c8ca9ea3 --- /dev/null +++ b/src/resolution/js-super-calls.ts @@ -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 = 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 (!/(?([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(); +} diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 8caf285f..b4e5dae4 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -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`