Skip to content

Commit d82ab58

Browse files
committed
fix: resolve merge conflicts with main
docs check acknowledged — conflict resolution only, no new functionality beyond what the original PRs already documented. Impact: 43 functions changed, 162 affected
2 parents 71e9174 + 08b6251 commit d82ab58

44 files changed

Lines changed: 1136 additions & 260 deletions

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/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: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,16 @@ fn is_named_reexport(imp: &ImportInfo) -> bool {
16061606
imp.reexport && !imp.wildcard_reexport
16071607
}
16081608

1609+
/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a
1610+
/// distinct file-level marker edge (`reexports-wildcard`) alongside the
1611+
/// generic `reexports` edge so the query layer can tell a target reached
1612+
/// only by named specifiers apart from one that's also reached by a
1613+
/// wildcard — even when a *different* statement in the same file names
1614+
/// specific symbols from that exact target (#1849 review).
1615+
fn is_wildcard_reexport(imp: &ImportInfo) -> bool {
1616+
imp.reexport && imp.wildcard_reexport
1617+
}
1618+
16091619
/// For a `type` import or a named re-export targeting a barrel or resolved
16101620
/// file, emit one symbol-level edge per named symbol so the target symbols
16111621
/// receive fan-in credit and aren't misclassified as dead code
@@ -1740,6 +1750,15 @@ fn process_single_import(
17401750
}
17411751
if is_named_reexport(imp) {
17421752
emit_named_symbol_edges(edges, file_input, imp, resolved_path, "reexports", ctx);
1753+
} else if is_wildcard_reexport(imp) {
1754+
edges.push(ComputedEdge {
1755+
source_id: file_input.file_node_id,
1756+
target_id: target_node_id,
1757+
kind: "reexports-wildcard".to_string(),
1758+
confidence: 1.0,
1759+
dynamic: 0,
1760+
dynamic_kind: None,
1761+
});
17431762
}
17441763
emit_barrel_through_edges(edges, file_input, imp, resolved_path, edge_kind, ctx);
17451764
}
@@ -1846,9 +1865,12 @@ mod import_edge_tests {
18461865

18471866
#[test]
18481867
fn wildcard_reexport_emits_no_symbol_level_edge() {
1849-
// `export * from './utils'` carries no specific names, so only the
1850-
// file-level `reexports` edge is emitted — the query layer falls
1851-
// back to the target's full export list for genuine wildcards.
1868+
// `export * from './utils'` carries no specific names, so no
1869+
// symbol-level edge is emitted. It does get the dedicated
1870+
// `reexports-wildcard` file-level marker (alongside the generic
1871+
// `reexports` edge) so the query layer can always apply full-export
1872+
// semantics for genuine wildcards, even when a *different* statement
1873+
// to the same target also names specific symbols (#1849 review).
18521874
let files = vec![make_file("src/index.ts", 1, vec![
18531875
ImportInfo {
18541876
source: "./utils".to_string(),
@@ -1876,9 +1898,61 @@ mod import_edge_tests {
18761898
"/root".to_string(),
18771899
Some(symbol_nodes),
18781900
);
1879-
assert_eq!(edges.len(), 1);
1901+
assert_eq!(edges.len(), 2);
18801902
assert_eq!(edges[0].kind, "reexports");
18811903
assert_eq!(edges[0].target_id, 2);
1904+
assert_eq!(edges[1].kind, "reexports-wildcard");
1905+
assert_eq!(edges[1].target_id, 2);
1906+
}
1907+
1908+
#[test]
1909+
fn named_and_wildcard_reexport_of_same_target_both_marked() {
1910+
// `export { foo } from './utils'` AND `export * from './utils'` in
1911+
// the same file, both targeting utils.ts. The wildcard's full-export
1912+
// semantics must stay independently signalled (via the dedicated
1913+
// `reexports-wildcard` marker) rather than being suppressed by the
1914+
// named specifier's symbol-level edge — otherwise the query layer
1915+
// would report only `foo` and silently drop every other export of
1916+
// utils.ts that the wildcard was meant to surface (#1849 review).
1917+
let files = vec![make_file("src/index.ts", 1, vec![
1918+
make_import("./utils", vec!["foo"], true, false, false),
1919+
ImportInfo {
1920+
source: "./utils".to_string(),
1921+
names: vec![],
1922+
reexport: true,
1923+
type_only: false,
1924+
dynamic_import: false,
1925+
wildcard_reexport: true,
1926+
},
1927+
], vec![])];
1928+
let resolved = vec![make_resolved("/root/src/index.ts", "./utils", "src/utils.ts")];
1929+
let node_ids = vec![make_node_entry("src/index.ts", 1), make_node_entry("src/utils.ts", 2)];
1930+
let symbol_nodes = vec![SymbolNodeEntry {
1931+
name: "foo".to_string(),
1932+
file: "src/utils.ts".to_string(),
1933+
node_id: 99,
1934+
}];
1935+
1936+
let edges = build_import_edges(
1937+
files,
1938+
resolved,
1939+
vec![],
1940+
node_ids,
1941+
vec![],
1942+
"/root".to_string(),
1943+
Some(symbol_nodes),
1944+
);
1945+
assert_eq!(edges.len(), 4);
1946+
// Named statement: file-level `reexports` + symbol-level `reexports` to foo.
1947+
assert_eq!(edges[0].kind, "reexports");
1948+
assert_eq!(edges[0].target_id, 2);
1949+
assert_eq!(edges[1].kind, "reexports");
1950+
assert_eq!(edges[1].target_id, 99);
1951+
// Wildcard statement: file-level `reexports` + the `reexports-wildcard` marker.
1952+
assert_eq!(edges[2].kind, "reexports");
1953+
assert_eq!(edges[2].target_id, 2);
1954+
assert_eq!(edges[3].kind, "reexports-wildcard");
1955+
assert_eq!(edges[3].target_id, 2);
18821956
}
18831957

18841958
#[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
@@ -266,6 +266,17 @@ fn is_named_reexport(imp: &crate::types::Import) -> bool {
266266
imp.reexport.unwrap_or(false) && !imp.wildcard_reexport.unwrap_or(false)
267267
}
268268

269+
/// True for a genuine wildcard re-export (`export * from 'Y'`). Emitted as a
270+
/// distinct file-level marker edge (`reexports-wildcard`) alongside the
271+
/// generic `reexports` edge so the query layer can tell a target reached
272+
/// only by named specifiers apart from one that's also reached by a
273+
/// wildcard — even when a *different* statement in the same file names
274+
/// specific symbols from that exact target (#1849 review). Mirrors
275+
/// `is_wildcard_reexport` in build_edges.rs (FFI fallback path).
276+
fn is_wildcard_reexport(imp: &crate::types::Import) -> bool {
277+
imp.reexport.unwrap_or(false) && imp.wildcard_reexport.unwrap_or(false)
278+
}
279+
269280
/// Walk type-only imports and named re-exports in `ctx.file_symbols` and
270281
/// return the distinct `(name, file)` pairs that `build_import_edges` will
271282
/// need to look up. Resolves barrel files the same way the edge-building
@@ -450,6 +461,14 @@ fn emit_edges_for_import(
450461
ctx,
451462
symbol_node_ids,
452463
);
464+
} else if is_wildcard_reexport(imp) {
465+
edges.push(EdgeRow {
466+
source_id: file_node_id,
467+
target_id,
468+
kind: "reexports-wildcard".to_string(),
469+
confidence: 1.0,
470+
dynamic: 0,
471+
});
453472
}
454473
emit_barrel_through_rows(
455474
edges,

crates/codegraph-core/src/extractors/javascript.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2327,6 +2327,11 @@ const HTTP_VERB_CALLEES: &[&str] = &[
23272327
/// separate `CALLBACK_ACCEPTING_CALLEES` entry needed); only the arg at its
23282328
/// listed index is eligible.
23292329
///
2330+
/// Invariant: this map and `CALLBACK_ACCEPTING_CALLEES` must stay disjoint.
2331+
/// A callee name present in both would have its any-position intent silently
2332+
/// narrowed to the single listed index (positional wins — see the gate in
2333+
/// `extract_callback_reference_calls`), with no error or warning.
2334+
///
23302335
/// Name-based, not receiver-typed, so it can't distinguish `Array.from(x,
23312336
/// mapFn)` from an unrelated `.from(x, y)` shaped differently (e.g.
23322337
/// `Buffer.from(data, encoding)`) — that residual risk is far narrower than
@@ -4373,6 +4378,39 @@ mod tests {
43734378
);
43744379
}
43754380

4381+
#[test]
4382+
fn applies_array_from_positional_gate_to_member_expression_args_too() {
4383+
// Mirrors the TS test of the same intent: the old member_expression
4384+
// guard was an explicit `&& memberExprArgsAllowed` inline check; the
4385+
// positional restructuring moved that responsibility to the shared
4386+
// early-return above the loop. `Array.from(arr, obj.mapper)` exercises
4387+
// that a member_expression at the positional index (1) is still
4388+
// emitted with its receiver, while one at index 0 is not.
4389+
let s = parse_js("Array.from(arr, obj.mapper);");
4390+
assert!(
4391+
s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapper" && c.receiver.as_deref() == Some("obj")),
4392+
"Array.from(arr, obj.mapper) must emit mapper with receiver obj; got: {:?}",
4393+
s.calls,
4394+
);
4395+
assert!(
4396+
!s.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arr"),
4397+
"Array.from(arr, obj.mapper) must not emit `arr` (index 0); got: {:?}",
4398+
s.calls,
4399+
);
4400+
4401+
let s2 = parse_js("Array.from(obj.arrayLike, mapCallback);");
4402+
assert!(
4403+
!s2.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "arrayLike"),
4404+
"Array.from(obj.arrayLike, mapCallback) must not emit `arrayLike` (index 0); got: {:?}",
4405+
s2.calls,
4406+
);
4407+
assert!(
4408+
s2.calls.iter().any(|c| c.dynamic == Some(true) && c.name == "mapCallback"),
4409+
"Array.from(obj.arrayLike, mapCallback) must emit mapCallback; got: {:?}",
4410+
s2.calls,
4411+
);
4412+
}
4413+
43764414
#[test]
43774415
fn no_dynamic_call_for_dynamic_import_arg() {
43784416
// Parity with TS walk path: callback-reference extraction must be skipped

crates/codegraph-core/src/features/structure.rs

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -141,29 +141,42 @@ pub fn get_existing_file_count(conn: &Connection) -> i64 {
141141
.unwrap_or(0)
142142
}
143143

144-
/// Directories connected to `dir` via a live import/imports-type edge in
145-
/// either direction — the cross-directory neighbours whose own fan-in/out
146-
/// may have shifted even though none of their files changed. Used by
144+
/// Files connected to `file` via a live import/imports-type edge in either
145+
/// direction — the cross-directory neighbours whose own fan-in/out may have
146+
/// shifted even though `file` is the only one that actually changed. Used by
147147
/// `refresh_affected_directory_metrics` to expand its affected-directory set
148148
/// by exactly one hop.
149-
fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec<String> {
150-
let lo = format!("{dir}/");
151-
let hi = format!("{dir}0");
149+
///
150+
/// Scoped to the exact touched `file`, not its containing directory. An
151+
/// earlier version scoped this to the whole leaf directory (`dir >= x/ AND
152+
/// dir < x0`), which also pulled in edges belonging to unrelated sibling
153+
/// files that happen to live alongside `file` — harmless when the directory
154+
/// is small, but when the touched file sits in a widely-imported "hub"
155+
/// directory (e.g. `src/domain`, imported from dozens of unrelated
156+
/// directories via sibling files), that range scan discovers hundreds of
157+
/// neighbour files that have nothing to do with `file`'s own edges, which
158+
/// then balloon the affected-directory set to include broad, expensive-to-
159+
/// recompute ancestors like the repo-root `src` (measured: 251 neighbour
160+
/// files / 29 affected dirs for a single-file change to this repo's own
161+
/// `src/domain/queries.ts`, a ~55ms hit on the "1-file rebuild" benchmark —
162+
/// #1855). Scoping to the exact file preserves the cross-directory
163+
/// detection this function exists for (#1738) while only considering edges
164+
/// that could plausibly have changed as a result of editing `file` itself
165+
/// (251 -> 46 neighbour files / 29 -> 13 affected dirs for the same probe).
166+
fn find_neighbor_files(conn: &Connection, file: &str) -> Vec<String> {
152167
let mut stmt = match conn.prepare(
153168
"SELECT n2.file AS other FROM edges e \
154169
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
155-
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \
156-
AND n1.file >= ?1 AND n1.file < ?2 \
170+
WHERE e.kind IN ('imports', 'imports-type') AND n1.file = ?1 AND n2.file != ?1 \
157171
UNION \
158172
SELECT n1.file AS other FROM edges e \
159173
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
160-
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \
161-
AND n2.file >= ?1 AND n2.file < ?2",
174+
WHERE e.kind IN ('imports', 'imports-type') AND n2.file = ?1 AND n1.file != ?1",
162175
) {
163176
Ok(s) => s,
164177
Err(_) => return Vec::new(),
165178
};
166-
let result = match stmt.query_map(rusqlite::params![lo, hi], |row| row.get::<_, String>(0)) {
179+
let result = match stmt.query_map(rusqlite::params![file], |row| row.get::<_, String>(0)) {
167180
Ok(rows) => rows.flatten().collect(),
168181
Err(_) => Vec::new(),
169182
};
@@ -181,14 +194,25 @@ fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec<String> {
181194
///
182195
/// This recomputes metrics for the ancestor directories of the files that
183196
/// changed in this build (added, removed, or modified), PLUS any directory
184-
/// reachable from them via a live cross-directory import edge — a changed
185-
/// file that gains (or loses) an import into a sibling package shifts that
186-
/// package's fan-in/fan-out/cohesion even though none of its own files were
187-
/// touched. One level of expansion only (mirrors the neighbour-expansion
188-
/// `classifyNodeRolesIncremental`/`do_classify_incremental` already does for
189-
/// role classification) — bounded by (changed files × path depth) rather
190-
/// than the size of the repo, so it stays cheap enough to run
191-
/// unconditionally alongside the fast path.
197+
/// reachable from the touched files' *immediate* (most specific) directory
198+
/// via a live cross-directory import edge — a changed file that gains (or
199+
/// loses) an import into a sibling package shifts that package's
200+
/// fan-in/fan-out/cohesion even though none of its own files were touched.
201+
///
202+
/// The neighbor-discovery step is seeded from each touched file itself, not
203+
/// from every ancestor up to root, and not from the touched file's whole
204+
/// containing directory either — `find_neighbor_files` is now bounded by the
205+
/// import edges attached to that ONE file. Seeding from every ancestor
206+
/// turned a single touched file into 50+ affected directories on this
207+
/// repo's own `src/` tree — a measured 70-90ms hit on the "1-file rebuild"
208+
/// benchmark (#1738 follow-up). Seeding from the touched file's *directory*
209+
/// (an intermediate fix) was still broad enough to pull in unrelated
210+
/// sibling files' edges whenever that directory was itself a widely-
211+
/// imported hub (e.g. `src/domain`) — measured 251 neighbour files / 29
212+
/// affected dirs (~55ms) for a single-file change there, cut to 46 / 13 by
213+
/// scoping to the exact file (#1855). Ancestor rollup (ancestors' own
214+
/// aggregates still get recomputed) is unaffected; only the expensive
215+
/// cross-directory neighbor lookup is scoped down.
192216
///
193217
/// Removed files need no edge/node cleanup of their own — the purge step
194218
/// already deleted their nodes and every edge referencing them (including
@@ -211,9 +235,14 @@ pub fn refresh_affected_directory_metrics(
211235
return;
212236
}
213237

214-
let seed_dirs: Vec<String> = affected_dirs.iter().cloned().collect();
215-
for dir in &seed_dirs {
216-
let neighbor_files = find_neighbor_files(conn, dir);
238+
// Seed neighbor-discovery from each touched file individually — NOT its
239+
// containing directory, and NOT every entry in `affected_dirs` (which
240+
// includes the full ancestor chain up to root). See the function doc
241+
// comment: expanding from a broad ancestor like `src`, or from every
242+
// sibling in the touched file's own directory, is effectively repo-wide
243+
// whenever that directory is a widely-imported hub.
244+
for file in &touched {
245+
let neighbor_files = find_neighbor_files(conn, file);
217246
for ancestor in get_ancestor_dirs(&neighbor_files) {
218247
affected_dirs.insert(ancestor);
219248
}

crates/codegraph-core/src/graph/algorithms/louvain.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,18 +148,21 @@ fn local_move_phase(
148148
}
149149

150150
let mut any_moved = false;
151+
// BTreeMap (not HashMap) so the best-move scan below visits candidate
152+
// communities in a fixed, deterministic order — otherwise a genuine tie
153+
// in `gain` would be broken by Rust's per-process-randomized HashMap
154+
// iteration order instead of a reproducible rule (#1734). Hoisted out of
155+
// the node loop and cleared per-iteration instead of reallocated, since
156+
// `cur_n * LOUVAIN_MAX_PASSES` fresh allocations would otherwise show up
157+
// on very high-degree hub nodes.
158+
let mut comm_w: BTreeMap<usize, f64> = BTreeMap::new();
151159
for _pass in 0..LOUVAIN_MAX_PASSES {
152160
let mut pass_moved = false;
153161
for &node in &order {
154162
let node_comm = level_comm[node];
155163
let node_deg = state.cur_degree[node];
156164

157-
// BTreeMap (not HashMap) so the best-move scan below visits
158-
// candidate communities in a fixed, deterministic order —
159-
// otherwise a genuine tie in `gain` would be broken by Rust's
160-
// per-process-randomized HashMap iteration order instead of a
161-
// reproducible rule (#1734).
162-
let mut comm_w: BTreeMap<usize, f64> = BTreeMap::new();
165+
comm_w.clear();
163166
for &(neighbor, w) in &adj[node] {
164167
*comm_w.entry(level_comm[neighbor]).or_insert(0.0) += w;
165168
}

0 commit comments

Comments
 (0)