diff --git a/CHANGELOG.md b/CHANGELOG.md index 644ee1d64..82873bfce 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 000000000..357ce97d4 --- /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 6a381191d..2f7f9cb70 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 000000000..c8ca9ea32 --- /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 8caf285f2..b4e5dae4d 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`