Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3892e7d
chore: gitignore napi-generated artifacts in crates/codegraph-core
carlos-alm Jun 13, 2026
ef8ea4f
chore(tests): remove unused biome suppression in visitor.test.ts
carlos-alm Jun 13, 2026
a372b82
fix(titan-run): sync --start-from enum and phase-timestamp list with …
carlos-alm Jun 13, 2026
9a52c7c
fix(hooks): track Bash file modifications via before/after git status…
carlos-alm Jun 13, 2026
85a26df
chore(native): remove dead code (unused var, method, variant, fields)
carlos-alm Jun 13, 2026
184d221
refactor(native): extract emit_pts_alias_edges params into PtsAliasCt…
carlos-alm Jun 13, 2026
909e1df
fix(wasm): sort call targets by confidence before emit to match nativ…
carlos-alm Jun 13, 2026
66fc899
fix(bench): add 2 warmup runs and raise INCREMENTAL_RUNS to 5 for inc…
carlos-alm Jun 13, 2026
84e1a5f
ci(bench): add per-PR perf canary for extractor/graph/native changes
carlos-alm Jun 13, 2026
d07b358
fix(perf): plumb symbolsOnly through parseFilesWasmInline to skip ana…
carlos-alm Jun 13, 2026
3db5d8c
fix(perf): scope runPostNativeCha to changed files on incremental builds
carlos-alm Jun 13, 2026
8b3aa3d
fix(native): add post-pass phase timings to result.phases
carlos-alm Jun 13, 2026
fd4ffd1
fix(perf): correct INLINE_BACKFILL_THRESHOLD docstring; raise thresho…
carlos-alm Jun 13, 2026
498ee21
fix(perf): guard post-native passes against unnecessary work on 1-fil…
carlos-alm Jun 13, 2026
61a9839
chore(types): remove dead protoMethodsMs field and stale comment
carlos-alm Jun 13, 2026
5f5d4d2
fix: class-scope field annotation typeMap keys to prevent cross-class…
carlos-alm Jun 13, 2026
bd5292e
Merge remote-tracking branch 'origin/main' into fix/field-typemap-cla…
carlos-alm Jun 13, 2026
cff36c6
fix(perf): update stale parseFilesWasmForBackfill docstring to refere…
carlos-alm Jun 13, 2026
06a911e
fix(resolver): check class-scoped typeMap key before bare fallback fo…
carlos-alm Jun 13, 2026
84f6c7c
test(1458): add Rust multi-class field collision unit test and end-to…
carlos-alm Jun 13, 2026
76a4380
fix(test): use TypeScript parser for field annotation collision test
carlos-alm Jun 13, 2026
1bb912a
fix: resolve merge conflicts with main
carlos-alm Jun 14, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -713,20 +713,21 @@ fn resolve_call_targets<'a>(
// Phase 8.3f: callee-scoped rest-param key (`callee::restName`) avoids
// same-name rest-binding collisions across functions in the same file (#1358).
let rest_param_key = format!("{}::{}", caller_name, effective_receiver);
// Class-scoped key (`ClassName.prop`) seeded by `this.prop = new Ctor()`
// property writes — prevents false edges when multiple classes define the
// same property name (issue #1323). Only consulted for `this.` receivers.
// Class-scoped key (`ClassName.prop`) seeded by `this.prop = new Ctor()` and
// field annotations — prevents false edges when multiple classes define the same
// property name (issues #1323, #1458). Consulted first for `this.` receivers so
// bare fallback keys (confidence 0.6) don't shadow the correct per-class entry.
let class_scoped_key = if receiver.starts_with("this.") && !caller_name.is_empty() {
caller_name
.rfind('.')
.map(|dot| format!("{}.{}", &caller_name[..dot], effective_receiver))
} else {
None
};
let type_lookup = type_map.get(effective_receiver)
let type_lookup = class_scoped_key.as_deref().and_then(|k| type_map.get(k))
.or_else(|| type_map.get(effective_receiver))
.or_else(|| type_map.get(receiver.as_str()))
.or_else(|| if caller_name.is_empty() { None } else { type_map.get(rest_param_key.as_str()) })
.or_else(|| class_scoped_key.as_deref().and_then(|k| type_map.get(k)));
.or_else(|| if caller_name.is_empty() { None } else { type_map.get(rest_param_key.as_str()) });
// Inline new-expression receiver: `(new Foo).bar()` — extract the constructor name
// when no typeMap entry exists for the complex receiver expression.
// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` in call-resolver.ts.
Expand Down
80 changes: 75 additions & 5 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep
}
}
// TypeScript class field declarations: `private repo: Repository<User>`
// Seeds both "repo" and "this.repo" so that `this.repo.method()` calls
// can be resolved to the interface/class type via the type map.
// Seeds a class-scoped key `ClassName.field` (confidence 0.9) as the primary
// entry so that two classes with identically-named fields don't overwrite each
// other's typeMap entry (issue #1458). The resolver's `CallerClass.X` fallback
// looks up exactly this key.
// Bare `field` and `this.field` keys are kept at lower confidence (0.6) as
// fallbacks for single-class files where the resolver may lack callerClass context.
// Mirrors handleFieldDefTypeMap in src/extractors/javascript.ts.
"public_field_definition" | "field_definition" => {
let name_node = node.child_by_field_name("name")
.or_else(|| node.child_by_field_name("property"))
Expand All @@ -216,9 +221,33 @@ fn match_js_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, _dep
let field_name = node_text(&name_node, source).to_string();
if let Some(type_anno) = find_child(node, "type_annotation") {
if let Some(type_name) = extract_simple_type_name(&type_anno, source) {
push_type_map_entry(symbols, field_name.clone(), type_name.to_string());
// "this.fieldName" key resolves `this.repo.method()` calls.
push_type_map_entry(symbols, format!("this.{}", field_name), type_name.to_string());
match enclosing_type_map_class(node, source) {
Some(class_name) => {
// Primary: class-scoped key prevents cross-class collision.
push_type_map_entry(
symbols,
format!("{}.{}", class_name, field_name),
type_name.to_string(),
);
// Fallback bare keys at lower confidence.
symbols.type_map.push(TypeMapEntry {
name: field_name.clone(),
type_name: type_name.to_string(),
confidence: 0.6,
});
symbols.type_map.push(TypeMapEntry {
name: format!("this.{}", field_name),
type_name: type_name.to_string(),
confidence: 0.6,
});
}
None => {
// No enclosing class declaration (e.g. class expression)
// — use bare keys only at full confidence.
push_type_map_entry(symbols, field_name.clone(), type_name.to_string());
push_type_map_entry(symbols, format!("this.{}", field_name), type_name.to_string());
}
}
}
}
Comment on lines +224 to 252

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No Rust-side regression test for issue #1458

The TS test file gets two new cases for the collision scenario, but no parallel Rust unit test is added. The existing tests in javascript.rs verify the ClassName.field key is seeded (e.g., line 3671 A.t, line 3722 C.helper), but none cover the multi-class scenario where two classes define the same field name. A minimal test mirroring the new TS prevents cross-class collision case would confirm parity between the two extractors.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added field_annotation_multi_class_seeds_separate_scoped_keys Rust unit test in crates/codegraph-core/src/extractors/javascript.rs. It parses two classes with identically-named fields and asserts OrderService.repo → OrderRepository @ 0.9 and UserService.repo → UserRepository @ 0.9 are both present as separate entries — mirroring the TS collision test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test was using parse_js() (tree-sitter-javascript) but the fixture uses TypeScript-specific syntax (private field declarations with type annotations). The JS grammar doesn't support type annotations so the type_map came back empty, causing the assertion to fail on CI. Fixed by adding parse_ts() using LANGUAGE_TYPESCRIPT and switching the test to it. All 396 Rust unit tests now pass locally.

}
Expand Down Expand Up @@ -2946,6 +2975,15 @@ mod tests {
JsExtractor.extract(&tree, code.as_bytes(), "test.js")
}

