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 @@ -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.
- **Explore's Relationships list marks a call matched by name alone.** `codegraph_node`'s trail and explore's Flow already label a hop the resolver could bind only by the callee's name as `[name match, unverified]`; the flat Relationships list (shown for repositories of 500 files or more) now does too, so every place explore draws a call edge shows the same warning.
- **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.
Expand Down
20 changes: 20 additions & 0 deletions __tests__/field-chain-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,26 @@ 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);'],
['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,
'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([]);
Expand Down
76 changes: 74 additions & 2 deletions codegraph-kernel/src/resolve/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ impl KernelResolver {
head.push(' ');
}
let inherits = re!(r"\bextends\b").is_match(&head);
let mut forwarded: Option<bool> = 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);
Expand Down Expand Up @@ -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..];
Expand Down Expand Up @@ -1157,6 +1159,76 @@ 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. 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<KNode>], 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 lines: Vec<String> = 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| {
Comment thread
bompus marked this conversation as resolved.
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 arg_start = 0;
for (i, c) in rest.char_indices() {
match c {
'(' | '[' | '{' => 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;
}
}
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<char> = 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
Expand Down Expand Up @@ -1190,7 +1262,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))
Expand Down
Loading