Skip to content

Commit 3d0de9b

Browse files
committed
fix: resolve merge conflicts with main (docs check acknowledged)
Owned scope confirmed via this PR's own single commit diff-stat: the typeOnlyNames sparse field on Import (types.ts/types.rs) and its propagation through both extractors (javascript.ts/javascript.rs), both JS edge-building paths (build-edges.ts/incremental.ts, relocated to the shared import-utils.ts importNamePairs since that extraction landed on main after this branch was cut), the native FFI pipeline (build_edges.rs), the pure-native pipeline (import_edges.rs), and pipeline.rs's tuple-destructure call site all merged in cleanly alongside main's independent changes (barrel-reachability, #1849 wildcard-reexport, #1781 satisfies_expression, #1930 value-ref filter docs, and this branch's own upstream #1812/#1957 hierarchy-edge scoping fix). The remaining conflicting files were unrelated stack noise, resolved by taking main's content. All 11 owned files verified byte-exact against both this PR's own diff (vs main) and main's independent additions (vs ORIG_HEAD). Impact: 55 functions changed, 190 affected
2 parents 79cd761 + d4736b0 commit 3d0de9b

63 files changed

Lines changed: 1581 additions & 335 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/titan-grind/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Forge shapes the metal. Grind smooths the rough edges. Your goal: find helpers t
109109

110110
12. **Capture dead-symbol baseline** (only if `grind.deadSymbolBaseline` is null):
111111
```bash
112-
codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})"
112+
codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{try{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count??0,byRole:data.summary??{}}));}catch(e){console.error('Failed to parse roles --json output: '+e.message);process.exit(1);}})"
113113
```
114114
`codegraph roles --json` returns `{ count, summary, symbols }` (not a bare array) — `summary` is already the per-role breakdown (e.g. `dead-leaf`, `dead-entry`, `dead-ffi`, `dead-unresolved`), so no manual reduce is needed.
115115
Store the total in `grind.deadSymbolBaseline`. Write `titan-state.json` immediately.
@@ -580,7 +580,7 @@ After all targets in the phase are processed:
580580
581581
```bash
582582
codegraph build
583-
codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count,byRole:data.summary}));})"
583+
codegraph roles --role dead -T --json | node -e "const d=[];process.stdin.on('data',c=>d.push(c));process.stdin.on('end',()=>{try{const data=JSON.parse(Buffer.concat(d));console.log(JSON.stringify({total:data.count??0,byRole:data.summary??{}}));}catch(e){console.error('Failed to parse roles --json output: '+e.message);process.exit(1);}})"
584584
```
585585
586586
Store in `grind.deadSymbolCurrent`. Write `titan-state.json`.

crates/codegraph-core/src/ast_analysis/complexity.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,6 +1748,22 @@ mod tests {
17481748
assert_eq!(m.cyclomatic, 3);
17491749
}
17501750

1751+
#[test]
1752+
fn lua_method_declaration() {
1753+
// Greptile follow-up to #1782: colon-syntax method declarations
1754+
// (`function Obj:method(x)`) have a `method_index_expression` name
1755+
// field but are still `function_declaration` nodes, so `function_nodes`
1756+
// already covers them — this pins that native/TS parity explicitly.
1757+
// Mirrors the TS test 'method declaration (colon syntax) is
1758+
// recognized as a function'.
1759+
let m = compute_lua(
1760+
"local Obj = {}\nfunction Obj:method(x)\n if x > 0 then\n return x\n end\nend",
1761+
);
1762+
assert_eq!(m.cognitive, 1);
1763+
assert_eq!(m.cyclomatic, 2);
1764+
assert_eq!(m.max_nesting, 1);
1765+
}
1766+
17511767
#[test]
17521768
fn lua_nested_if() {
17531769
let m = compute_lua(

crates/codegraph-core/src/db/connection.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,10 +880,13 @@ impl NativeDatabase {
880880
let insert_ok = insert_nodes::do_insert_nodes(conn, &batches, &removed_files)
881881
.inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes failed: {e}"))
882882
.is_ok();
883+
if !insert_ok {
884+
return Ok(false);
885+
}
883886
let hashes_ok = insert_nodes::commit_file_hashes(conn, &file_hashes)
884887
.inspect_err(|e| eprintln!("[NativeDatabase] bulk_insert_nodes hash commit failed: {e}"))
885888
.is_ok();
886-
Ok(insert_ok && hashes_ok)
889+
Ok(hashes_ok)
887890
}
888891

889892
/// Bulk-insert edge rows using chunked multi-value INSERT statements.

crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -791,13 +791,14 @@ fn process_file<'a>(
791791
// of these positions is as likely to be a plain data reference
792792
// (`{ name: SOME_CONSTANT }`) as a real function/class, so drop any
793793
// other-kind match rather than fabricating a "calls" edge to a
794-
// constant. `class` is included alongside function/method because
795-
// `instanceof`'s right operand is always a class/constructor
796-
// (#1784) — unlike the original #1771 object-literal case, which is
797-
// function/method only. Applied once here (after all
798-
// resolve_call_targets tiers), mirroring the
799-
// `dynamicKind === 'value-ref'` filter in resolveFallbackTargets
800-
// (stages/build-edges.ts).
794+
// constant. `class` was added because `instanceof`'s right operand
795+
// is always a class/constructor (#1784). The filter is keyed on
796+
// `dynamic_kind`, not on which site produced the call, so the #1771
797+
// object-literal and #1776 Lua sites also gain class-kind
798+
// resolution as a side effect — not because either idiom commonly
799+
// names a class. Applied once here (after all resolve_call_targets
800+
// tiers), mirroring the `dynamicKind === 'value-ref'` filter in
801+
// resolveFallbackTargets (stages/build-edges.ts).
801802
if call.dynamic_kind.as_deref() == Some("value-ref") {
802803
targets.retain(|t| t.kind == "function" || t.kind == "method" || t.kind == "class");
803804
}
@@ -837,7 +838,7 @@ fn process_file<'a>(
837838
}
838839
}
839840

