mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 12:00:02 +08:00
The override-dispatch pass (interface-impl) linked a base class's
method to every subclass method of the same name, so a static method
counted as overriding an instance one, or the reverse, though a call to
one never runs the other (issue #2761). TypeScript nodes recorded
static already, so there the pass alone was wrong. JavaScript nodes
never did: the extractor had no isStatic hook and the kernel answered
nothing for the JS variants, so a JS class's static s() and s() were
both stored as instance methods.
JavaScript members now record their own static keyword with the hook
TypeScript uses (hasStaticModifier, which also keeps a method named
static() an instance one), and the kernel gives the JS variants the
same answer, so both engines agree. The pass skips a JS/TS base and
override whose static flags differ; a static method still overrides a
static one, and an instance method an instance one.
Validation: the new test fails 5 of 10 on main 08478eea (JS and JSX
static flags in both engines, and the override pairs) and passes 10 of
10 on the fix. A/B against main on 20 repos: webpack's lib loses 15
edges, every one a cross-kind pair, such as Module's instance
deserialize(context) to the static deserialize(context) factories of
NormalModule and six other modules, or Module's static
getSourceBasicTypes(module) to RuntimeModule's instance
getSourceBasicTypes(); the same-kind pairs stay and no edge is added.
No other repo's edges change. Nodes change only in is_static, 0 to 1,
on 315 JavaScript static members, none in TypeScript or another
language; the flask and gin control dumps are byte-identical. Kernel
parity over the 20 TS/JS repos: 16,612 of 16,873 files byte-parity,
none with diffs, the rest deferred to wasm as before. Index time,
median of 3 interleaved runs on vite, nuxt, webdriverio, insomnia and
webpack: -1.1% total, within noise.
Fixes #2761
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
/**
|
|
* The override-dispatch edges (`synthesizedBy: interface-impl`) linked a base
|
|
* class's method to a subclass's method of the same name even when one was
|
|
* `static` and the other not (#2761). In JavaScript and TypeScript a static
|
|
* method is inherited and overridden by the subclass's static method, an
|
|
* instance method by an instance method, and a call to one never runs the
|
|
* other. JavaScript members also never recorded `static` at all, in the
|
|
* kernel or the wasm extractor, so the edges couldn't tell them apart there.
|
|
*/
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { extractFromSource } from '../src/extraction';
|
|
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
|
|
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
|
|
import type { ExtractionResult, Language } from '../src/types';
|
|
|
|
const KERNEL_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'codegraph-kernel',
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
'codegraph-kernel.node'
|
|
);
|
|
const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1';
|
|
|
|
const MEMBERS = `export class Flags {
|
|
static create() {}
|
|
run() {}
|
|
static handler = () => {};
|
|
callback = () => {};
|
|
static count = 0;
|
|
value = 0;
|
|
static async *stream() {}
|
|
static get size() { return 1; }
|
|
static() {}
|
|
}
|
|
`;
|
|
|
|
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
|
|
|
describe('a JS/TS class member records whether it is static (#2761)', () => {
|
|
let savedEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeAll(async () => {
|
|
await initGrammars();
|
|
await loadGrammarsForLanguages(['typescript', 'tsx', 'javascript', 'jsx']);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
resetKernelForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
resetKernelForTests();
|
|
});
|
|
|
|
function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult {
|
|
if (backend === 'wasm') {
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
return extractFromSource(file, source, language);
|
|
}
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
const result = tryKernelExtract(file, source, language);
|
|
expect(result, `kernel extraction of ${file}`).not.toBeNull();
|
|
return result!;
|
|
}
|
|
|
|
const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const);
|
|
const variants = [['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx']] as const;
|
|
|
|
for (const backend of backends) {
|
|
it.each(variants)(`in %s, ${backend}`, (ext, language) => {
|
|
const members = extract(backend, `src/flags.${ext}`, MEMBERS, language).nodes
|
|
.filter((n) => n.kind === 'method' || n.kind === 'property')
|
|
.map((n) => `${n.name} ${n.kind} ${n.isStatic ? 'static' : 'instance'}`);
|
|
expect(members).toEqual([
|
|
'create method static', 'run method instance', 'handler method static', 'callback method instance',
|
|
'count property static', 'value property instance', 'stream method static', 'size method static',
|
|
// A member named `static` is an instance method.
|
|
'static method instance',
|
|
]);
|
|
});
|
|
}
|
|
|
|
it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node', () => {
|
|
for (const [ext, language] of variants) {
|
|
const canon = (r: ExtractionResult) => r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())).sort();
|
|
expect(canon(extract('kernel', `src/flags.${ext}`, MEMBERS, language)), ext)
|
|
.toEqual(canon(extract('wasm', `src/flags.${ext}`, MEMBERS, language)));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('an override pairs a static method with a static one only (#2761)', () => {
|
|
let root = '';
|
|
let graph: CodeGraph | null = null;
|
|
|
|
afterEach(() => {
|
|
graph?.close();
|
|
graph = null;
|
|
if (root) fs.rmSync(root, { recursive: true, force: true });
|
|
root = '';
|
|
});
|
|
|
|
/** `base -> override` for every interface-impl edge, by qualified name and kind of method. */
|
|
function overrides(g: CodeGraph, file: string): string[] {
|
|
const out: string[] = [];
|
|
const label = (id: string) => {
|
|
const n = g.getNode(id)!;
|
|
return `${n.isStatic ? 'static ' : ''}${n.qualifiedName}`;
|
|
};
|
|
for (const node of g.getNodesInFile(file)) {
|
|
for (const e of g.getOutgoingEdges(node.id)) {
|
|
if (e.kind === 'calls' && e.metadata?.synthesizedBy === 'interface-impl') out.push(`${label(e.source)} -> ${label(e.target)}`);
|
|
}
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
it('in TypeScript and JavaScript, for methods and callable fields', async () => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-static-override-'));
|
|
const classes = [
|
|
// The issue's reproduction, and the other direction.
|
|
'class B { s() {} }',
|
|
'class A extends B { static s() {} }',
|
|
'class StaticBase { static t() {} }',
|
|
'class InstanceDerived extends StaticBase { t() {} }',
|
|
// A static overriding a static, an instance method an instance one.
|
|
'class SB { static make() {} }',
|
|
'class SD extends SB { static make() {} }',
|
|
'class IB { go() {} }',
|
|
'class ID extends IB { go() {} }',
|
|
// A base with both: each pairs with its own kind.
|
|
'class Mixed { static m() {} m() {} }',
|
|
'class StaticOnly extends Mixed { static m() {} }',
|
|
'class InstanceOnly extends Mixed { m() {} }',
|
|
// A subclass with both, like webpack's modules with a `static
|
|
// deserialize()` beside the instance one: only that one overrides.
|
|
'class Both extends IB { static go() {} go() {} }',
|
|
// Callable class fields.
|
|
'class FieldBase { run = () => {}; static start = () => {}; }',
|
|
'class FieldDerived extends FieldBase { static run = () => {}; start = () => {}; }',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'app' }));
|
|
fs.mkdirSync(path.join(root, 'src'));
|
|
fs.writeFileSync(path.join(root, 'src/classes.ts'), classes);
|
|
fs.writeFileSync(path.join(root, 'src/classes.js'), classes);
|
|
fs.writeFileSync(
|
|
path.join(root, 'src/shapes.ts'),
|
|
'export interface Shape { area(): number }\nexport class Square implements Shape { static area(): number { return 0; } }\nexport class Circle implements Shape { area(): number { return 1; } }\n',
|
|
);
|
|
// Java, whose override edges this leaves as they were.
|
|
fs.writeFileSync(path.join(root, 'src/Base.java'), 'class Base { static void s() {} void r() {} }\nclass Derived extends Base { static void s() {} void r() {} }\n');
|
|
graph = await CodeGraph.init(root, { index: true });
|
|
|
|
const expected = [
|
|
'IB::go -> Both::go',
|
|
'IB::go -> ID::go',
|
|
'Mixed::m -> InstanceOnly::m',
|
|
'static Mixed::m -> static StaticOnly::m',
|
|
'static SB::make -> static SD::make',
|
|
];
|
|
expect(overrides(graph, 'src/classes.ts')).toEqual(expected);
|
|
expect(overrides(graph, 'src/classes.js')).toEqual(expected);
|
|
expect(overrides(graph, 'src/shapes.ts')).toEqual(['Shape::area -> Circle::area']);
|
|
expect(overrides(graph, 'src/Base.java')).toEqual(['Base::r -> Derived::r', 'static Base::s -> static Derived::s']);
|
|
}, 120_000);
|
|
});
|