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 @@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The Claude Code prompt hook now goes by what you typed. Claude Code's desktop app puts status notes, like which other sessions are linked, in front of your prompt, and the hook read them as part of it, so every turn, even a one-word "yes", could get code context about the note's words, and so could a task notification, a subagent's report or a message from another session that came after one. The notes are now skipped, and a message from another session gets no context, as task notifications and subagent reports already didn't. Thanks @WonderMr for the report and @yang0228 for the first fix. (#2635)
- `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578)
- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585).
- In JavaScript and TypeScript, a method called on an element of a class field, like `this.handlers[type].handle()` or `this.items[0].save()`, now links to the method of the type the field is declared to hold, such as `Item[]` or `Record<string, Handler>`, instead of to any project method that shares its name, often another class's, and is left unlinked when that type isn't written down, as in plain JavaScript. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows. (#2766)
- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In Delphi, a routine with an attribute on one of its parameters, like `const [ref] AValue: Integer` or DelphiMVCFramework's `const [MVCFromBody] Person: TPerson`, is now indexed with its calls, and so is every class member declared after it. Before, the routine was lost or its calls were credited to the file, the parameter showed up as a field of the class, and the members after it were missing. Thanks @stalexteam for the report. (#2545)
Expand Down
16 changes: 15 additions & 1 deletion __tests__/expression-receiver-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const files: Record<string, string> = {
'export class GraphAdapter { async map(req?: string) { return req ?? \'\'; } }\n' +
'export class Runner { run() { return 1; } }\n' +
'export class Base { hello() { return 1; } }\n',
// A project `push` an element of a field must not reach (`this.listeners[type].push`).
'src/stack.ts': 'export interface UndoStack { push: (entry: string) => void; }\n',
'src/bus.ts':
'export class Bus {\n' +
' private listeners: Record<string, string[]> = {};\n' +
' on(type: string, handler: string) { this.listeners[type].push(handler); }\n' +
'}\n',
'src/use.ts':
"import { GraphAdapter, Runner, Base } from './adapter';\n" +
"import { helper } from './helper.js';\n" +
Expand Down Expand Up @@ -84,6 +91,10 @@ describe('expression receivers', () => {
expect(cg.getCallers(node('map', 'adapter.ts').id).map(({ node: n }) => n.name)).toEqual(['typed']);
});

it('TS: a method on an element of a field does not bind by its bare name', () => {
expect(calleesOf('on', 'bus.ts')).toEqual([]);
});

it('Rust: `v.iter().map()` does not bind to a TypeScript class method', () => {
expect(calleesOf('lens', 'lib.rs')).toEqual([]);
expect(calleesOf('opt', 'lib.rs')).toEqual([]);
Expand Down Expand Up @@ -116,6 +127,9 @@ describe('expression receivers', () => {
' f().list.map(g);\n' +
' (() => 1).call(null);\n' +
' this.a.b.run();\n' +
' this.listeners[type].push(h);\n' +
' this[k].stop();\n' +
" this['svc'].start();\n" +
' new Runner().go();\n' +
' window.Api.start();\n' +
'}\n';
Expand All @@ -124,7 +138,7 @@ describe('expression receivers', () => {
.map((r) => r.referenceName);
expect(refs).toEqual([
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
'f', 'run', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start', 'go', 'start',
]);
});
});
8 changes: 7 additions & 1 deletion __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ async function exprReceivers(x, y) {
f().list.map(g);
(() => 1).call(null);
this.a.b.run();
this.listeners[type].push(h);
this[k].stop();
this['svc'].start();
${typed ? 'this.items![0].save(); (this.items[1] as X).save();' : ''}
super.stop();
new Runner().go();
window.Api.start();
Expand All @@ -304,7 +308,9 @@ async function exprReceivers(x, y) {
.map((r) => r.referenceName)).toEqual([
'list().map', 'list', 'x.run',
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
'f', 'run', 'stop', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start',
...(typed ? ['this.items[].save', 'this.items[].save'] : []),
'stop', 'go', 'start',
]);
});

Expand Down
18 changes: 18 additions & 0 deletions __tests__/ts-array-annotated-receivers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,24 @@ describe("a call on a TypeScript local annotated as one of a type's properties",
expect(calls.filter((c) => c.startsWith('onDone.'))).toEqual(['onDone.fire -> Emitter::fire']);
}, 60_000);

it("reads a property typed by a type parameter of the indexed type as no type, whatever class shares the parameter's name", async () => {
// `Item` is each type's own parameter here, not the project class `Item`:
// a generic type alias, and a class whose parameter list holds braces.
// `Stack` has a `flush` too, so the call's name alone picks neither.
const calls = await callsOf({
...CLASSES,
'src/stack.ts': 'export class Stack {\n findLast() { return 1; }\n flush() { return 2; }\n}\n',
'src/state.ts':
'export type State<Item = unknown> = { current: Item };\n' +
'export class Shaped<Item extends { id: string } = any> { current!: Item; }\n',
'src/use.ts':
"import { State, Shaped } from './state';\n" +
"export function viaAlias(x: State['current']) { return x.flush(); }\n" +
"export function viaShaped(x: Shaped['current']) { return x.flush(); }\n",
});
expect(calls.filter((c) => c.startsWith('x.'))).toEqual([]);
}, 60_000);

it("is not matched to a method by the call's name when the property's type isn't written in the project", async () => {
// vscode's `invocation: Parameters<typeof rawFinalizeToolInvocation>[0]`:
// left untyped, the receiver's name alone would pick `Logger.info`.
Expand Down
141 changes: 141 additions & 0 deletions __tests__/ts-this-field-element.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* A TS/JS method called on an element of a field of the enclosing class —
* `this.handlers[type].handle()`, `this.items[0].save()` — reaches the
* element's type as the field's declaration writes it (`Item[]`,
* `Array<Item>`, `Record<string, Handler>`), or nothing. It used to keep the
* bare method name, which exact-matched any project method of that name:
* `this.listeners[type].push(h)` reached a stack class's `push` in another
* file. `this['svc'].start()` is the field `svc` itself.
*/
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';

let dir: string;
let cg: CodeGraph;

const files: Record<string, string> = {
'src/model.ts':
'export class Item { save() { return 1; } }\n' +
'export class Handler { handle(t: string) { return t; } start() { return 0; } }\n',
// Same-named methods a bare-name match would reach.
'src/decoy.ts':
'export class UndoStack { push(entry: string) { return entry; } }\n' +
'export class Other { save() { return 2; } handle(t: string) { return t; } }\n',
'src/holder.ts':
"import { Item, Handler } from './model';\n" +
'export class Holder {\n' +
' private items: Item[] = [];\n' +
' private list: Array<Item> = [];\n' +
' private handlers: Record<string, Handler> = {};\n' +
' private listeners: Record<string, string[]> = {};\n' +
' private names: string[] = [];\n' +
' private svc: Handler = new Handler();\n' +
' #store: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
' fromList(i: number) { return this.list[i].save(); }\n' +
' fire(t: string) { return this.handlers[t].handle(t); }\n' +
' keyed() { return this.handlers["main"].handle("x"); }\n' +
' cast() { return (this.items[1] as Item).save(); }\n' +
' asserted() { return this.items![0]!.save(); }\n' +
' privately() { return this.#store[0].save(); }\n' +
" byKey() { return this['svc'].start(); }\n" +
' on(type: string, h: string) { this.listeners[type].push(h); }\n' +
' builtin(i: number) { return this.names[i].trim(); }\n' +
'}\n',
// `Item` here is the class's type parameter, not the project class.
'src/store.ts':
'export class Store<Item> {\n' +
' private items: Item[] = [];\n' +
' private byKey: Record<string, Item> = {};\n' +
' first() { return this.items[0].save(); }\n' +
' keyed(k: string) { return this.byKey[k].save(); }\n' +
'}\n' +
// A header over several lines, and a constraint holding braces.
'export class Wide<\n' +
' A,\n' +
' B,\n' +
' Item\n' +
'> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n' +
'export class Shaped<Item extends { id: string }> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n',
// Either array's element: no one type to resolve on.
'src/mixed.ts':
"import { Item } from './model';\n" +
"import { Other } from './decoy';\n" +
'export class Mixed {\n' +
' private entries: Item[] | Other[] = [];\n' +
' private maybe: Item[] | null = null;\n' +
' either() { return this.entries[0].save(); }\n' +
' nullable() { return this.maybe![0].save(); }\n' +
' private below: Array<Item>\n' +
' | Other = [];\n' +
' private joined: Item[] & Other[] = [];\n' +
' continued() { return this.below[0].save(); }\n' +
' both() { return this.joined[0].save(); }\n' +
'}\n',
};

beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-this-element-'));
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});

afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});

const calleesOf = (name: string, file = 'holder.ts') => {
const caller = cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && n.kind === 'method')!;
return cg.getCallees(caller.id)
.filter(({ edge }) => edge.kind === 'calls')
.map(({ node: n }) => `${path.basename(n.filePath)}:${n.qualifiedName}`)
.sort();
};

describe('a call on an element of a this-field', () => {
it.each([
['first', 'model.ts:Item::save'],
['fromList', 'model.ts:Item::save'],
['fire', 'model.ts:Handler::handle'],
['keyed', 'model.ts:Handler::handle'],
['cast', 'model.ts:Item::save'],
['asserted', 'model.ts:Item::save'],
['privately', 'model.ts:Item::save'],
['byKey', 'model.ts:Handler::start'],
])('%s reaches the element type', (caller, target) => {
expect(calleesOf(caller)).toEqual([target]);
});

it('an element of a builtin type reaches no project method', () => {
expect(calleesOf('on')).toEqual([]);
expect(calleesOf('builtin')).toEqual([]);
});

it("a class's type parameter is not the project class of that name", () => {
expect(calleesOf('first', 'store.ts')).toEqual([]);
expect(calleesOf('keyed', 'store.ts')).toEqual([]);
const caller = (cls: string) => cg.getNodesByName('first').find((n) => n.qualifiedName === `${cls}::first`)!;
for (const cls of ['Wide', 'Shaped']) expect(cg.getCallees(caller(cls).id).filter(({ edge }) => edge.kind === 'calls')).toEqual([]);
});

it('a union of element types reaches none of them; `| null` keeps the one', () => {
expect(calleesOf('either', 'mixed.ts')).toEqual([]);
expect(calleesOf('nullable', 'mixed.ts')).toEqual(['model.ts:Item::save']);
expect(calleesOf('continued', 'mixed.ts')).toEqual([]);
expect(calleesOf('both', 'mixed.ts')).toEqual([]);
});
});
43 changes: 41 additions & 2 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,12 @@ impl<'t> Walker<'t> {
// resolver can read its declared type (#1496). Mirrors
// TreeSitterExtractor.extractCall.
callee_name = format!("this.{field}.{method_name}");
} else if let Some(element) = receiver.and_then(|r| self.this_subscript_of(r)) {
// `this.<field>[k].<method>()` — an element of the
// field, typed by the resolver from the field's
// declaration; `this['svc']` is the field itself.
// Mirrors TreeSitterExtractor.extractCall.
callee_name = format!("{element}.{method_name}");
} else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
// Call receiver — `make().run()` (#1683): keep the inner
// callee as `<inner>().<method>`, or emit nothing when it
Expand Down Expand Up @@ -1739,6 +1745,36 @@ impl<'t> Walker<'t> {
Some(self.text(property).to_string())
}

/// A receiver subscripting the enclosing class's object: `this.<field>[k]`
/// → `this.<field>[]`, `this['svc']` → `this.svc`; None for any other
/// subscript. Mirrors tsThisSubscriptReceiver.
fn this_subscript_of(&self, receiver: Node<'t>) -> Option<String> {
if receiver.kind() != "subscript_expression" {
return None;
}
let object = peel_receiver(receiver.child_by_field_name("object")?);
if object.kind() == "this" {
let index = receiver.child_by_field_name("index")?;
if index.kind() != "string" {
return None;
}
let text = self.text(index);
let quoted = text.len() >= 2
&& (text.starts_with('\'') || text.starts_with('"'))
&& text.ends_with(&text[..1]);
let key = if quoted { &text[1..text.len() - 1] } else { return None };
let mut chars = key.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_' || first == '$')
|| !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
{
return None;
}
return Some(format!("this.{key}"));
}
self.this_field_of(object).map(|field| format!("this.{field}[]"))
}

/// `await f<T>(x)` parses with `await f` as the callee (#2586), and a
/// prefix operator over a generic call the same way
/// (`!settings.get<boolean>('X')`, `void apiRequest<T>()`); the call is to
Expand Down Expand Up @@ -2084,11 +2120,14 @@ fn peel_receiver(node: Node<'_>) -> Node<'_> {
}

/// `this` / `super`, a member chain rooted at either or at `window`, or
/// `new C()` still collapse to the bare method name (mirrors
/// keepsBareTsJsReceiver).
/// `new C()` still collapse to the bare method name; a subscript anywhere in
/// the chain does not (mirrors keepsBareTsJsReceiver).
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
let mut cur = node;
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
if cur.kind() == "subscript_expression" {
return false;
}
match cur.child_by_field_name("object") {
Some(object) => cur = peel_receiver(object),
None => return false,
Expand Down
Loading