From a9d519f9af60549f1566d513d10173ab86ebabbd Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 21 Oct 2026 15:04:44 -0400 Subject: [PATCH 1/4] fix(js/ts): a method on an element of a field does not bind by its bare name `this.listeners[type].push(handler)` kept the bare name `push`, because a member chain rooted at `this` collapses to it for the resolver to type. A subscript in that chain is an element the resolver cannot type, so the bare name exact-matched any project method called `push`. A subscript anywhere in the chain now emits nothing, as `arr[0].run()` already did. Mirrored in the kernel's keeps_bare_receiver. --- CHANGELOG.md | 1 + __tests__/expression-receiver-calls.test.ts | 13 +++++++++++++ __tests__/kernel-tsjs-parity.test.ts | 1 + codegraph-kernel/src/tsjs/extractors.rs | 7 +++++-- src/extraction/tree-sitter.ts | 5 ++++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bbf5895..4e6f77c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. - 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 field, like `this.listeners[type].push(handler)`, no longer links to an unrelated project method of the same name, such as a `push` on a stack class in another file. - `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 VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. diff --git a/__tests__/expression-receiver-calls.test.ts b/__tests__/expression-receiver-calls.test.ts index e22de1f74..77681464f 100644 --- a/__tests__/expression-receiver-calls.test.ts +++ b/__tests__/expression-receiver-calls.test.ts @@ -24,6 +24,13 @@ const files: Record = { '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 = {};\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" + @@ -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([]); @@ -116,6 +127,8 @@ 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' + ' new Runner().go();\n' + ' window.Api.start();\n' + '}\n'; diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index f408f822a..329f65d1c 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -294,6 +294,7 @@ async function exprReceivers(x, y) { f().list.map(g); (() => 1).call(null); this.a.b.run(); + this.listeners[type].push(h); super.stop(); new Runner().go(); window.Api.start(); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index abaaa24a1..d4f818da9 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1999,11 +1999,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, diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 2cffc3a49..f330ee78d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -628,11 +628,14 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S * `super` (the resolver reads the owner off the enclosing class), a member * chain rooted at either or at `window` (the project-global escape of * {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the - * call). + * call). A subscript anywhere in the chain does not: `this.listeners[type]` is + * an element of a field, which the resolver cannot type, so the bare name + * would exact-match any project method of that name (`push`). */ function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean { let cur: SyntaxNode | null = node; while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) { + if (cur.type === 'subscript_expression') return false; const object = getChildByField(cur, 'object'); cur = object ? peelTsJsReceiver(object) : null; } From 6afe0ffb77cbfce1399ae5e488337117c106b71d Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 21 Oct 2026 16:05:29 -0400 Subject: [PATCH 2/4] fix(js/ts): a call on an element of a this-field reaches the element's type Dropping every subscript chain (previous commit) also lost calls that resolve: `this.handlers[t].handle()` on a `Record`, `this.items[0].save()` on an `Item[]`. Upstream main links these to whichever project method shares the name, often another class's. `this.[k].m()` is now emitted as `this.[].m`, and the resolver reads the element type off the field's declaration (`X[]`, `Array`, `ReadonlyArray`, `Record`) and resolves the method on it, or leaves the call unresolved. `this['svc'].m()` is the field `svc`. Any other subscript chain still emits nothing. Mirrored in the kernel (this_subscript_of). --- CHANGELOG.md | 2 +- __tests__/expression-receiver-calls.test.ts | 3 +- __tests__/kernel-tsjs-parity.test.ts | 7 +- __tests__/ts-this-field-element.test.ts | 91 +++++++++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 36 ++++++++ src/extraction/tree-sitter.ts | 43 +++++++++- src/resolution/name-matcher.ts | 69 ++++++++++++++-- 7 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 __tests__/ts-this-field-element.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6f77c28..967e13348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. - 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 field, like `this.listeners[type].push(handler)`, no longer links to an unrelated project method of the same name, such as a `push` on a stack class in another file. +- 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 holds (`Item[]`, `Array`, `Record`). Before, it linked to whichever project method shared the name, often another class's, and `this.listeners[type].push(handler)` reached an unrelated `push` in another file. - `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 VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. diff --git a/__tests__/expression-receiver-calls.test.ts b/__tests__/expression-receiver-calls.test.ts index 77681464f..17c09f39c 100644 --- a/__tests__/expression-receiver-calls.test.ts +++ b/__tests__/expression-receiver-calls.test.ts @@ -129,6 +129,7 @@ describe('expression receivers', () => { ' 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'; @@ -137,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', ]); }); }); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 329f65d1c..aabba4f4c 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -295,6 +295,9 @@ async function exprReceivers(x, y) { (() => 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(); @@ -305,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', ]); }); diff --git a/__tests__/ts-this-field-element.test.ts b/__tests__/ts-this-field-element.test.ts new file mode 100644 index 000000000..eebb45553 --- /dev/null +++ b/__tests__/ts-this-field-element.test.ts @@ -0,0 +1,91 @@ +/** + * 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`, `Record`), 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 = { + '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 = [];\n' + + ' private handlers: Record = {};\n' + + ' private listeners: Record = {};\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', +}; + +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) => { + const caller = cg.getNodesByName(name).find((n) => n.filePath.endsWith('holder.ts') && 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([]); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index d4f818da9..ea8cddd94 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1582,6 +1582,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.[k].()` — 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 `().`, or emit nothing when it @@ -1663,6 +1669,36 @@ impl<'t> Walker<'t> { Some(self.text(property).to_string()) } + /// A receiver subscripting the enclosing class's object: `this.[k]` + /// → `this.[]`, `this['svc']` → `this.svc`; None for any other + /// subscript. Mirrors tsThisSubscriptReceiver. + fn this_subscript_of(&self, receiver: Node<'t>) -> Option { + 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(x)` parses with `await f` as the callee (#2586), and a /// prefix operator over a generic call the same way /// (`!settings.get('X')`, `void apiRequest()`); the call is to diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f330ee78d..26562e1b7 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -623,14 +623,37 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null; } +/** + * A TS/JS receiver that subscripts the enclosing class's object: an element + * of a field, `this.[k]` → `this.[]`, or a field named by a + * string key, `this['svc']` → `this.svc`. Null for any other subscript. + * Mirrored in the kernel's this_subscript_of. + */ +function tsThisSubscriptReceiver(node: SyntaxNode, source: string): string | null { + if (node.type !== 'subscript_expression') return null; + const objectNode = getChildByField(node, 'object'); + if (!objectNode) return null; + const object = peelTsJsReceiver(objectNode); + if (object.type === 'this') { + const index = getChildByField(node, 'index'); + const key = index?.type === 'string' ? /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(index, source))?.[2] : undefined; + return key ? `this.${key}` : null; + } + if (object.type !== 'member_expression' || getChildByField(object, 'object')?.type !== 'this') return null; + const property = getChildByField(object, 'property'); + if (!property || !THIS_FIELD_PROPERTY_TYPES.has(property.type)) return null; + return `this.${getNodeText(property, source)}[]`; +} + /** * Whether a TS/JS receiver still collapses to the bare method name: `this` / * `super` (the resolver reads the owner off the enclosing class), a member * chain rooted at either or at `window` (the project-global escape of * {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the - * call). A subscript anywhere in the chain does not: `this.listeners[type]` is - * an element of a field, which the resolver cannot type, so the bare name - * would exact-match any project method of that name (`push`). + * call). A subscript anywhere in the chain does not: the resolver cannot + * read the receiver back from it, so the bare name would exact-match any + * project method of that name. (An element of a this-field is typed instead, + * see {@link tsThisSubscriptReceiver}.) */ function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean { let cur: SyntaxNode | null = node; @@ -5937,6 +5960,18 @@ export class TreeSitterExtractor { // its name keeps the `#` (#1987). const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source); calleeName = `this.${fieldName}.${methodName}`; + } else if ( + TS_JS_CHAIN_LANGUAGES.has(this.language) && + receiver && + tsThisSubscriptReceiver(receiver, this.source) !== null + ) { + // An element of a field of the enclosing class — + // `this.listeners[type].push()`, `this.items[0].save()` — kept + // as `this.[].`: the resolver reads the element + // type off the field's declaration (`Item[]`, `Record`) + // or leaves it unresolved. `this['svc'].start()` is the field + // itself. Mirrored in the kernel's extract_call. + calleeName = `${tsThisSubscriptReceiver(receiver, this.source)}.${methodName}`; } else if ( (this.language === 'typescript' || this.language === 'javascript' || @@ -5998,7 +6033,7 @@ export class TreeSitterExtractor { ) { // Any other TS/JS receiver is an expression with no static // type here — `(a ?? b).map()`, `f().list.map()`, - // `arr[0].run()`, `(() => {}).call()`. The bare method name this + // `arr[0].run()`, `this.a.b[k].run()`, `(() => {}).call()`. The bare method name this // used to emit exact-matched whichever project method shared the // name (`(await list()).map()` onto an adapter class's `map`). // Emit nothing: a silent miss, never a wrong edge. Mirrored in diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ee8e44eee..1a65dc2e9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18963,6 +18963,11 @@ export function matchMethodCall( 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); + // An element of a field — `this.listeners[type].push()`, emitted as + // `this.listeners[].push`: the element type the field's declaration + // writes, or nothing. + const elementOf = ref.referenceName.match(/^this\.(#?[\w$]+)\[\]\.([\w$]+)$/); + if (elementOf) return matchTsThisFieldCall(elementOf[1]!, elementOf[2]!, ref, context, true); } const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch; @@ -23068,7 +23073,8 @@ const TS_FIELD_DECL_MEMO = new WeakMap`), whether that is a value's * (`typeof X`), and whether the field holds an array of it (`items: Item[]`). */ -type TsFieldDecl = { valueType: boolean; type: string; array?: boolean }; +/** `element`: what one of the field's elements holds — `Item` of `Item[]`, `Array` or `Record`. */ +type TsFieldDecl = { valueType: boolean; type: string; array?: boolean; element?: string }; /** * The call an initializer hands `new X(…)` to: `this.(…)`, or * `this..(…)` — vscode's `this._register(new Emitter())` and @@ -23147,7 +23153,12 @@ function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string // An injector's type argument is the type it returns: `inject(DIALOG_DATA)`. const type = pattern.wrapped ? m[4] : pattern.injected ? m[2] ?? m[3] : m[1]; if (!type) return null; - const decl: TsFieldOnLine = { valueType: pattern.valueType, type, array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) }; + const rest = line.slice(m.index! + m[0].length); + const decl: TsFieldOnLine = { valueType: pattern.valueType, type, array: TS_ARRAY_SUFFIX.test(rest) }; + if (!pattern.valueType && !pattern.wrapped && !pattern.injected) { + const element = tsFieldElementType(type, rest); + if (element) decl.element = element; + } if (pattern.wrapped) { decl.via = { holder: m[1], method: m[2]! }; if (m[3]) decl.injector = 'createInstance'; @@ -23160,6 +23171,45 @@ function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string return decl; } +/** Container types whose element type is one of their type arguments, and which. */ +const TS_ELEMENT_CONTAINERS: ReadonlyMap = new Map([ + ['Array', { args: 1, at: 0 }], + ['ReadonlyArray', { args: 1, at: 0 }], + ['Record', { args: 2, at: 1 }], +]); + +/** + * The type an element of a field holds, from the field's written type (`type`) + * and what follows it on the line (`rest`): `Item` of `Item[]`, `Array` + * and `Record`. Undefined for any other type, an array of + * arrays, or an element that is not a plain type name (a union, a generic). + */ +function tsFieldElementType(type: string, rest: string): string | undefined { + const plain = (t: string | undefined): string | undefined => (t && /^[A-Za-z_$][\w.$]*$/.test(t) ? t : undefined); + if (/^\s*\[\s*\]/.test(rest)) return /^\s*\[\s*\]\s*\[/.test(rest) ? undefined : type; + const container = TS_ELEMENT_CONTAINERS.get(type); + if (!container) return undefined; + const open = /^\s*' || c === ')' || c === ']' || c === '}') && depth > 0) depth--; + else if (c === ',' && depth === 0) { + args.push(rest.slice(from, i).trim()); + from = i + 1; + } else if (c === '>' && depth === 0) { + args.push(rest.slice(from, i).trim()); + if (/^\s*\[/.test(rest.slice(i + 1))) return undefined; + return args.length === container.args ? plain(args[container.at]) : undefined; + } + } + return undefined; +} + /** The first declaration of `field` on one line, as `line.match` of its patterns in order would find it. */ function tsFieldOnLine(line: string, field: string): TsFieldOnLine | null { let at: number[] | null = null; @@ -23316,7 +23366,7 @@ function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly st if (!tsCallEndsStatement(lines, row, onLine.open!)) return null; if (onLine.via && !tsWrapperReturnsArgument(cls, onLine.via, context)) return null; if (onLine.injector && !tsInjectsInstance(cls, onLine.injector, context)) return null; - return { valueType: onLine.valueType, type: onLine.type, array: onLine.array }; + return { valueType: onLine.valueType, type: onLine.type, array: onLine.array, element: onLine.element }; } /** Per-context memo: does the project declare a `createInstance` that returns an instance of its constructor argument? */ @@ -23687,6 +23737,7 @@ function matchTsThisFieldCall( methodName: string, ref: UnresolvedRef, context: ResolutionContext, + element = false, ): ResolvedRef | null { if (!field || field.includes('.')) return null; const written = context.getNodeById?.(ref.fromNodeId); @@ -23714,7 +23765,14 @@ function matchTsThisFieldCall( } return null; }); - if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context); + // `this.[k].()` calls the element's type: what the field's + // declaration writes for it, or nothing. + const held = (decl: TsFieldRead): TsFieldRead | null => + !element ? decl : !decl.valueType && decl.element ? { valueType: false, type: decl.element } : null; + if (found) { + const decl = held(found.decl); + return decl ? tsFieldCall(decl, found.cls, methodName, ref, context) : null; + } // Else a same-named class elsewhere may declare it, read for a calling // class whose own declaration isn't (a wrapper the reader can't vouch // for), but only with a type the call's file imports or declares: another @@ -23722,7 +23780,8 @@ function matchTsThisFieldCall( const namesake = nmTimedT('mc-thisfield-namesakes', ref, () => { for (const cls of owners) { if (cls.filePath === ref.filePath) continue; - const decl = tsFieldDeclaration(cls, field, context); + const read = tsFieldDeclaration(cls, field, context); + const decl = read && held(read); if (decl && tsTypeVisibleIn(decl.type, ref, context)) return decl; } return null; From 418978fa0f26d8b2da6675c67776b94473bdca85 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 21 Oct 2026 19:33:50 -0400 Subject: [PATCH 3/4] fix(js/ts): an element type that is a class type parameter or a union reaches nothing `class Store { items: Item[] }` read the type parameter `Item` as the project class of that name, and `Item[] | Other[]` as `Item[]`. Both now leave the call unresolved; `Item[] | null` keeps `Item`. --- CHANGELOG.md | 2 +- __tests__/ts-this-field-element.test.ts | 32 +++++++++++++++++++++++-- src/resolution/name-matcher.ts | 27 +++++++++++++++------ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 967e13348..531a52f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. - 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 holds (`Item[]`, `Array`, `Record`). Before, it linked to whichever project method shared the name, often another class's, and `this.listeners[type].push(handler)` reached an unrelated `push` in another file. +- In 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 (`Item[]`, `Array`, `Record`). Before, it linked to whichever project method shared the name, often another class's, and `this.listeners[type].push(handler)` reached an unrelated `push` in another file. Where the element's type isn't written down, as in plain JavaScript, the call is left unlinked rather than guessed. - `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 VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. diff --git a/__tests__/ts-this-field-element.test.ts b/__tests__/ts-this-field-element.test.ts index eebb45553..afefbc9ba 100644 --- a/__tests__/ts-this-field-element.test.ts +++ b/__tests__/ts-this-field-element.test.ts @@ -45,6 +45,24 @@ const files: Record = { ' 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 {\n' + + ' private items: Item[] = [];\n' + + ' private byKey: Record = {};\n' + + ' first() { return this.items[0].save(); }\n' + + ' keyed(k: string) { return this.byKey[k].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' + + '}\n', }; beforeAll(async () => { @@ -62,8 +80,8 @@ afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); }); -const calleesOf = (name: string) => { - const caller = cg.getNodesByName(name).find((n) => n.filePath.endsWith('holder.ts') && n.kind === 'method')!; +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}`) @@ -88,4 +106,14 @@ describe('a call on an element of a this-field', () => { 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([]); + }); + + 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']); + }); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1a65dc2e9..d9be905f0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -23182,11 +23182,19 @@ const TS_ELEMENT_CONTAINERS: ReadonlyMap = * The type an element of a field holds, from the field's written type (`type`) * and what follows it on the line (`rest`): `Item` of `Item[]`, `Array` * and `Record`. Undefined for any other type, an array of - * arrays, or an element that is not a plain type name (a union, a generic). + * arrays, an element that is not a plain type name (a union, a generic), or + * a field typed as a union with another type (`Item[] | Other[]`); `| null` + * and `| undefined` leave the element as it is. */ function tsFieldElementType(type: string, rest: string): string | undefined { const plain = (t: string | undefined): string | undefined => (t && /^[A-Za-z_$][\w.$]*$/.test(t) ? t : undefined); - if (/^\s*\[\s*\]/.test(rest)) return /^\s*\[\s*\]\s*\[/.test(rest) ? undefined : type; + // What follows the field's type unions it with another type. + const unionAfter = (tail: string): boolean => /^\s*\|/.test(tail.replace(/^(?:\s*\|\s*(?:null|undefined)\b)+/, '')); + const array = /^\s*\[\s*\]/.exec(rest); + if (array) { + const tail = rest.slice(array[0].length); + return /^\s*\[/.test(tail) || unionAfter(tail) ? undefined : type; + } const container = TS_ELEMENT_CONTAINERS.get(type); if (!container) return undefined; const open = /^\s*' && depth === 0) { args.push(rest.slice(from, i).trim()); - if (/^\s*\[/.test(rest.slice(i + 1))) return undefined; + if (/^\s*\[/.test(rest.slice(i + 1)) || unionAfter(rest.slice(i + 1))) return undefined; return args.length === container.args ? plain(args[container.at]) : undefined; } } @@ -23767,10 +23775,15 @@ function matchTsThisFieldCall( }); // `this.[k].()` calls the element's type: what the field's // declaration writes for it, or nothing. - const held = (decl: TsFieldRead): TsFieldRead | null => - !element ? decl : !decl.valueType && decl.element ? { valueType: false, type: decl.element } : null; + // A type parameter of the declaring class names no type (`class + // Store { items: Item[] }`), whatever project class shares its name. + const held = (decl: TsFieldRead, cls: Node): TsFieldRead | null => + !element ? decl + : !decl.valueType && decl.element && !tsClassTypeParameter(cls, decl.element, context) + ? { valueType: false, type: decl.element } + : null; if (found) { - const decl = held(found.decl); + const decl = held(found.decl, found.cls); return decl ? tsFieldCall(decl, found.cls, methodName, ref, context) : null; } // Else a same-named class elsewhere may declare it, read for a calling @@ -23781,7 +23794,7 @@ function matchTsThisFieldCall( for (const cls of owners) { if (cls.filePath === ref.filePath) continue; const read = tsFieldDeclaration(cls, field, context); - const decl = read && held(read); + const decl = read && held(read, cls); if (decl && tsTypeVisibleIn(decl.type, ref, context)) return decl; } return null; From a6d089e652df1d14b5c306a2320d6c600f77f4e2 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Wed, 21 Oct 2026 19:52:05 -0400 Subject: [PATCH 4/4] fix(js/ts): read a class's type parameters over a multi-line header, and a union continued below The type-parameter check read three lines and stopped at the first `{`, so `class C<\n A,\n B,\n Item\n>` and `` took `Item` for the project class. It now reads from the class keyword to the list's own `>` with depth tracking, and an unreadable list drops the type. A field type that goes on below with `| Other` (or joins with `&`) holds no one element type. --- CHANGELOG.md | 2 +- __tests__/ts-this-field-element.test.ts | 22 ++++++++++ src/extraction/tree-sitter.ts | 6 ++- src/resolution/name-matcher.ts | 54 +++++++++++++++++++------ 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 531a52f53..d2c45dfc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. - 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 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 (`Item[]`, `Array`, `Record`). Before, it linked to whichever project method shared the name, often another class's, and `this.listeners[type].push(handler)` reached an unrelated `push` in another file. Where the element's type isn't written down, as in plain JavaScript, the call is left unlinked rather than guessed. +- In TypeScript, a method called on an element of a class field, like `this.handlers[type].handle()`, now links to the method of the type the field is declared to hold (`Item[]`, `Record`) 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. - `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 VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. diff --git a/__tests__/ts-this-field-element.test.ts b/__tests__/ts-this-field-element.test.ts index afefbc9ba..2a490741a 100644 --- a/__tests__/ts-this-field-element.test.ts +++ b/__tests__/ts-this-field-element.test.ts @@ -52,6 +52,19 @@ const files: Record = { ' private byKey: Record = {};\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 {\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': @@ -62,6 +75,11 @@ const files: Record = { ' private maybe: Item[] | null = null;\n' + ' either() { return this.entries[0].save(); }\n' + ' nullable() { return this.maybe![0].save(); }\n' + + ' private below: Array\n' + + ' | Other = [];\n' + + ' private joined: Item[] & Other[] = [];\n' + + ' continued() { return this.below[0].save(); }\n' + + ' both() { return this.joined[0].save(); }\n' + '}\n', }; @@ -110,10 +128,14 @@ describe('a call on an element of a this-field', () => { 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([]); }); }); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 26562e1b7..7f12e54dc 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -5786,6 +5786,8 @@ export class TreeSitterExtractor { : rawReceiver && this.language === 'swift' ? peelSwiftCallWrappers(rawReceiver, this.source) : rawReceiver; + // `this.[]` / `this.` of a subscript receiver, set by its branch below. + let thisSubscript: string | null = null; // A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`, // `5.times`, `[].concat(...)` — calls a builtin of the literal's // type, never a project symbol. The bare-name fallback below let @@ -5963,7 +5965,7 @@ export class TreeSitterExtractor { } else if ( TS_JS_CHAIN_LANGUAGES.has(this.language) && receiver && - tsThisSubscriptReceiver(receiver, this.source) !== null + (thisSubscript = tsThisSubscriptReceiver(receiver, this.source)) !== null ) { // An element of a field of the enclosing class — // `this.listeners[type].push()`, `this.items[0].save()` — kept @@ -5971,7 +5973,7 @@ export class TreeSitterExtractor { // type off the field's declaration (`Item[]`, `Record`) // or leaves it unresolved. `this['svc'].start()` is the field // itself. Mirrored in the kernel's extract_call. - calleeName = `${tsThisSubscriptReceiver(receiver, this.source)}.${methodName}`; + calleeName = `${thisSubscript}.${methodName}`; } else if ( (this.language === 'typescript' || this.language === 'javascript' || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d9be905f0..d0ce93b9f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -23073,8 +23073,13 @@ const TS_FIELD_DECL_MEMO = new WeakMap`), whether that is a value's * (`typeof X`), and whether the field holds an array of it (`items: Item[]`). */ -/** `element`: what one of the field's elements holds — `Item` of `Item[]`, `Array` or `Record`. */ -type TsFieldDecl = { valueType: boolean; type: string; array?: boolean; element?: string }; +type TsFieldDecl = { + valueType: boolean; + type: string; + array?: boolean; + /** What one of the field's elements holds: `Item` of `Item[]`, `Array` or `Record`. */ + element?: string; +}; /** * The call an initializer hands `new X(…)` to: `this.(…)`, or * `this..(…)` — vscode's `this._register(new Emitter())` and @@ -23171,6 +23176,14 @@ function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string return decl; } +/** + * Whether what follows a field's type joins it with another type: `| Other[]`, + * `& Extra`. `| null` and `| undefined` do not. + */ +function tsJoinsAnotherType(tail: string): boolean { + return /^\s*[|&]/.test(tail.replace(/^(?:\s*\|\s*(?:null|undefined)\b)+/, '')); +} + /** Container types whose element type is one of their type arguments, and which. */ const TS_ELEMENT_CONTAINERS: ReadonlyMap = new Map([ ['Array', { args: 1, at: 0 }], @@ -23188,12 +23201,10 @@ const TS_ELEMENT_CONTAINERS: ReadonlyMap = */ function tsFieldElementType(type: string, rest: string): string | undefined { const plain = (t: string | undefined): string | undefined => (t && /^[A-Za-z_$][\w.$]*$/.test(t) ? t : undefined); - // What follows the field's type unions it with another type. - const unionAfter = (tail: string): boolean => /^\s*\|/.test(tail.replace(/^(?:\s*\|\s*(?:null|undefined)\b)+/, '')); const array = /^\s*\[\s*\]/.exec(rest); if (array) { const tail = rest.slice(array[0].length); - return /^\s*\[/.test(tail) || unionAfter(tail) ? undefined : type; + return /^\s*\[/.test(tail) || tsJoinsAnotherType(tail) ? undefined : type; } const container = TS_ELEMENT_CONTAINERS.get(type); if (!container) return undefined; @@ -23211,7 +23222,7 @@ function tsFieldElementType(type: string, rest: string): string | undefined { from = i + 1; } else if (c === '>' && depth === 0) { args.push(rest.slice(from, i).trim()); - if (/^\s*\[/.test(rest.slice(i + 1)) || unionAfter(rest.slice(i + 1))) return undefined; + if (/^\s*\[/.test(rest.slice(i + 1)) || tsJoinsAnotherType(rest.slice(i + 1))) return undefined; return args.length === container.args ? plain(args[container.at]) : undefined; } } @@ -23370,11 +23381,17 @@ function tsFieldDeclaration( * before. */ function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly string[], row: number, context: ResolutionContext): TsFieldDecl | null { + // A type that goes on below with `| Other[]` holds no one element type. + if (onLine?.element) { + let below = ''; + for (let r = row + 1; r < lines.length && /^\s*[|&]/.test(lines[r]!); r++) below += ' ' + lines[r]; + if (tsJoinsAnotherType(below)) onLine = { ...onLine, element: undefined }; + } if (!onLine?.via && !onLine?.injector) return onLine; if (!tsCallEndsStatement(lines, row, onLine.open!)) return null; if (onLine.via && !tsWrapperReturnsArgument(cls, onLine.via, context)) return null; if (onLine.injector && !tsInjectsInstance(cls, onLine.injector, context)) return null; - return { valueType: onLine.valueType, type: onLine.type, array: onLine.array, element: onLine.element }; + return { valueType: onLine.valueType, type: onLine.type, array: onLine.array }; } /** Per-context memo: does the project declare a `createInstance` that returns an instance of its constructor argument? */ @@ -23703,14 +23720,25 @@ function tsIndexedPropertyType( return undefined; } -/** Is `name` one of the type parameters the head of the TS class or interface `cls` declares (`class Emitter`)? */ +/** Lines of a class header the type-parameter reader looks through: decorators, then `class C<…>` over several lines. */ +const TS_CLASS_HEAD_LINES = 24; + +/** + * Is `name` one of the type parameters the head of the TS class or interface + * `cls` declares (`class Emitter`)? The list is read from the `class` / + * `interface` keyword to its own `>`, over several lines and through + * constraints and defaults that hold braces (``). + * A list whose `>` is not found reads as declaring `name`: a caller drops + * the type rather than take a parameter for a project class. + */ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContext): boolean { const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? []; - const head = lines.slice(cls.startLine - 1, cls.startLine + 2).join(' ').split('{')[0]!; - const open = new RegExp(`\\b${cls.name.replace(/\$/g, '\\$')}\\s*<`).exec(head); + const head = lines.slice(cls.startLine - 1, cls.startLine - 1 + TS_CLASS_HEAD_LINES).join('\n'); + const open = new RegExp(`\\b(?:class|interface)\\s+${cls.name.replace(/\$/g, '\\$')}\\s*<`).exec(head); if (!open) return false; // The parameters between the `<` after the name and its own `>`, split // at their top level: `T`, `K extends keyof T = never`. + const named = (param: string): boolean => param.trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name; let depth = 0; let from = open.index + open[0].length; for (let i = from; i < head.length; i++) { @@ -23718,14 +23746,14 @@ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContex if (c === '<' || c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') depth--; else if (c === '>' && head[i - 1] !== '=') { - if (depth === 0) return head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name; + if (depth === 0) return named(head.slice(from, i)); depth--; } else if (c === ',' && depth === 0) { - if (head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name) return true; + if (named(head.slice(from, i))) return true; from = i + 1; } } - return false; + return true; } /**