diff --git a/CHANGELOG.md b/CHANGELOG.md index 75535a0bbd..2029807308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/__tests__/fixtures/kernel-parity/torture.tsx b/__tests__/fixtures/kernel-parity/torture.tsx index b7f4be7496..3135422843 100644 --- a/__tests__/fixtures/kernel-parity/torture.tsx +++ b/__tests__/fixtures/kernel-parity/torture.tsx @@ -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(); + 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); }; diff --git a/__tests__/ts-this-field-call.test.ts b/__tests__/ts-this-field-call.test.ts index 69bcf20860..c3a2d8b05e 100644 --- a/__tests__/ts-this-field-call.test.ts +++ b/__tests__/ts-this-field-call.test.ts @@ -65,6 +65,23 @@ beforeAll(async () => { ' async settings(): Promise { 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();\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(); }); @@ -103,4 +120,14 @@ describe('this..() (#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([]); + }); }); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 99fe07468a..03605a6900 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1226,14 +1226,17 @@ impl<'t> Walker<'t> { // --- extractInstantiation ----------------------------------------------------------- - /// `this.` as a member_expression receiver → Some(field) (#1496). + /// `this.` as a member_expression receiver → Some(field) (#1496, #1987). fn this_field_of(&self, receiver: Node<'t>) -> Option { 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()) diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 633949b2f0..56f24cd431 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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..()` 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), @@ -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.` prefix: @@ -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.` (#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 ( diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5c0bfac7d6..e3e66c5d78 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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.` 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; @@ -2931,6 +2940,9 @@ 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('#') ? '(? = [ // `storage: typeof DraftHubStorage` — the type OF a value: an object // literal used as a namespace. Its members are bare-named functions inside @@ -2938,18 +2950,18 @@ function matchTsThisFieldCall( // `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);