Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
203 changes: 203 additions & 0 deletions __tests__/js-super-calls.test.ts
Original file line number Diff line number Diff line change
@@ -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<T = unknown> { 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<string> 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);
26 changes: 26 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
Loading