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 @@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### Symbols, tests and the viewer

- A TypeScript or JavaScript call through an ES private field, such as `this.#items.add(x)`, now resolves on the field's declared or constructed type, like `this.items.add(x)` already did, instead of linking to whichever project method shares the name. (#1987)
- Kotlin functions and methods now carry their signature — `(params): ReturnType` — in `codegraph_explore`, `node` and the viewer, instead of no signature at all. Re-index Kotlin projects after upgrading. (#1495)
- TypeScript/JavaScript value aliases — `export const alias = fn`, `export { fn as alias }`, object-literal `api = { run: fn }`, and same-file `const local = fn` — now forward calls edges to the aliased function, so callers and impact on the implementation include consumers that call through the alias instead of stopping at the binding. Genuine wrappers (`() => fn()`) are unchanged. Re-index after upgrading. Thanks @valkyriweb. (#1482, #1485)
- `codegraph affected` now finds Go, Python and JVM test files that previously went unreported, while preserving custom `--filter` behavior (thanks @danusha2345; #1507, #1688).
Expand Down
8 changes: 8 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ export class FieldDelegator {
direct(): void { this.send('x'); super.toString(); }
}

// --- call through an ES private field (#1987) --------------------------------
export class PrivateDelegator {
#mailer = new FieldDelegator({ send: (m: string) => m }, []);
#items = new Set<string>();
send(msg: string): string { return this.#mailer.send(msg); }
add(x: string): void { this.#items.add(x); }
}

// --- const-bound functions inside a body (#1669) -----------------------------
export function NestedHandlers({ items, onPick }: { items: string[]; onPick: (a: unknown, b: unknown) => void }) {
const handleClear = () => { onPick(null, null); };
Expand Down
27 changes: 27 additions & 0 deletions __tests__/ts-this-field-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ beforeAll(async () => {
' async settings(): Promise<object> { return this.storage.getSettings(); }\n' +
'}\n'
);
// ES private fields (#1987). `Outbox::send` and `Cart::add` sit in the same
// file, so a bare-name guess would pick them over the field's real type.
w(
'vault.ts',
"import { Mailer } from './mailer';\n" +
'export class Outbox {\n send(msg: string): string { return msg; }\n}\n' +
'export class Cart {\n add(item: string): void {}\n}\n' +
'export class Vault {\n' +
' #mailer: Mailer;\n' +
' #backup = new Mailer();\n' +
' #items = new Set<string>();\n' +
' constructor(m: Mailer) { this.#mailer = m; }\n' +
' notify(msg: string): string { return this.#mailer.send(msg); }\n' +
' fallback(msg: string): string { return this.#backup.send(msg); }\n' +
' put(x: string): void { this.#items.add(x); }\n' +
'}\n'
);
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
Expand Down Expand Up @@ -103,4 +120,14 @@ describe('this.<field>.<method>() (#1496)', () => {
const self = cg.getCallers(method('Keeper::get').id).some(({ node }) => node.id === method('Keeper::get').id);
expect(self).toBe(false);
});

it('resolves an ES private field on its declared or constructed type (#1987)', () => {
expect(calleesOf('Vault::notify')).toEqual(['Mailer::send']);
expect(calleesOf('Vault::fallback')).toEqual(['Mailer::send']);
});

it('leaves a builtin-typed ES private field unresolved (#1987)', () => {
// `this.#items.add()` on a Set must not bind to the project's `Cart::add`.
expect(calleesOf('Vault::put')).toEqual([]);
});
});
7 changes: 5 additions & 2 deletions codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1226,14 +1226,17 @@ impl<'t> Walker<'t> {

// --- extractInstantiation -----------------------------------------------------------

/// `this.<field>` as a member_expression receiver → Some(field) (#1496).
/// `this.<field>` as a member_expression receiver → Some(field) (#1496, #1987).
fn this_field_of(&self, receiver: Node<'t>) -> Option<String> {
if receiver.kind() != "member_expression" {
return None;
}
let object = receiver.child_by_field_name("object")?;
let property = receiver.child_by_field_name("property")?;
if object.kind() != "this" || property.kind() != "property_identifier" {
// An ES private field (`this.#items`) keeps its `#` (#1987).
if object.kind() != "this"
|| !matches!(property.kind(), "property_identifier" | "private_property_identifier")
{
return None;
}
Some(self.text(property).to_string())
Expand Down
6 changes: 5 additions & 1 deletion src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']

/** Receiver node types (TS/JS grammars) that continue a member chain downward. */
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
/** The field of a `this.<field>.<method>()` receiver: public or ES private (#1496, #1987). */
const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']);

/**
* Identifier-rooted member chains have no inferred property type (#1566),
Expand Down Expand Up @@ -4820,7 +4822,7 @@ export class TreeSitterExtractor {
receiver &&
receiver.type === 'member_expression' &&
getChildByField(receiver, 'object')?.type === 'this' &&
getChildByField(receiver, 'property')?.type === 'property_identifier'
THIS_FIELD_PROPERTY_TYPES.has(getChildByField(receiver, 'property')?.type ?? '')
) {
// TS/JS call through a field of the enclosing class —
// `this.mailer.send()` (#1496). Keep the `this.<field>` prefix:
Expand All @@ -4833,6 +4835,8 @@ export class TreeSitterExtractor {
// itself when the two share a name, a self-edge not in the
// source. Same discipline as Rust's `self.<field>` (#1585).
// Mirrored in the kernel's extract_call (tsjs/extractors.rs).
// An ES private field (`this.#items.add()`) is the same shape;
// its name keeps the `#` (#1987).
const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source);
calleeName = `this.${fieldName}.${methodName}`;
} else if (
Expand Down
18 changes: 15 additions & 3 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,15 @@ export function matchMethodCall(
);
}

// A TS/JS call through an ES private field of the enclosing class —
// `this.#items.add()`, emitted as `this.#items.add` (#1987) — resolves
// exactly like `this.<field>` below (#1496). `#` is outside dotMatch's
// receiver class, so the shape is matched here.
if (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx') {
const privateField = ref.referenceName.match(/^this\.(#[\w$]+)\.(\w+)$/);
if (privateField) return matchTsThisFieldCall(privateField[1]!, privateField[2]!, ref, context);
}

const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
if (!match) {
return null;
Expand Down Expand Up @@ -2931,25 +2940,28 @@ function matchTsThisFieldCall(
(n) => (n.kind === 'class' || n.kind === 'component') && sameLanguageFamily(n.language, ref.language)
);
const fieldEsc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// `\b` can't open a name that starts with `#` (an ES private field, #1987):
// there is no word boundary between a space and `#`.
const fieldStart = field.startsWith('#') ? '(?<![\\w$])' : '\\b';
const patterns: Array<{ re: RegExp; valueType: boolean }> = [
// `storage: typeof DraftHubStorage` — the type OF a value: an object
// literal used as a namespace. Its members are bare-named functions inside
// the constant's extent (#1573), so they are found by containment, not by
// `Type::method`. Tried first: the declared-type pattern below would
// otherwise capture the word `typeof`.
{
re: new RegExp(`\\b${fieldEsc}\\b\\s*[?!]?\\s*:\\s*(?:readonly\\s+)?typeof\\s+([A-Za-z_$][\\w.$]*)`),
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*[?!]?\\s*:\\s*(?:readonly\\s+)?typeof\\s+([A-Za-z_$][\\w.$]*)`),
valueType: true,
},
// `private readonly mailer?: Mailer` — a class field or a constructor
// parameter property; the capture stops at `<`, `[` or `|`, so a generic
// or union type yields its head and resolveMethodOnType decides.
{
re: new RegExp(`\\b${fieldEsc}\\b\\s*[?!]?\\s*:\\s*(?:readonly\\s+)?([A-Za-z_$][\\w.$]*)`),
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*[?!]?\\s*:\\s*(?:readonly\\s+)?([A-Za-z_$][\\w.$]*)`),
valueType: false,
},
// `mailer = new Mailer()` / `this.mailer = new Mailer()`
{ re: new RegExp(`\\b${fieldEsc}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), valueType: false },
{ re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), valueType: false },
];
for (const cls of owners) {
const source = context.readFile(cls.filePath);
Expand Down