Skip to content
Merged
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 @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions __tests__/js-family-receiver-fixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,74 @@ 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 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; } }
`,
'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;
// 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;
}
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();
}
});

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();
}
});
});
76 changes: 71 additions & 5 deletions codegraph-kernel/src/resolve/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,9 +377,56 @@ 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<Vec<Arc<KNode>>> = None;
// The class heading up to its body, type parameters removed: `class A<T extends X>`
// 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);
// `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` 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.name == field && 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) {
Expand All @@ -394,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..];
Expand Down Expand Up @@ -1090,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).
Expand All @@ -1113,11 +1168,11 @@ fn ts_declares_field(
methods: &[Arc<KNode>],
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)
Expand All @@ -1128,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,
}
Expand Down
Loading