Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2b6a00d
fix: scope codegraph batch complexity targets to file paths
carlos-alm Jul 5, 2026
b28051a
fix: exclude parameters and interface/type members from dead-role cla…
carlos-alm Jul 5, 2026
17fbcba
fix: credit import-type usages as exports consumers
carlos-alm Jul 5, 2026
26918bb
fix: prevent fn-impact/query crash when -f/--file is passed
carlos-alm Jul 5, 2026
be6432c
fix: correct exported-symbol detection for literal and object-literal…
carlos-alm Jul 5, 2026
4ead840
fix: exclude primitive type keywords from ast --kind string matching
carlos-alm Jul 5, 2026
f6326bf
fix: resolve call edges through renamed import specifiers
carlos-alm Jul 5, 2026
590f560
fix: couple file_hashes updates with edge regeneration in incremental…
carlos-alm Jul 5, 2026
d3ea4a4
fix: compare signature-change diffs using new-file line ranges
carlos-alm Jul 5, 2026
c306812
feat: add environment doctor check for stale native binary and missin…
carlos-alm Jul 5, 2026
8bb5893
fix: eliminate non-deterministic ordering in community detection
carlos-alm Jul 6, 2026
46037b1
fix: sync update-graph.sh hook extension allowlist with EXTENSIONS
carlos-alm Jul 6, 2026
6a84cf9
fix: recompute directory structure metrics for affected directories o…
carlos-alm Jul 6, 2026
8d936d1
refactor: register hardcoded execFileSync/execSync maxBuffer values i…
carlos-alm Jul 6, 2026
2b27e37
fix(perf): scope directory neighbor-discovery to leaf dirs (#1738)
carlos-alm Jul 6, 2026
c6b76ba
fix: address Greptile review comments on directory-metrics refresh (#…
carlos-alm Jul 6, 2026
a15b2c7
fix(native): exclude intra-directory pairs from find_neighbor_files (…
carlos-alm Jul 6, 2026
56a1d1c
fix: resolve merge conflicts with main (docs check acknowledged)
carlos-alm Jul 6, 2026
eed4553
Merge remote-tracking branch 'origin/fix/issue-1738-structure-metrics…
carlos-alm Jul 6, 2026
fbfe934
fix: thread coChange.execMaxBufferBytes through CLI into scanGitHisto…
carlos-alm Jul 6, 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
8 changes: 8 additions & 0 deletions crates/codegraph-core/src/domain/graph/builder/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,19 @@ fn reconnect_saved_reverse_dep_edges(
/// structure rebuild based on the same gates as the JS pipeline. The change
/// set is read from `file_symbols.keys()` because only truly-changed files
/// are present (reverse-deps are reconnected, not re-parsed).
///
/// `removed_files` is threaded through separately from `parse_changes_len`
/// (which only counts re-parsed files) so the fast path's directory-metrics
/// refresh also covers files deleted from a directory, not just files added
/// or modified within it (#1738).
fn run_structure_phase(
conn: &Connection,
file_symbols: &BTreeMap<String, FileSymbols>,
collect_directories: &HashSet<String>,
root_dir: &str,
line_count_map: &HashMap<String, i64>,
parse_changes_len: usize,
removed_files: &[String],
is_full_build: bool,
) {
let changed_files: Vec<String> = file_symbols.keys().cloned().collect();
Expand All @@ -303,6 +309,7 @@ fn run_structure_phase(

if use_fast_path {
structure::update_changed_file_metrics(conn, &changed_files, line_count_map, file_symbols);
structure::refresh_affected_directory_metrics(conn, &changed_files, removed_files);
} else {
let changed_for_structure: Option<Vec<String>> = if is_full_build {
None
Expand Down Expand Up @@ -603,6 +610,7 @@ pub fn run_pipeline(
root_dir,
&line_count_map,
parse_changes.len(),
&change_result.removed,
change_result.is_full_build,
);
timing.structure_ms = t0.elapsed().as_secs_f64() * 1000.0;
Expand Down
250 changes: 250 additions & 0 deletions crates/codegraph-core/src/features/structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,256 @@ pub fn get_existing_file_count(conn: &Connection) -> i64 {
.unwrap_or(0)
}

/// Directories connected to `dir` via a live import/imports-type edge in
/// either direction — the cross-directory neighbours whose own fan-in/out
/// may have shifted even though none of their files changed. Used by
/// `refresh_affected_directory_metrics` to expand its affected-directory set
/// by exactly one hop.
fn find_neighbor_files(conn: &Connection, dir: &str) -> Vec<String> {
let lo = format!("{dir}/");
let hi = format!("{dir}0");
let mut stmt = match conn.prepare(
"SELECT n2.file AS other FROM edges e \
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \
AND n1.file >= ?1 AND n1.file < ?2 \
AND NOT (n2.file >= ?1 AND n2.file < ?2) \
UNION \
SELECT n1.file AS other FROM edges e \
JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id \
WHERE e.kind IN ('imports', 'imports-type') AND n1.file != n2.file \
AND n2.file >= ?1 AND n2.file < ?2 \
AND NOT (n1.file >= ?1 AND n1.file < ?2)",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let result = match stmt.query_map(rusqlite::params![lo, hi], |row| row.get::<_, String>(0)) {
Ok(rows) => rows.flatten().collect(),
Err(_) => Vec::new(),
};
result
}

/// Targeted directory-metrics refresh for the small-incremental fast path.
///
/// `update_changed_file_metrics` only ever touches per-file `node_metrics`
/// rows — it never looks at directories. Any file added to, removed from, or
/// edited within a directory left that directory's
/// fileCount/symbolCount/fanIn/fanOut/cohesion stale until the next full
/// rebuild (#1738), and a file added under a brand-new directory never even
/// got a directory node or a `contains` edge from its parent.
///
/// This recomputes metrics for the ancestor directories of the files that
/// changed in this build (added, removed, or modified), PLUS any directory
/// reachable from the touched files' *immediate* (most specific) directory
/// via a live cross-directory import edge — a changed file that gains (or
/// loses) an import into a sibling package shifts that package's
/// fan-in/fan-out/cohesion even though none of its own files were touched.
///
/// The neighbor-discovery step is seeded only from each touched file's
/// leaf directory, not from every ancestor up to root — `find_neighbor_files`
/// is bounded by the number of import edges crossing ONE directory's
/// boundary, which for a broad, near-root "container" directory (e.g. `src`,
/// which transitively contains most of the repo) is effectively the size of
/// the whole codebase, not "path depth". Seeding from every ancestor turned
/// a single touched file into 50+ affected directories on this repo's own
/// `src/` tree — a measured 70-90ms hit on the "1-file rebuild" benchmark
/// (#1738 follow-up). Ancestor rollup (ancestors' own aggregates still get
/// recomputed) is unaffected; only the expensive cross-directory neighbor
/// lookup is scoped down.
///
/// Removed files need no edge/node cleanup of their own — the purge step
/// already deleted their nodes and every edge referencing them (including
/// their old `contains` edge) earlier in the pipeline; only their ancestor
/// directories' aggregates need recomputing here. Note this expansion can't
/// reach a directory whose ONLY relationship to the touched set was an edge
/// to/from a file that was JUST removed — that edge is already gone by the
/// time this runs, so there's nothing left to discover it from (tracked
/// separately, see #1738 follow-up).
pub fn refresh_affected_directory_metrics(
conn: &Connection,
changed_files: &[String],
removed_files: &[String],
) {
let mut touched: Vec<String> = Vec::with_capacity(changed_files.len() + removed_files.len());
touched.extend_from_slice(changed_files);
touched.extend_from_slice(removed_files);
let mut affected_dirs = get_ancestor_dirs(&touched);
if affected_dirs.is_empty() {
return;
}

// Seed neighbor-discovery from each touched file's own leaf directory
// only — NOT every entry in `affected_dirs` (which includes the full
// ancestor chain up to root). See the function doc comment: expanding
// from a broad ancestor like `src` is effectively repo-wide.
let leaf_dirs: HashSet<String> = touched.iter().filter_map(|f| parent_dir(f)).collect();
for dir in &leaf_dirs {
let neighbor_files = find_neighbor_files(conn, dir);
for ancestor in get_ancestor_dirs(&neighbor_files) {
affected_dirs.insert(ancestor);
}
}

let tx = match conn.unchecked_transaction() {
Ok(tx) => tx,
Err(_) => return,
};

// 1. Ensure directory nodes exist for the whole affected ancestor chain —
// handles a file added under a brand-new (possibly multi-level) directory.
{
let mut insert_dir = match tx.prepare(
"INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, 'directory', ?, 0, NULL)",
) {
Ok(s) => s,
Err(_) => return,
};
for dir in &affected_dirs {
let _ = insert_dir.execute(rusqlite::params![dir, dir]);
}
}

{
let mut insert_edge = match tx.prepare(
"INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) \
SELECT ?, ?, 'contains', 1.0, 0 \
WHERE NOT EXISTS (SELECT 1 FROM edges WHERE source_id = ? AND target_id = ? AND kind = 'contains')",
) {
Ok(s) => s,
Err(_) => return,
};

// 2. Wire dir -> parent-dir contains edges for the chain.
for dir in &affected_dirs {
if let Some(parent) = parent_dir(dir) {
if let (Some(parent_id), Some(dir_id)) = (
get_node_id(&tx, &parent, "directory", &parent, 0),
get_node_id(&tx, dir, "directory", dir, 0),
) {
let _ =
insert_edge.execute(rusqlite::params![parent_id, dir_id, parent_id, dir_id]);
}
}
}

// 3. Wire dir -> file contains edges for changed (added/modified) files.
// Removed files' nodes and edges are already purged upstream.
for rel_path in changed_files {
if let Some(dir) = parent_dir(rel_path) {
if let (Some(dir_id), Some(file_id)) = (
get_node_id(&tx, &dir, "directory", &dir, 0),
get_node_id(&tx, rel_path, "file", rel_path, 0),
) {
let _ =
insert_edge.execute(rusqlite::params![dir_id, file_id, dir_id, file_id]);
}
}
}
}

// 4. Recompute each affected directory's metrics from the live DB state.
{
// fileCount/symbolCount: transitive counts under `dir`, matching
// compute_directory_metrics below. `file >= dir/ AND file < dir0` is
// an index-friendly prefix-range scan equivalent to `file LIKE
// 'dir/%'` — '0' (0x30) is the character immediately after '/'
// (0x2F), so this bound matches exactly the paths nested under `dir`.
let mut count_files = match tx.prepare(
"SELECT COUNT(*) FROM nodes WHERE kind = 'file' AND file >= ?1 AND file < ?2",
) {
Ok(s) => s,
Err(_) => return,
};
let mut count_symbols = match tx.prepare(
"SELECT COUNT(*) FROM nodes WHERE kind != 'file' AND kind != 'directory' AND file >= ?1 AND file < ?2",
) {
Ok(s) => s,
Err(_) => return,
};
// Edges sourced from a file inside dir: intra (target also inside dir) vs fan-out.
let mut outbound = match tx.prepare(
"SELECT \
COALESCE(SUM(CASE WHEN n2.file >= ?1 AND n2.file < ?2 THEN 1 ELSE 0 END), 0), \
COUNT(*) \
FROM edges e \
JOIN nodes n1 ON e.source_id = n1.id \
JOIN nodes n2 ON e.target_id = n2.id \
WHERE e.kind IN ('imports', 'imports-type') \
AND n1.file != n2.file \
AND n2.kind = 'file' \
AND n1.file >= ?1 AND n1.file < ?2",
) {
Ok(s) => s,
Err(_) => return,
};
// Edges targeting a file inside dir, sourced from a file outside dir (fan-in only).
let mut inbound = match tx.prepare(
"SELECT COUNT(*) \
FROM edges e \
JOIN nodes n1 ON e.source_id = n1.id \
JOIN nodes n2 ON e.target_id = n2.id \
WHERE e.kind IN ('imports', 'imports-type') \
AND n1.file != n2.file \
AND n2.kind = 'file' \
AND n2.file >= ?1 AND n2.file < ?2 \
AND NOT (n1.file >= ?1 AND n1.file < ?2)",
) {
Ok(s) => s,
Err(_) => return,
};
let mut upsert = match tx.prepare(
"INSERT OR REPLACE INTO node_metrics \
(node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) \
VALUES (?, NULL, ?, NULL, NULL, ?, ?, ?, ?)",
) {
Ok(s) => s,
Err(_) => return,
};

for dir in &affected_dirs {
let dir_id = match get_node_id(&tx, dir, "directory", dir, 0) {
Some(id) => id,
None => continue,
};
let lo = format!("{dir}/");
let hi = format!("{dir}0");

let file_count: i64 = count_files
.query_row(rusqlite::params![lo, hi], |r| r.get(0))
.unwrap_or(0);
let symbol_count: i64 = count_symbols
.query_row(rusqlite::params![lo, hi], |r| r.get(0))
.unwrap_or(0);
let (intra, total): (i64, i64) = outbound
.query_row(rusqlite::params![lo, hi], |r| Ok((r.get(0)?, r.get(1)?)))
.unwrap_or((0, 0));
let fan_out = total - intra;
let fan_in: i64 = inbound
.query_row(rusqlite::params![lo, hi], |r| r.get(0))
.unwrap_or(0);
let total_edges = intra + fan_in + fan_out;
let cohesion: Option<f64> = if total_edges > 0 {
Some(intra as f64 / total_edges as f64)
} else {
None
};

let _ = upsert.execute(rusqlite::params![
dir_id,
symbol_count,
fan_in,
fan_out,
cohesion,
file_count
]);
}
}

let _ = tx.commit();
}

// ── Full structure computation ──────────────────────────────────────────

/// Normalize a path to use forward slashes only.
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Controls graph construction.
| `dbPath` | `string` | `".codegraph/graph.db"` | Path to the SQLite database, relative to the project root. |
| `driftThreshold` | `number` | `0.2` | Fraction (0–1). If incremental rebuild changes node or edge counts by more than this, codegraph warns and suggests `--no-incremental`. |
| `smallFilesThreshold` | `number` | `5` | When ≤ this many files change in an incremental build, codegraph takes faster code paths (skips full rebuilds of structure metrics, scoped barrel re-parsing, JS fallback for inserts). |
| `execMaxBufferBytes` | `number` | `104857600` | Max stdout buffer size (bytes) for the `git check-ignore` subprocess spawned while detecting gitignored files during native-orchestrator drop detection. |

---

Expand Down Expand Up @@ -318,6 +319,7 @@ Toggles for the lightweight `codegraph check` command (separate from manifesto).
| `signatures` | `boolean` | `true` | Warn on signature changes in the diff. |
| `boundaries` | `boolean` | `true` | Honor the `manifesto.boundaries` rules. |
| `depth` | `number` | `3` | Transitive depth for blast-radius calculation. |
| `execMaxBufferBytes` | `number` | `10485760` | Max stdout buffer size (bytes) for the `git diff` subprocess spawned to compute changed-line ranges (also used by `diff-impact`). |

---

Expand All @@ -331,6 +333,7 @@ Configures `codegraph co-changes` (files that historically change together based
| `minSupport` | `number` | `3` | Minimum number of co-occurring commits before a pair is reported. |
| `minJaccard` | `number` | `0.3` | Minimum Jaccard similarity (`|A∩B| / |A∪B|`) for a pair. |
| `maxFilesPerCommit` | `number` | `50` | Skip commits touching more than this many files (avoids noise from large refactors / merges). |
| `execMaxBufferBytes` | `number` | `52428800` | Max stdout buffer size (bytes) for the `git log` subprocess spawned while scanning commit history. |

---

Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/co-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const command: CommandDefinition = {
? parseInt(opts.minSupport as string, 10)
: coChangeConfig?.minSupport,
maxFilesPerCommit: coChangeConfig?.maxFilesPerCommit,
execMaxBufferBytes: coChangeConfig?.execMaxBufferBytes,
full: opts.full,
});
if (opts.json) {
Expand Down
5 changes: 3 additions & 2 deletions src/domain/analysis/diff-impact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function findGitRoot(repoRoot: string): boolean {
function runGitDiff(
repoRoot: string,
opts: { staged?: boolean; ref?: string },
maxBuffer: number,
): { output: string; error?: never } | { error: string; output?: never } {
try {
const args = opts.staged
Expand All @@ -49,7 +50,7 @@ function runGitDiff(
const output = execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
maxBuffer,
stdio: ['pipe', 'pipe', 'pipe'],
});
return { output };
Expand Down Expand Up @@ -281,7 +282,7 @@ export function diffImpactData(
return { error: `Not a git repository: ${repoRoot}` };
}

const gitResult = runGitDiff(repoRoot, opts);
const gitResult = runGitDiff(repoRoot, opts, config.check.execMaxBufferBytes);
if ('error' in gitResult) return { error: gitResult.error };

if (!gitResult.output.trim()) {
Expand Down
Loading
Loading