Skip to content
Closed
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 @@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, 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)
Expand Down
20 changes: 20 additions & 0 deletions __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
107 changes: 107 additions & 0 deletions __tests__/static-override-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
9 changes: 2 additions & 7 deletions codegraph-kernel/src/tsjs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<bool> {
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 {
Expand Down
3 changes: 2 additions & 1 deletion src/extraction/languages/javascript.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down Expand Up @@ -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++) {
Expand Down
2 changes: 2 additions & 0 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down