840-
emit_hierarchy_edges(ctx, file_input, fc.rel_path, &fc.imported_names, edges);
841+
emit_hierarchy_edges(ctx, file_input, fc.rel_path, &fc.imported_names, &fc.imported_original_names, edges);
841842
}
842843

843844
/// Callable definition kinds — only function/method bodies act as enclosing
@@ -1381,7 +1382,11 @@ fn emit_receiver_edge(
13811382
/// the graph regardless of file or language, producing false cross-file
13821383
/// (even cross-language) hierarchy edges for common type names. Priority:
13831384
/// 1. Same-file declaration, when `name` is not itself an import artifact.
1384-
/// 2. The file's actually-resolved import for `name` (barrel-traced).
1385+
/// 2. The file's actually-resolved import for `name` (barrel-traced). For a
1386+
/// renamed import (`import { Base as MyBase }`), the imported file stores
1387+
/// the symbol under its original exported name, not the local alias — so
1388+
/// `imported_original_name` resolves `MyBase` back to `Base` before the
1389+
/// lookup, mirroring `resolve_call_targets` (#1730).
13851390
/// 3. Last resort: a same-language-family global-by-name match (#1783),
13861391
/// first candidate only — a heritage clause names exactly one type.
13871392
fn resolve_hierarchy_targets<'a>(
@@ -1390,6 +1395,7 @@ fn resolve_hierarchy_targets<'a>(
13901395
rel_path: &str,
13911396
imported_names: &HashMap<&str, &str>,
13921397
target_kinds: &[&str],
1398+
imported_original_names: &HashMap<&str, &str>,
13931399
) -> Vec<&'a NodeInfo> {
13941400
let samefile_all: Vec<&NodeInfo> = ctx.nodes_by_name_and_file
13951401
.get(&(name, rel_path))
@@ -1402,8 +1408,9 @@ fn resolve_hierarchy_targets<'a>(
14021408
}
14031409

