From 5ab9346448cc46f1009d6479a1a25ccbdc0c1097 Mon Sep 17 00:00:00 2001 From: qingyang Date: Thu, 22 Oct 2026 10:29:28 +0800 Subject: [PATCH] fix(js/ts): separate static and instance override dispatch --- CHANGELOG.md | 1 + __tests__/kernel-tsjs-parity.test.ts | 20 ++++ __tests__/static-override-dispatch.test.ts | 107 +++++++++++++++++++++ codegraph-kernel/src/tsjs/mod.rs | 9 +- src/extraction/languages/javascript.ts | 3 +- src/resolution/callback-synthesizer.ts | 2 + 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 __tests__/static-override-dispatch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0861455134..7c20a91449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A JavaScript or TypeScript instance method no longer appears to call a subclass's static method of the same name, or the reverse; re-index these projects after upgrading. (#2761) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index f408f822a7..aa16977117 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -96,6 +96,26 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { return viaWasm; } + it.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], + ] as const)('preserves static modifiers on methods and fields: %s (#2761)', (ext, language) => { + const result = assertParity(`static.${ext}`, `export class Flags { + static create() {} + run() {} + static handler = () => {}; + callback = () => {}; + static count = 0; + value = 0; + static() {} +} +`, language); + expect(result.nodes.filter((n) => n.kind === 'method' || n.kind === 'property') + .map((n) => [n.name, n.isStatic])).toEqual([ + ['create', true], ['run', false], ['handler', true], ['callback', false], + ['count', true], ['value', false], ['static', false], + ]); + }); + it.each([ ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], ] as const)('named object literals own their members: %s (#2300)', (ext, language) => { diff --git a/__tests__/static-override-dispatch.test.ts b/__tests__/static-override-dispatch.test.ts new file mode 100644 index 0000000000..803331331b --- /dev/null +++ b/__tests__/static-override-dispatch.test.ts @@ -0,0 +1,107 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +describe.each(['ts', 'js'])('static and instance override dispatch: %s (#2761)', (ext) => { + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-static-override-')); + fs.writeFileSync(path.join(root, `classes.${ext}`), ` +class InstanceBase { s() {} } +class StaticDerived extends InstanceBase { static s() {} } +class StaticBase { static s() {} } +class InstanceDerived extends StaticBase { s() {} } +class MixedBase { + static s() {} + s() {} +} +class StaticOnly extends MixedBase { static s() {} } +class InstanceOnly extends MixedBase { s() {} } +class FieldBase { + run = () => {}; + static start = () => {}; +} +class FieldDerived extends FieldBase { + static run = () => {}; + start = () => {}; +} +`); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + function dispatchesFrom(owner: string, name = 's') { + const methods = cg.getNodesByName(name) + .filter((n) => n.kind === 'method' && n.qualifiedName === `${owner}::${name}`); + expect(methods, `${owner}::${name}`).not.toHaveLength(0); + return methods.flatMap((n) => cg.getOutgoingEdges(n.id) + .filter((e) => e.kind === 'calls' && e.metadata?.synthesizedBy === 'interface-impl') + .map((e) => { + const target = cg.getNode(e.target)!; + return [n.isStatic, target.qualifiedName, target.isStatic]; + })); + } + + it('records static and instance methods separately', () => { + const methods = cg.getNodesByName('s') + .filter((n) => n.kind === 'method' && n.qualifiedName === 'MixedBase::s') + .sort((a, b) => a.startLine - b.startLine); + expect(methods.map((n) => n.isStatic)).toEqual([true, false]); + }); + + it('does not dispatch an instance base method to a static method', () => { + expect(dispatchesFrom('InstanceBase')).toEqual([]); + }); + + it('does not dispatch a static base method to an instance method', () => { + expect(dispatchesFrom('StaticBase')).toEqual([]); + }); + + it('keeps same-kind links when both namespaces share a method name', () => { + expect(dispatchesFrom('MixedBase')).toEqual(expect.arrayContaining([ + [true, 'StaticOnly::s', true], + [false, 'InstanceOnly::s', false], + ])); + expect(dispatchesFrom('MixedBase')).toHaveLength(2); + }); + + it('also keeps callable class fields in their own namespace', () => { + expect(dispatchesFrom('FieldBase', 'run')).toEqual([]); + expect(dispatchesFrom('FieldBase', 'start')).toEqual([]); + }); +}); + +it('keeps Java instance overrides whose annotation text mentions static (#2761)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-static-override-java-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync(path.join(root, 'Classes.java'), ` +class Base { + @SuppressWarnings("static") public void run() {} +} +class Derived extends Base { + @Override public void run() {} +} +`); + cg = await CodeGraph.init(root, { index: true }); + const base = cg.getNodesByName('run').find((n) => n.qualifiedName === 'Base::run'); + const derived = cg.getNodesByName('run').find((n) => n.qualifiedName === 'Derived::run'); + expect(base).toBeDefined(); + expect(derived).toBeDefined(); + expect(cg.getOutgoingEdges(base!.id)).toContainEqual(expect.objectContaining({ + kind: 'calls', target: derived!.id, + metadata: expect.objectContaining({ synthesizedBy: 'interface-impl' }), + })); + } finally { + cg?.destroy(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 69860a0155..4640b1fdf5 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -42,7 +42,7 @@ impl Variant { } } /// TS-family (typescript/tsx): type annotations, interfaces, enums, - /// aliases, visibility, isStatic. The JS family lacks all of those hooks. + /// aliases, visibility. The JS family lacks all of those hooks. fn is_ts(self) -> bool { matches!(self, Variant::Typescript | Variant::Tsx) } @@ -1072,13 +1072,8 @@ impl<'t> Walker<'t> { self.has_keyword_child(node, "async") } - /// TS has an isStatic hook; JS does not (None = field absent). fn is_static(&self, node: Node) -> Option { - if self.variant.is_ts() { - Some(self.has_keyword_child(node, "static")) - } else { - None - } + Some(self.has_keyword_child(node, "static")) } fn is_const_decl(&self, node: Node) -> bool { diff --git a/src/extraction/languages/javascript.ts b/src/extraction/languages/javascript.ts index 899e1530a3..4e1c8ff8e6 100644 --- a/src/extraction/languages/javascript.ts +++ b/src/extraction/languages/javascript.ts @@ -1,6 +1,6 @@ import { getNodeText, getChildByField } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; -import { classifyTsClassMember } from './typescript'; +import { classifyTsClassMember, typescriptExtractor } from './typescript'; export const javascriptExtractor: LanguageExtractor = { functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], @@ -77,6 +77,7 @@ export const javascriptExtractor: LanguageExtractor = { } return false; }, + isStatic: typescriptExtractor.isStatic, isConst: (node) => { if (node.type === 'lexical_declaration') { for (let i = 0; i < node.childCount; i++) { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0dac3e1e1d..db3ee50993 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1668,6 +1668,8 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield for (const m of impls) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; if (bm.id === m.id) continue; + if ((base.language === 'javascript' || base.language === 'typescript') && + (bm.isStatic ?? false) !== (m.isStatic ?? false)) continue; const key = `${bm.id}>${m.id}`; if (seen.has(key)) continue; seen.add(key);