From e8af4b0147a4db30227d358ef6b26c99fcd78cfc Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 22:27:25 -0600 Subject: [PATCH 1/4] fix(resolve): type a TypeScript property written as a getter by its declared return type --- __tests__/js-family-receiver-fixes.test.ts | 35 ++++++++++++++++++++++ codegraph-kernel/src/resolve/fields.rs | 30 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/__tests__/js-family-receiver-fixes.test.ts b/__tests__/js-family-receiver-fixes.test.ts index 18385c38d6..ba2dc4f612 100644 --- a/__tests__/js-family-receiver-fixes.test.ts +++ b/__tests__/js-family-receiver-fixes.test.ts @@ -314,4 +314,39 @@ export function viaUnion(s: Service) { s.many.query(); } cg.close(); } }); + + it('types a property written as a getter by its declared return type', async () => { + // `c.req.header()` reaches HonoRequest.header, not Res.header, because + // `get req(): Req` types `req`. A getter with no return type says nothing. + const cg = await index({ + 'src/req.ts': `export class Req { header(name: string): string { return name; } } +`, + 'src/res.ts': `export class Res { header(name: string): string { return name; } } +`, + 'src/ctx.ts': `import { Req } from './req'; +import { Res } from './res'; +export class Ctx { + #req: Req | undefined; + get req(): Req { + this.#req ??= new Req(); + return this.#req; + } + get res() { + return new Res(); + } +} +`, + 'src/use.ts': `import { Ctx } from './ctx'; +export function fromReq(c: Ctx) { return c.req.header('x'); } +export function fromRes(c: Ctx) { return c.res.header('x'); } +`, + }); + try { + const calls = callsFrom(cg, 'src/use.ts'); + expect(calls.filter((c) => c.includes('src/req.ts:Req::header'))).toHaveLength(1); + expect(calls.some((c) => c.includes('src/res.ts'))).toBe(false); + } finally { + cg.close(); + } + }); }); diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index 9816c3592e..33d6fff704 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -380,6 +380,36 @@ impl KernelResolver { 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); + // `get field(): T` exposes `field` as a property of type `T`. + for at in occurrences(&line, field, 0) { + let gap = &line[..at]; + let before = gap.trim_end(); + let is_getter = gap.len() > before.len() + && before.ends_with("get") + && !before[..before.len() - 3].bytes().next_back().is_some_and(|b| is_word_byte(b) || b == b'.' || b == b'#'); + if !is_getter || line[at + field.len()..].bytes().next().is_some_and(is_word_byte) { + continue; + } + let Some(caps) = re!(r"^\s*\(\s*\)\s*:\s*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_.$]*)").captures(&line[at + field.len()..]) else { + continue; + }; + // The getter's own node starts at `get`; any other method around the line holds a local. + let pos = (line_no, at as i64); + let methods = match &methods { + Some(v) => v, + None => methods.insert(self.class_methods(owner)?), + }; + if methods.iter().any(|m| pos > (m.start_line, m.start_column) && pos <= (m.end_line, m.end_column) && m.start_line != line_no) { + continue; + } + let rest = &line[at + field.len() + caps.get(0).map_or(0, |m| m.end())..]; + return Ok(Some(TsFieldDecl { + ty: caps[1].to_string(), + value_type: false, + typed_collection: guard1_tail_re().is_match(rest), + nullable: nullable_union_tail_re().is_match(rest), + })); + } for (affix, value_type) in TS_FIELD_TYPE_PATTERNS.iter() { let (_, tail) = affix.local(); for at in occurrences(&line, field, 0) { From c9519bdb9d0c2f73679f1cb4b987e324dc788245 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 22:45:33 -0600 Subject: [PATCH 2/4] fix(resolve): read a plain constructor parameter as a field only with a modifier or an extended class --- CHANGELOG.md | 1 + __tests__/js-family-receiver-fixes.test.ts | 9 ++++- codegraph-kernel/src/resolve/fields.rs | 46 +++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0482ce828..195f955207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **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. - The first session search over a large Codex history is faster the same way: rollouts of 256 KB and up are scanned for the message lines and the `session_meta` line instead of every line being decoded and parsed, since about 95% of a rollout is tool traffic. The indexed passages and session ids are identical (checked on 297 real rollouts, and the Claude Code reader's output on 1,331 transcripts after the scan moved to a shared helper); spaced JSON or a large file with no match falls back to the whole-file parse. Parsing those 297 rollouts took about 3.6 s before and 1.6 s after. diff --git a/__tests__/js-family-receiver-fixes.test.ts b/__tests__/js-family-receiver-fixes.test.ts index ba2dc4f612..00c2ab899e 100644 --- a/__tests__/js-family-receiver-fixes.test.ts +++ b/__tests__/js-family-receiver-fixes.test.ts @@ -316,8 +316,9 @@ export function viaUnion(s: Service) { s.many.query(); } }); it('types a property written as a getter by its declared return type', async () => { - // `c.req.header()` reaches HonoRequest.header, not Res.header, because - // `get req(): Req` types `req`. A getter with no return type says nothing. + // `c.req.header()` reaches Req.header, not Res.header, because `get req(): Req` + // types `req` even though the constructor has an untyped-as-field `req: Request` + // parameter. A getter with no return type says nothing. const cg = await index({ 'src/req.ts': `export class Req { header(name: string): string { return name; } } `, @@ -327,6 +328,10 @@ export function viaUnion(s: Service) { s.many.query(); } import { Res } from './res'; export class Ctx { #req: Req | undefined; + // A plain constructor parameter named like the getter is a local, not the field. + constructor(req: Request) { + void req; + } get req(): Req { this.#req ??= new Req(); return this.#req; diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index 33d6fff704..60c42c3e2e 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -377,6 +377,23 @@ impl KernelResolver { let start = (owner.start_line - 1).max(0) as usize; let end = (owner.end_line as usize).min(source.len()); let mut methods: Option>> = None; + // The class heading up to its body, type parameters removed: `class A` + // does not extend anything, `class A extends B` does. + let mut head = String::new(); + let mut depth = 0usize; + 'heading: for raw in source.get(start..end).unwrap_or_default() { + for c in strip_line_comments(raw).chars() { + match c { + '{' if depth == 0 => break 'heading, + '<' => depth += 1, + '>' if depth > 0 => depth -= 1, + _ if depth == 0 => head.push(c), + _ => {} + } + } + head.push(' '); + } + let inherits = re!(r"\bextends\b").is_match(&head); 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); @@ -424,7 +441,7 @@ impl KernelResolver { Some(v) => v, None => methods.insert(self.class_methods(owner)?), }; - if !ts_declares_field(methods, affix, field, &line, line_no, at, &line[gs..ge]) { + if !ts_declares_field(methods, affix, field, FieldSite { line: &line, line_no, at }, &line[gs..ge], inherits) { continue; } let rest = &line[m.end..]; @@ -1120,6 +1137,14 @@ impl KernelResolver { } } +/// Where a candidate field declaration sits: its line text, line number and byte offset. +#[derive(Clone, Copy)] +struct FieldSite<'a> { + line: &'a str, + line_no: i64, + at: usize, +} + /// A TypeScript class-field declaration found by `ts_field_decl`. pub(super) struct TsFieldDecl { /// The declared type text (`Mailer`, `ns.Mailer`, the `typeof` operand). @@ -1143,11 +1168,11 @@ fn ts_declares_field( methods: &[Arc], affix: &'static Affix, field: &str, - line: &str, - line_no: i64, - at: usize, + site: FieldSite, ty: &str, + inherits: bool, ) -> bool { + let FieldSite { line, line_no, at } = site; let pos = (line_no, at as i64); let inside = methods.iter().find(|m| { pos > (m.start_line, m.start_column) && pos <= (m.end_line, m.end_column) @@ -1158,7 +1183,18 @@ fn ts_declares_field( Some(m) if m.name == "constructor" => m .signature .as_deref() - .and_then(|sig| affix.find_from(sig, field, 0).and_then(|hit| hit.group.map(|(s, e)| &sig[s..e] == ty))) + .and_then(|sig| { + let (s, e) = affix.find_from(sig, field, 0)?.group?; + if &sig[s..e] != ty { + return None; + } + // 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. + 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)) + }) .unwrap_or(false), Some(_) => false, } From b195b13a61c228339a1cce4583eb3059173fa282 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 22:58:55 -0600 Subject: [PATCH 3/4] fix(resolve): exclude an object getter inside a one-line method from the class getter lookup --- __tests__/js-family-receiver-fixes.test.ts | 2 ++ codegraph-kernel/src/resolve/fields.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/__tests__/js-family-receiver-fixes.test.ts b/__tests__/js-family-receiver-fixes.test.ts index 00c2ab899e..3cd6d9c247 100644 --- a/__tests__/js-family-receiver-fixes.test.ts +++ b/__tests__/js-family-receiver-fixes.test.ts @@ -328,6 +328,8 @@ export function viaUnion(s: Service) { s.many.query(); } import { Res } from './res'; export class Ctx { #req: Req | undefined; + // An object literal's getter inside a one-line method is not the class's. + probe() { return { get req(): Res { return new Res(); } }; } // A plain constructor parameter named like the getter is a local, not the field. constructor(req: Request) { void req; diff --git a/codegraph-kernel/src/resolve/fields.rs b/codegraph-kernel/src/resolve/fields.rs index 60c42c3e2e..f526f3cac2 100644 --- a/codegraph-kernel/src/resolve/fields.rs +++ b/codegraph-kernel/src/resolve/fields.rs @@ -410,13 +410,13 @@ impl KernelResolver { let Some(caps) = re!(r"^\s*\(\s*\)\s*:\s*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_.$]*)").captures(&line[at + field.len()..]) else { continue; }; - // The getter's own node starts at `get`; any other method around the line holds a local. + // The getter's own node starts at `get` on this line; any other method around it holds a local. let pos = (line_no, at as i64); let methods = match &methods { Some(v) => v, None => methods.insert(self.class_methods(owner)?), }; - if methods.iter().any(|m| pos > (m.start_line, m.start_column) && pos <= (m.end_line, m.end_column) && m.start_line != line_no) { + if methods.iter().any(|m| pos > (m.start_line, m.start_column) && pos <= (m.end_line, m.end_column) && !(m.name == field && m.start_line == line_no)) { continue; } let rest = &line[at + field.len() + caps.get(0).map_or(0, |m| m.end())..]; From 7b9881c26ff18a5ab5be1fc1efce62e5ecf533d8 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 10 Oct 2026 23:21:35 -0600 Subject: [PATCH 4/4] test: cover a plain constructor parameter that is not a field --- __tests__/js-family-receiver-fixes.test.ts | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/__tests__/js-family-receiver-fixes.test.ts b/__tests__/js-family-receiver-fixes.test.ts index 3cd6d9c247..ef1ac7aa12 100644 --- a/__tests__/js-family-receiver-fixes.test.ts +++ b/__tests__/js-family-receiver-fixes.test.ts @@ -356,4 +356,32 @@ export function fromRes(c: Ctx) { return c.res.header('x'); } cg.close(); } }); + + it('does not treat a plain constructor parameter as a field', async () => { + const cg = await index({ + 'src/req.ts': `export class Req { header(name: string): string { return name; } } +`, + 'src/plain.ts': `import { Req } from './req'; +export class Plain { + constructor(req: Req) { + void req; + } +} +export class Held { + constructor(private req: Req) {} +} +`, + 'src/use.ts': `import { Plain, Held } from './plain'; +export function viaPlain(p: Plain) { return p.req.header('x'); } +export function viaHeld(h: Held) { return h.req.header('x'); } +`, + }); + try { + const calls = callsFrom(cg, 'src/use.ts'); + // Only the parameter property `private req` makes `req` a field. + expect(calls.filter((c) => c.includes('src/req.ts:Req::header'))).toHaveLength(1); + } finally { + cg.close(); + } + }); });