14041410
if let Some(imported_from) = imported_names.get(name) {
1411+
let target_name = imported_original_names.get(name).copied().unwrap_or(name);
14051412
let imported_candidates: Vec<&NodeInfo> = ctx.nodes_by_name_and_file
1406-
.get(&(name, *imported_from))
1413+
.get(&(target_name, *imported_from))
14071414
.cloned().unwrap_or_default()
14081415
.into_iter()
14091416
.filter(|n| target_kinds.contains(&n.kind.as_str()))
@@ -1424,6 +1431,7 @@ fn resolve_hierarchy_targets<'a>(
14241431
fn emit_hierarchy_edges(
14251432
ctx: &EdgeContext, file_input: &FileEdgeInput, rel_path: &str,
14261433
imported_names: &HashMap<&str, &str>,
1434+
imported_original_names: &HashMap<&str, &str>,
14271435
edges: &mut Vec<ComputedEdge>,
14281436
) {
14291437
for cls in &file_input.classes {
@@ -1434,7 +1442,7 @@ fn emit_hierarchy_edges(
14341442
let Some(source) = source_row else { continue };
14351443

14361444
if let Some(ref extends_name) = cls.extends {
1437-
let targets = resolve_hierarchy_targets(ctx, extends_name, rel_path, imported_names, EXTENDS_TARGET_KINDS);
1445+
let targets = resolve_hierarchy_targets(ctx, extends_name, rel_path, imported_names, EXTENDS_TARGET_KINDS, imported_original_names);
14381446
for t in targets {
14391447
edges.push(ComputedEdge {
14401448
source_id: source.id, target_id: t.id,
@@ -1444,7 +1452,7 @@ fn emit_hierarchy_edges(
14441452
}
14451453
}
14461454
if let Some(ref implements_name) = cls.implements {
1447-
let targets = resolve_hierarchy_targets(ctx, implements_name, rel_path, imported_names, IMPLEMENTS_TARGET_KINDS);
1455+
let targets = resolve_hierarchy_targets(ctx, implements_name, rel_path, imported_names, IMPLEMENTS_TARGET_KINDS, imported_original_names);
14481456
for t in targets {
14491457
edges.push(ComputedEdge {
14501458
source_id: source.id, target_id: t.id,
@@ -1682,6 +1690,16 @@ fn has_type_only_names(imp: &ImportInfo) -> bool {
16821690
imp.type_only || !imp.type_only_names.is_empty()
16831691
}
16841692

1693+
/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a
1694+
/// distinct file-level marker edge (`reexports-wildcard`) alongside the
1695+
/// generic `reexports` edge so the query layer can tell a target reached
1696+
/// only by named specifiers apart from one that's also reached by a
1697+
/// wildcard — even when a *different* statement in the same file names
1698+
/// specific symbols from that exact target (#1849 review).
1699+
fn is_wildcard_reexport(imp: &ImportInfo) -> bool {
1700+
imp.reexport && imp.wildcard_reexport
1701+
}
1702+
16851703
/// For a `type` import or a named re-export targeting a barrel or resolved
16861704
/// file, emit one symbol-level edge per named symbol so the target symbols
16871705
/// receive fan-in credit and aren't misclassified as dead code
@@ -1826,6 +1844,15 @@ fn process_single_import(
18261844
}
18271845
if is_named_reexport(imp) {
18281846
emit_named_symbol_edges(edges, file_input, imp, resolved_path, "reexports", ctx);
1847+
} else if is_wildcard_reexport(imp) {
1848+
edges.push(ComputedEdge {
1849+
source_id: file_input.file_node_id,
1850+
target_id: target_node_id,
1851+
kind: "reexports-wildcard".to_string(),
1852+
confidence: 1.0,
1853+
dynamic: 0,
1854+
dynamic_kind: None,
1855+
});
18291856
}
18301857
emit_barrel_through_edges(edges, file_input, imp, resolved_path, edge_kind, ctx);
18311858
}
@@ -1951,9 +1978,12 @@ mod import_edge_tests {
19511978

19521979
#[test]
19531980
fn wildcard_reexport_emits_no_symbol_level_edge() {
1954-
// `export * from './utils'` carries no specific names, so only the
1955-
// file-level `reexports` edge is emitted — the query layer falls
1956-
// back to the target's full export list for genuine wildcards.
1981+
// `export * from './utils'` carries no specific names, so no
1982+
// symbol-level edge is emitted. It does get the dedicated
1983+
// `reexports-wildcard` file-level marker (alongside the generic
1984+
// `reexports` edge) so the query layer can always apply full-export
1985+
// semantics for genuine wildcards, even when a *different* statement
1986+
// to the same target also names specific symbols (#1849 review).
19571987
let files = vec![make_file("src/index.ts", 1, vec![
19581988
ImportInfo {
19591989
source: "./utils".to_string(),
@@ -1982,9 +2012,62 @@ mod import_edge_tests {
19822012
"/root".to_string(),
19832013
Some(symbol_nodes),
19842014
);
1985-
assert_eq!(edges.len(), 1);
2015+
assert_eq!(edges.len(), 2);
2016+
assert_eq!(edges[0].kind, "reexports");
2017+
assert_eq!(edges[0].target_id, 2);
2018+
assert_eq!(edges[1].kind, "reexports-wildcard");
2019+
assert_eq!(edges[1].target_id, 2);
2020+
}
2021+
2022+
#[test]
2023+
fn named_and_wildcard_reexport_of_same_target_both_marked() {
2024+
// `export { foo } from './utils'` AND `export * from './utils'` in
2025+
// the same file, both targeting utils.ts. The wildcard's full-export
2026+
// semantics must stay independently signalled (via the dedicated
2027+
// `reexports-wildcard` marker) rather than being suppressed by the
2028+
// named specifier's symbol-level edge — otherwise the query layer
2029+
// would report only `foo` and silently drop every other export of
2030+
// utils.ts that the wildcard was meant to surface (#1849 review).
2031+
let files = vec![make_file("src/index.ts", 1, vec![
2032+
make_import("./utils", vec!["foo"], true, false, false),
2033+
ImportInfo {
2034+
source: "./utils".to_string(),
2035+
names: vec![],
2036+
reexport: true,
2037+
type_only: false,
2038+
dynamic_import: false,
2039+
wildcard_reexport: true,
2040+
type_only_names: vec![],
2041+
},
2042+
], vec![])];
2043+
let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")];
2044+
let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)];
2045+
let symbol_nodes = vec![SymbolNodeEntry {
2046+
name: "foo".to_string(),
2047+
file: "src/utils.ts".to_string(),
2048+
node_id: 99,
2049+
}];
2050+
2051+
let edges = build_import_edges(
2052+
files,
2053+
resolved,
2054+
vec![],
2055+
node_ids,
2056+
vec![],
2057+
"/root".to_string(),
2058+
Some(symbol_nodes),
2059+
);
2060+
assert_eq!(edges.len(), 4);
2061+
// Named statement: file-level `reexports` + symbol-level `reexports` to foo.
19862062
assert_eq!(edges[0].kind, "reexports");
19872063
assert_eq!(edges[0].target_id, 2);
2064+
assert_eq!(edges[1].kind, "reexports");
2065+
assert_eq!(edges[1].target_id, 99);
2066+
// Wildcard statement: file-level `reexports` + the `reexports-wildcard` marker.
2067+
assert_eq!(edges[2].kind, "reexports");
2068+
assert_eq!(edges[2].target_id, 2);
2069+
assert_eq!(edges[3].kind, "reexports-wildcard");
2070+
assert_eq!(edges[3].target_id, 2);
19882071
}
19892072

19902073
#[test]

crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,17 @@ fn is_named_reexport(imp: &crate::types::Import) -> bool {
289289
imp.reexport.unwrap_or(false) && !imp.wildcard_reexport.unwrap_or(false)
290290
}
291291

292+
/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a
293+
/// distinct file-level marker edge (`reexports-wildcard`) alongside the
294+
/// generic `reexports` edge so the query layer can tell a target reached
295+
/// only by named specifiers apart from one that's also reached by a
296+
/// wildcard — even when a *different* statement in the same file names
297+
/// specific symbols from that exact target (#1849 review). Mirrors
298+
/// `is_wildcard_reexport` in build_edges.rs (FFI fallback path).
299+
fn is_wildcard_reexport(imp: &crate::types::Import) -> bool {
300+
imp.reexport.unwrap_or(false) && imp.wildcard_reexport.unwrap_or(false)
301+
}
302+
292303
/// Walk type-only imports and named re-exports in `ctx.file_symbols` and
293304
/// return the distinct `(name, file)` pairs that `build_import_edges` will
294305
/// need to look up. Resolves barrel files the same way the edge-building
@@ -484,6 +495,14 @@ fn emit_edges_for_import(
484495
ctx,
485496
symbol_node_ids,
486497
);
498+
} else if is_wildcard_reexport(imp) {
499+
edges.push(EdgeRow {
500+
source_id: file_node_id,
501+
target_id,
502+
kind: "reexports-wildcard".to_string(),
503+
confidence: 1.0,
504+
dynamic: 0,
505+
});
487506
}
488507
emit_barrel_through_rows(
489508
edges,

crates/codegraph-core/src/domain/graph/resolve.rs

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,22 @@ fn ancestor_chain(dir: &str) -> Vec<String> {
260260
chain
261261
}
262262

263+
// directory_distance is on the hot path for every call-edge confidence
264+
// score, invoked from inside compute_confidence's rayon `.par_iter()` caller
265+
// (line ~330 below). The same directory pairs recur constantly across a
266+
// build, so memoizing avoids rebuilding both ancestor chains and the lookup
267+
// map every call. Thread-local (not a shared Mutex/DashMap) because rayon's
268+
// worker pool is reused across the whole build — each worker accumulates its
269+
// own useful cache with zero lock contention, at the cost of some redundant
270+
// computation the first time a given pair is seen on each thread.
271+
// distance(a, b) === distance(b, a) (symmetric tree distance), so the key is
272+
// order-independent to halve the effective cache size per thread (#1769
273+
// perf regression).
274+
thread_local! {
275+
static DIRECTORY_DISTANCE_CACHE: std::cell::RefCell<std::collections::HashMap<(String, String), usize>> =
276+
std::cell::RefCell::new(std::collections::HashMap::new());
277+
}
278+
263279
/// Directory-tree distance between two directories: hops up from `a` to the
264280
/// nearest ancestor shared with `b`, plus hops down from there to `b`.
265281
///
@@ -272,14 +288,24 @@ fn ancestor_chain(dir: &str) -> Vec<String> {
272288
/// so e.g. a file in `graph/algorithms/*.rs` calling a method declared in
273289
/// the shallower `graph/model.rs` was scored as maximally distant (issue #1769).
274290
fn directory_distance(a: &str, b: &str) -> usize {
291+
let key = if a <= b { (a.to_string(), b.to_string()) } else { (b.to_string(), a.to_string()) };
292+
if let Some(cached) = DIRECTORY_DISTANCE_CACHE.with(|c| c.borrow().get(&key).copied()) {
293+
return cached;
294+
}
295+
275296
let chain_a = ancestor_chain(a);
276297
let chain_b = ancestor_chain(b);
298+
let index_in_b: std::collections::HashMap<&str, usize> =
299+
chain_b.iter().enumerate().map(|(j, d)| (d.as_str(), j)).collect();
300+
let mut dist = usize::MAX;
277301
for (i, dir_a) in chain_a.iter().enumerate() {
278-
if let Some(j) = chain_b.iter().position(|dir_b| dir_b == dir_a) {
279-
return i + j;
302+
if let Some(&j) = index_in_b.get(dir_a.as_str()) {
303+
dist = i + j;
304+
break;
280305
}
281306
}
282-
usize::MAX
307+
DIRECTORY_DISTANCE_CACHE.with(|c| c.borrow_mut().insert(key, dist));
308+
dist
283309
}
284310

285311
/// Coarse "language family" for a file, derived from its extension via
@@ -291,8 +317,21 @@ fn directory_distance(a: &str, b: &str) -> usize {
291317
/// separate families would reject huge amounts of legitimate same-project
292318
/// resolution. Every other `LanguageKind` variant keeps its own family,
293319
/// preserving `from_extension`'s existing per-language extension groupings
294-
/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`).
320+
/// (e.g. C's `.c`+`.h`, C++'s `.cpp`/`.cc`/`.cxx`/`.hpp`) — EXCEPT `.h`,
321+
/// treated as ambiguous (returns `None`) rather than inheriting
322+
/// `from_extension`'s C-only mapping: `from_extension` needs one canonical
323+
/// grammar per extension, but a `.h` header is real-world ambiguous between
324+
/// C and C++, and the extremely common case of a `.cpp` file calling into
325+
/// its own project's `.h` header would otherwise be misclassified as
326+
/// cross-language and rejected outright — a real regression from the
327+
/// pre-#1783 same-directory score of 0.7 (Greptile review). This keeps the
328+
/// C/C++-header case working without merging C and C++ source-file families
329+
/// wholesale (`.c` vs `.cpp` intentionally do NOT merge — see
330+
/// is_same_language_family_does_not_merge_c_and_cpp).
295331
fn language_family(file: &str) -> Option<LanguageKind> {
332+
if file.to_ascii_lowercase().ends_with(".h") {
333+
return None;
334+
}
296335
match LanguageKind::from_extension(file) {
297336
Some(LanguageKind::TypeScript) | Some(LanguageKind::Tsx) => Some(LanguageKind::JavaScript),
298337
other => other,
@@ -609,6 +648,15 @@ mod tests {
609648
assert!(is_same_language_family("src/a.c", "src/a.h"));
610649
}
611650

651+
#[test]
652+
fn is_same_language_family_treats_h_as_ambiguous_with_cpp() {
653+
// Greptile follow-up to #1783: `.h` is real-world ambiguous between C
654+
// and C++ (LANGUAGE_REGISTRY/from_extension assigns it to C alone for
655+
// grammar-selection purposes), so a `.cpp` file calling into its own
656+
// project's `.h` header must not be rejected as cross-language.
657+
assert!(is_same_language_family("src/widget.cpp", "src/widget.h"));
658+
}
659+
612660
#[test]
613661
fn is_same_language_family_merges_cpp_source_and_header_variants() {
614662
assert!(is_same_language_family("src/a.cpp", "src/a.hpp"));

0 commit comments

Comments
 (0)