fn parse_ts(code: &str) -> FileSymbols {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
.unwrap();
let tree = parser.parse(code.as_bytes(), None).unwrap();
JsExtractor.extract(&tree, code.as_bytes(), "test.ts")
}

#[test]
fn finds_function_declaration() {
let s = parse_js("function greet(name) { return name; }");
Expand Down Expand Up @@ -3569,6 +3607,38 @@ mod tests {
assert_eq!(tm.unwrap().type_name, "HttpClient");
}

/// Issue #1458: two classes with identically-named field annotations must
/// produce separate class-scoped typeMap keys, not overwrite each other.
/// Mirrors the TS `prevents cross-class collision` test.
#[test]
fn field_annotation_multi_class_seeds_separate_scoped_keys() {
let s = parse_ts(
"class OrderService {\n\
private repo: OrderRepository;\n\
}\n\
class UserService {\n\
private repo: UserRepository;\n\
}",
);
let order_entry = s.type_map.iter().find(|t| t.name == "OrderService.repo");
assert!(
order_entry.is_some(),
"type_map should contain 'OrderService.repo'; got: {:?}",
s.type_map.iter().map(|e| &e.name).collect::<Vec<_>>()
);
assert_eq!(order_entry.unwrap().type_name, "OrderRepository");
assert_eq!(order_entry.unwrap().confidence, 0.9);

let user_entry = s.type_map.iter().find(|t| t.name == "UserService.repo");
assert!(
user_entry.is_some(),
"type_map should contain 'UserService.repo'; got: {:?}",
s.type_map.iter().map(|e| &e.name).collect::<Vec<_>>()
);
assert_eq!(user_entry.unwrap().type_name, "UserRepository");
assert_eq!(user_entry.unwrap().confidence, 0.9);
}

/// Issue #1453 (edge 4): `const f = fn.bind(ctx)` must record a
/// fnRefBinding f → fn so later `f()` calls resolve through pts.
#[test]
Expand Down
23 changes: 13 additions & 10 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,25 @@ export function resolveByMethodOrGlobal(
const effectiveReceiver = call.receiver.startsWith('this.')
? call.receiver.slice('this.'.length)
: call.receiver;
// For this.prop receivers, also try the class-scoped key (ClassName.prop) seeded by
// handlePropWriteTypeMap — prevents false edges when multiple classes define the same
// property name (issue #1323).
let typeEntry =
typeMap.get(effectiveReceiver) ??
typeMap.get(call.receiver) ??
// Phase 8.3f: callee-scoped rest-param key (`callee::restName`) to avoid
// same-name rest-binding collision across functions in the same file (#1358).
(callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined);
if (!typeEntry && call.receiver.startsWith('this.') && callerName) {
// For this.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by
// handlePropWriteTypeMap / handleFieldDefTypeMap — prevents false edges when multiple
// classes define the same property name (issues #1323, #1458).
// Class-scoped lookup runs first so bare fallback keys (confidence 0.6) don't shadow
// the correct per-class entry when callerName is available.
let typeEntry: unknown;
if (call.receiver.startsWith('this.') && callerName) {
const dotIdx = callerName.lastIndexOf('.');
if (dotIdx > -1) {
const callerClass = callerName.slice(0, dotIdx);
typeEntry = typeMap.get(`${callerClass}.${effectiveReceiver}`);
}
}
typeEntry ??=
typeMap.get(effectiveReceiver) ??
typeMap.get(call.receiver) ??
// Phase 8.3f: callee-scoped rest-param key (`callee::restName`) to avoid
// same-name rest-binding collision across functions in the same file (#1358).
(callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined);
let typeName = typeEntry
? typeof typeEntry === 'string'
? typeEntry
Expand Down
37 changes: 29 additions & 8 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1867,7 +1867,7 @@ function runContextCollectorWalk(rootNode: TreeSitterNode, out: ContextCollector
} else if (t === 'required_parameter' || t === 'optional_parameter') {
handleParamTypeMap(node, out.typeMap);
} else if (t === 'public_field_definition' || t === 'field_definition') {
handleFieldDefTypeMap(node, out.typeMap);
handleFieldDefTypeMap(node, out.typeMap, typeMapClass);
} else if (t === 'assignment_expression') {
handlePropWriteTypeMap(node, out.typeMap, typeMapClass);
} else if (t === 'call_expression') {
Expand Down Expand Up @@ -2094,11 +2094,23 @@ function handleParamTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEn

/**
* Extract type info from a class field declaration: `private repo: Repository<User>`.
* Seeds both "repo" and "this.repo" so `this.repo.method()` calls resolve to the
* declared type via the type map. Mirrors the field_definition branch of
* match_js_type_map in crates/codegraph-core/src/extractors/javascript.rs.
*
* Seeds a class-scoped key `ClassName.field` (confidence 0.9) as the primary entry
* so that two classes with identically-named fields don't overwrite each other's
* typeMap entry (issue #1458). The resolver's `CallerClass.X` fallback (call-resolver.ts
* line 110) looks up exactly this key.
*
* Bare `field` and `this.field` keys are kept at lower confidence (0.6) as fallbacks
* for single-class files where the resolver may not have a callerClass context.
*
* Mirrors the field_definition branch of match_js_type_map in
* crates/codegraph-core/src/extractors/javascript.rs.
*/
function handleFieldDefTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEntry>): void {
function handleFieldDefTypeMap(
node: TreeSitterNode,
typeMap: Map<string, TypeMapEntry>,
currentClass: string | null,
): void {
const nameNode =
node.childForFieldName('name') ||
node.childForFieldName('property') ||
Expand All @@ -2115,9 +2127,18 @@ function handleFieldDefTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMa
if (!typeAnno) return;
const typeName = extractSimpleTypeName(typeAnno);
if (!typeName) return;
setTypeMapEntry(typeMap, nameNode.text, typeName, 0.9);
// "this.fieldName" key resolves `this.repo.method()` calls.
setTypeMapEntry(typeMap, `this.${nameNode.text}`, typeName, 0.9);
if (currentClass) {
// Primary: class-scoped key prevents cross-class collision (issue #1458).
setTypeMapEntry(typeMap, `${currentClass}.${nameNode.text}`, typeName, 0.9);
// Fallback: bare keys at lower confidence for single-class files or when
// the resolver does not have a callerClass in scope.
setTypeMapEntry(typeMap, nameNode.text, typeName, 0.6);
setTypeMapEntry(typeMap, `this.${nameNode.text}`, typeName, 0.6);
} else {
// No enclosing class declaration (e.g. class expression) — use bare keys only.
setTypeMapEntry(typeMap, nameNode.text, typeName, 0.9);
setTypeMapEntry(typeMap, `this.${nameNode.text}`, typeName, 0.9);
}
}

/**
Expand Down
111 changes: 111 additions & 0 deletions tests/integration/issue-1458-cross-class-field-typemap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Integration test for #1458: cross-class field annotation typeMap collision.
*
* Two classes with identically-named fields (`repo`) caused the later class's
* annotation to overwrite the earlier one's bare typeMap key. `this.repo.save()`
* inside UserService would resolve to OrderRepository instead of UserRepository.
*
* Fix: handleFieldDefTypeMap seeds `ClassName.field` at confidence 0.9 as the
* primary key; the resolver checks the class-scoped key before bare fallback keys
* for `this.` receivers so the correct type is always chosen.
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildGraph } from '../../src/domain/graph/builder.js';

const FIXTURE = {
'services.ts': `
class OrderRepository {
save(order: unknown) {}
}
class UserRepository {
save(user: unknown) {}
}
class OrderService {
private repo: OrderRepository;
run() { this.repo.save({}); }
}
class UserService {
private repo: UserRepository;
run() { this.repo.save({}); }
}
`,
};

let tmpDir: string;

beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1458-'));
for (const [rel, content] of Object.entries(FIXTURE)) {
fs.writeFileSync(path.join(tmpDir, rel), content);
}
await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true });
});

afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

function readCallEdges(dbPath: string) {
const db = new Database(dbPath, { readonly: true });
try {
return db
.prepare(
`SELECT n1.name AS src, n2.name AS tgt
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.kind = 'calls'
ORDER BY n1.name, n2.name`,
)
.all() as Array<{ src: string; tgt: string }>;
} finally {
db.close();
}
}

describe('cross-class field annotation typeMap collision (#1458)', () => {
it('resolves this.repo.save() inside OrderService.run to OrderRepository.save', () => {
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
const edges = readCallEdges(dbPath);
const edge = edges.find(
(e) => e.src === 'OrderService.run' && e.tgt === 'OrderRepository.save',
);
expect(
edge,
'OrderService.run → OrderRepository.save edge missing; cross-class collision may be present',
).toBeDefined();
});

it('resolves this.repo.save() inside UserService.run to UserRepository.save', () => {
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
const edges = readCallEdges(dbPath);
const edge = edges.find((e) => e.src === 'UserService.run' && e.tgt === 'UserRepository.save');
expect(
edge,
'UserService.run → UserRepository.save edge missing; cross-class collision may be present',
).toBeDefined();
});

it('does not emit a false edge from OrderService.run to UserRepository.save', () => {
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
const edges = readCallEdges(dbPath);
const falseEdge = edges.find(
(e) => e.src === 'OrderService.run' && e.tgt === 'UserRepository.save',
);
expect(falseEdge).toBeUndefined();
});

it('does not emit a false edge from UserService.run to OrderRepository.save', () => {
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
const edges = readCallEdges(dbPath);
const falseEdge = edges.find(
(e) => e.src === 'UserService.run' && e.tgt === 'OrderRepository.save',
);
expect(falseEdge).toBeUndefined();
});
});
34 changes: 31 additions & 3 deletions tests/parsers/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,43 @@ describe('JavaScript parser', () => {
expect(symbols.typeMap.get('res')).toEqual({ type: 'Response', confidence: 0.9 });
});

it('extracts class field annotations into typeMap with confidence 0.9', () => {
it('extracts class field annotations into class-scoped typeMap key (issue #1458)', () => {
const symbols = parseTS(`
class UserService {
private repo: Repository;
run() { this.repo.save(); }
}
`);
expect(symbols.typeMap.get('repo')).toEqual({ type: 'Repository', confidence: 0.9 });
expect(symbols.typeMap.get('this.repo')).toEqual({ type: 'Repository', confidence: 0.9 });
// Primary: class-scoped key at 0.9 — prevents cross-class collision.
expect(symbols.typeMap.get('UserService.repo')).toEqual({
type: 'Repository',
confidence: 0.9,
});
// Fallback bare keys at lower confidence for single-class files.
expect(symbols.typeMap.get('repo')).toEqual({ type: 'Repository', confidence: 0.6 });
expect(symbols.typeMap.get('this.repo')).toEqual({ type: 'Repository', confidence: 0.6 });
});

it('prevents cross-class collision for same-named fields (issue #1458)', () => {
const symbols = parseTS(`
class OrderService {
private repo: OrderRepository;
}
class UserService {
private repo: UserRepository;
}
`);
// Each class gets its own scoped key — no collision.
expect(symbols.typeMap.get('OrderService.repo')).toEqual({
type: 'OrderRepository',
confidence: 0.9,
});
expect(symbols.typeMap.get('UserService.repo')).toEqual({
type: 'UserRepository',
confidence: 0.9,
});
// Bare "repo" key should hold the first class's type at 0.6 (second write is same confidence, no overwrite).
expect(symbols.typeMap.get('repo')?.confidence).toBe(0.6);
});
Comment on lines +237 to 257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Collision test only covers extractor output, not resolution

The new prevents cross-class collision test verifies that the correct keys are seeded in typeMap, but it doesn't exercise the resolver. Because the resolver checks bare repo before ClassName.repo, an end-to-end test that calls resolveCallEdges with callerName = "UserService.run" and asserts the resolved type is UserRepository (not OrderRepository) would immediately expose the gap described above. Without a resolver-level assertion, this test can pass while the original bug remains active in production.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The resolver (call-resolver.ts) now checks the class-scoped key (ClassName.prop) before bare fallback keys for this. receivers. Added integration test issue-1458-cross-class-field-typemap.test.ts that calls buildGraph with both classes in the same file and asserts OrderService.run → OrderRepository.save and UserService.run → UserRepository.save, with no false cross-class edges.


it('returns empty typeMap when no annotations', () => {
Expand Down
Loading