From d889b53977a3323d74588bb2bb32f63f7c667b19 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 11 Oct 2026 01:53:06 -0600 Subject: [PATCH 1/2] fix(resolve): a plain constructor parameter names an inherited field only when passed to super --- CHANGELOG.md | 1 + __tests__/field-chain-owner.test.ts | 17 +++++++++++ codegraph-kernel/src/resolve/fields.rs | 42 ++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18af4965a7..11c8bf156a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **A derived TypeScript class no longer treats an unforwarded constructor parameter as a field.** In `class B extends A { constructor(req: Request) { super(); } }`, a call through `this.req` was resolved as if the parameter declared `req`, even when nothing passed it on. A plain constructor parameter now names an inherited field only when the constructor hands it to `super(...)` as an argument; `super(options.req)` does not count. - **A TypeScript property written as a getter now types the calls made through it.** `c.req.header()` reached nothing when `req` was `get req(): HonoRequest`, because only `req: T` fields were read. A plain constructor parameter named like the field (`constructor(req: Request)`) was also taken for the field's declaration and hid the real one; it now counts only with a modifier (`private req: T`), or in a class that extends another and may pass it to `super`. On hono this adds 74 call edges and removes none, and no added edge contradicts the compiler where it reached the caller; on zod the edges are unchanged. - **Indexing no longer depends on the runtime's directory order.** Node lists a directory's files alphabetically while Bun lists them in the filesystem's own order, so under Bun files were indexed in a different order and equal-score search and explore results came out differently from Node's. Directories are now read in name order on both. - The first session search over a large Claude Code history is faster: transcripts of 256 KB and up are scanned for the entries that can hold prose instead of every line being decoded and parsed, since about 95% of a transcript is tool traffic. The indexed passages and titles are identical (checked on 536 real transcripts); spaced JSON or a large file with no match falls back to the whole-file parse. In-process cold build of those 536 transcripts: about 1.0 s before, 0.7 s after. diff --git a/__tests__/field-chain-owner.test.ts b/__tests__/field-chain-owner.test.ts index 965eecf806..68fd0f1f2b 100644 --- a/__tests__/field-chain-owner.test.ts +++ b/__tests__/field-chain-owner.test.ts @@ -138,6 +138,23 @@ export class Notifier extends Base { expect(calls('Notifier::send')).toEqual(['Mailer::send@src/mailer.ts']); }); + it.each([ + ['nothing', 'super();'], + ['a member of another value', 'super(options.mailer);'], + ])('does not read a field from a constructor parameter the derived class passes %s to super', async (_label, call) => { + await project({ + 'src/mailer.ts': mailer, + 'src/notifier.ts': `import { Mailer } from './mailer'; +export class Base {} +export class Notifier extends Base { + constructor(mailer: Mailer, options: { mailer: Mailer }) { ${call} } + send(msg: string): string { return this.mailer.send(msg); } +} +`, + }); + expect(calls('Notifier::send')).toEqual([]); + }); + it('does not bind an array method to the element type', async () => { await project({ 'src/mailer.ts': mailer, 'src/notifier.ts': notifier('', 'Mailer[]') }); expect(calls('Notifier::push')).toEqual([]); diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index f526f3cac2..78358e658d 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -394,6 +394,7 @@ impl KernelResolver { head.push(' '); } let inherits = re!(r"\bextends\b").is_match(&head); + let mut forwarded: Option = None; for (i, raw) in source.get(start..end).unwrap_or_default().iter().enumerate() { let line_no = (start + i + 1) as i64; let line = strip_line_comments(raw); @@ -441,7 +442,8 @@ impl KernelResolver { Some(v) => v, None => methods.insert(self.class_methods(owner)?), }; - if !ts_declares_field(methods, affix, field, FieldSite { line: &line, line_no, at }, &line[gs..ge], inherits) { + let hands_to_super = inherits && *forwarded.get_or_insert_with(|| ctor_forwards_to_super(&source, methods, field)); + if !ts_declares_field(methods, affix, field, FieldSite { line: &line, line_no, at }, &line[gs..ge], hands_to_super) { continue; } let rest = &line[m.end..]; @@ -1157,6 +1159,42 @@ pub(super) struct TsFieldDecl { pub(super) nullable: bool, } +/// Whether the class's constructor passes `field` as an argument of `super(...)`, +/// the only way a plain constructor parameter names an inherited field. An +/// argument that merely contains the name (`options.field`) does not count. +fn ctor_forwards_to_super(source: &SourceFile, methods: &[Arc], field: &str) -> bool { + let Some(ctor) = methods.iter().find(|m| m.name == "constructor") else { return false }; + let from = (ctor.start_line - 1).max(0) as usize; + let to = (ctor.end_line as usize).min(source.len()); + let body = source.get(from..to).unwrap_or_default().iter().map(|l| strip_line_comments(l)).collect::>().join("\n"); + let forwarded = occurrences(&body, "super", 0).any(|at| { + if at > 0 && is_word_byte(body.as_bytes()[at - 1]) { + return false; + } + let Some(rest) = body[at + 5..].trim_start().strip_prefix('(') else { return false }; + let mut depth = 1; + let mut end = rest.len(); + for (i, c) in rest.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = i; + break; + } + } + _ => {} + } + } + let args = &rest[..end]; + occurrences(args, field, 0).any(|p| { + word_boundary_at(args, p) && word_boundary_at(args, p + field.len()) && !args[..p].trim_end().ends_with('.') + }) + }); + forwarded +} + /// Whether a match at byte `at` of line `line_no` declares the class field /// `field` with type `ty`: outside every method of the class, a /// `this.field = new T` assignment, or a constructor parameter (read back @@ -1190,7 +1228,7 @@ fn ts_declares_field( } // A parameter property (`private x: T`) declares a field. A plain // `x: T` parameter is a local, unless the class extends another - // and may hand it to `super` for an inherited field. + // and hands it to `super` for an inherited field (`inherits`). let head = sig[..s].rfind(field).map(|at| &sig[..at])?; let param = head.rsplit([',', '(']).next().unwrap_or(""); Some(inherits || re!(r"(?:^|\s)(?:public|private|protected|readonly)\s+(?:(?:public|private|protected|readonly|override)\s+)*$").is_match(param)) From 489a60feacafbebff5859b5293fcd965435d12ba Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sun, 11 Oct 2026 02:03:06 -0600 Subject: [PATCH 2/2] fix(resolve): match only bare super() arguments and ignore string literals --- __tests__/field-chain-owner.test.ts | 3 ++ codegraph-kernel/src/resolve/fields.rs | 62 ++++++++++++++++++++------ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/__tests__/field-chain-owner.test.ts b/__tests__/field-chain-owner.test.ts index 68fd0f1f2b..fb3f8fe8c6 100644 --- a/__tests__/field-chain-owner.test.ts +++ b/__tests__/field-chain-owner.test.ts @@ -141,6 +141,9 @@ export class Notifier extends Base { it.each([ ['nothing', 'super();'], ['a member of another value', 'super(options.mailer);'], + ['inside a call', 'super(wrap(mailer));'], + ['as an object property', 'super({ mailer });'], + ['only inside a string', "console.log('super(mailer)'); super();"], ])('does not read a field from a constructor parameter the derived class passes %s to super', async (_label, call) => { await project({ 'src/mailer.ts': mailer, diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index 78358e658d..6e9f3aa67c 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -1160,41 +1160,75 @@ pub(super) struct TsFieldDecl { } /// Whether the class's constructor passes `field` as an argument of `super(...)`, -/// the only way a plain constructor parameter names an inherited field. An -/// argument that merely contains the name (`options.field`) does not count. +/// the only way a plain constructor parameter names an inherited field. Only a +/// bare top-level argument counts: `options.field`, `wrap(field)`, `{ field }` +/// and a `super(field)` inside a string literal do not. fn ctor_forwards_to_super(source: &SourceFile, methods: &[Arc], field: &str) -> bool { let Some(ctor) = methods.iter().find(|m| m.name == "constructor") else { return false }; let from = (ctor.start_line - 1).max(0) as usize; let to = (ctor.end_line as usize).min(source.len()); - let body = source.get(from..to).unwrap_or_default().iter().map(|l| strip_line_comments(l)).collect::>().join("\n"); + let lines: Vec = source.get(from..to).unwrap_or_default().iter().map(|l| strip_line_comments(l)).collect(); + let body = blank_string_literals(&lines.join("\n")); let forwarded = occurrences(&body, "super", 0).any(|at| { if at > 0 && is_word_byte(body.as_bytes()[at - 1]) { return false; } let Some(rest) = body[at + 5..].trim_start().strip_prefix('(') else { return false }; let mut depth = 1; - let mut end = rest.len(); + let mut arg_start = 0; for (i, c) in rest.char_indices() { match c { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - end = i; - break; + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth == 1 => { + if rest[arg_start..i].trim() == field { + return true; } + arg_start = i + 1; } _ => {} } + if depth == 0 { + return rest[arg_start..i].trim() == field; + } } - let args = &rest[..end]; - occurrences(args, field, 0).any(|p| { - word_boundary_at(args, p) && word_boundary_at(args, p + field.len()) && !args[..p].trim_end().ends_with('.') - }) + false }); forwarded } +/// `text` with the contents of every quoted string and template literal +/// replaced by spaces, so a `super(` or a parameter name inside one is not +/// read as code. An unterminated quote blanks to the end. +fn blank_string_literals(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut quote: Option = None; + let mut escaped = false; + for c in text.chars() { + match quote { + None => { + if matches!(c, '\'' | '"' | '`') { + quote = Some(c); + } + out.push(c); + } + Some(q) => { + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == q { + quote = None; + out.push(c); + continue; + } + out.push(if c == '\n' { '\n' } else { ' ' }); + } + } + } + out +} + /// Whether a match at byte `at` of line `line_no` declares the class field /// `field` with type `ty`: outside every method of the class, a /// `this.field = new T` assignment, or a constructor parameter (read back