diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779c..2bfe515808 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 +- In JavaScript, a `static` class method is now recorded as static, as it already was in TypeScript; and in both languages a static method and an instance method of the same name are no longer linked as an override pair, so a call to one no longer reaches the other. (#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__/static-instance-override.test.ts b/__tests__/static-instance-override.test.ts new file mode 100644 index 0000000000..c9a91241aa --- /dev/null +++ b/__tests__/static-instance-override.test.ts @@ -0,0 +1,92 @@ +/** + * A static method and an instance method of the same name are not an override + * pair: `A.s()` never runs `B.prototype.s`, and `new A().s()` never runs + * `A.s`. The interface-impl synthesizer matched by name alone, so `B::s -> + * A::s` was drawn for `class B { s() {} }` / `class A extends B { static s() + * {} }`. In JavaScript a second cause: methods never recorded `isStatic` at + * all (TypeScript's extractor had the hook, JavaScript's did not), so even a + * static/static pair looked like instance/instance (#2761). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-static-override-')); + const files: Record = { + 'ts/a.ts': `export class B { + s(): void {} + static t(): void {} + u(): void {} +} +export class A extends B { + static s(): void {} + t(): void {} + u(): void {} +} +export class S extends B { + static t(): void {} +} +`, + 'js/a.js': `export class B { + static s() {} + s() {} +} +export class A extends B { + static s() {} +} +export class C extends B { + s() {} +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = CodeGraph.initSync(root); + await cg.indexAll(); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `Base::m -> Sub::m` of every interface-impl edge drawn inside `file`, with each end's static flag. */ +function overridePairs(file: string): string[] { + const nodes = cg.getNodesInFile(file); + const byId = new Map(nodes.map((n) => [n.id, n])); + return cg.getOutgoingEdgesFrom(nodes.map((n) => n.id)) + .filter((e) => e.kind === 'calls' && e.metadata?.synthesizedBy === 'interface-impl') + .map((e) => { + const s = byId.get(e.source)!; + const t = cg.getNode(e.target)!; + const tag = (n: typeof s) => `${n.qualifiedName}${n.isStatic ? '[static]' : ''}`; + return `${tag(s)} -> ${tag(t)}`; + }) + .sort(); +} + +describe('a static and an instance method of one name are not an override pair (#2761)', () => { + it('TypeScript: only same-kind pairs are linked', () => { + expect(overridePairs('ts/a.ts')).toEqual([ + 'B::t[static] -> S::t[static]', + 'B::u -> A::u', + ]); + }); + + it('JavaScript: methods record `static`, and only same-kind pairs are linked', () => { + const statics = cg.getNodesInFile('js/a.js').filter((n) => n.kind === 'method').map((n) => `${n.qualifiedName}${n.isStatic ? '[static]' : ''}`).sort(); + expect(statics).toEqual(['A::s[static]', 'B::s', 'B::s[static]', 'C::s']); + expect(overridePairs('js/a.js')).toEqual([ + 'B::s -> C::s', + 'B::s[static] -> A::s[static]', + ]); + }); +}); diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index 69860a0155..b68c4bf856 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -1072,13 +1072,10 @@ impl<'t> Walker<'t> { self.has_keyword_child(node, "async") } - /// TS has an isStatic hook; JS does not (None = field absent). + /// Both extractors read the `static` keyword child — JS's hook is TS's + /// since #2761. 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..63f3e7b6f3 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'], @@ -9,6 +9,10 @@ export const javascriptExtractor: LanguageExtractor = { // JS `field_definition` ≙ TS `public_field_definition`: plain fields are // properties, function-valued fields are methods (#808). classifyMethodNode: classifyTsClassMember, + // `static` is the same keyword child in both grammars. Without it every JS + // method was stored as an instance method, and a static `s()` paired with + // the instance `s()` of a subclass as an override (#2761). + isStatic: typescriptExtractor.isStatic, interfaceTypes: [], structTypes: [], enumTypes: [], diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0dac3e1e1d..ebaceaf2fa 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1668,6 +1668,9 @@ 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; + // A static method and an instance method of one name are not an + // override pair: a call to one never runs the other (#2761). + if ((bm.isStatic ?? false) !== (m.isStatic ?? false)) continue; const key = `${bm.id}>${m.id}`; if (seen.has(key)) continue; seen.add(key);