Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
11c84be
fix: gate blast-radius check on newly introduced risk, not pre-existi…
carlos-alm Jul 6, 2026
963301a
fix: gate identifier-argument dynamic call edges on callback-acceptin…
carlos-alm Jul 6, 2026
3e8035d
fix: gate Array.from's callback arg by position, not just callee name
carlos-alm Jul 6, 2026
879635e
fix: scope reexportedSymbols to actually-named re-export specifiers
carlos-alm Jul 6, 2026
0fe9fc3
fix: stop CFG block/edge count from overriding AST-derived cyclomatic…
carlos-alm Jul 6, 2026
ccf3c19
fix: backfill edges.technique for incrementally-inserted calls edges
carlos-alm Jul 6, 2026
d5b1162
fix: wrap remote embedding JSON parse failure in EngineError
carlos-alm Jul 6, 2026
8971017
refactor: extract shared platform-default-path helper in config.ts
carlos-alm Jul 6, 2026
056c5e4
refactor: decompose loadConfig and related high-effort functions in c…
carlos-alm Jul 6, 2026
0738171
refactor: decompose findDbPath and openRepo in db/connection.ts (docs…
carlos-alm Jul 6, 2026
6f2423c
refactor: dedupe busy/locked error detection into isBusyOrLockedError
carlos-alm Jul 6, 2026
4c02378
fix: log statSync failures in findDbPath instead of silently swallowi…
carlos-alm Jul 6, 2026
0698e16
test: add direct unit coverage for closeDbPair/closeDbPairDeferred/cl…
carlos-alm Jul 6, 2026
4ac3b99
fix: correct blast-radius/fn-impact computation for line-shifted decl…
carlos-alm Jul 6, 2026
6767f09
feat: wire points-to solver max-iterations cap through DEFAULTS.analy…
carlos-alm Jul 6, 2026
5f6f30a
refactor: route console.log calls in domain/search through logger
carlos-alm Jul 6, 2026
b479318
refactor: reduce cyclomatic complexity of computeDeltaModularityDirected
carlos-alm Jul 6, 2026
c39ac40
refactor: unify impact-level rendering format between audit and fn-im…
carlos-alm Jul 6, 2026
067674f
refactor: dedupe computeSavings via pct helper and persist partial to…
carlos-alm Jul 6, 2026
f6807b4
fix: add main.rs driver to rust dynamic tracer fixture
carlos-alm Jul 6, 2026
1c07a54
fix: scope diff file-header detection to between-hunk positions
carlos-alm Jul 6, 2026
bf82aa2
fix: update titan-grind's dead-symbol script for current roles --json…
carlos-alm Jul 6, 2026
cd02d27
fix: thread configured busyTimeoutMs through remaining read-only quer…
carlos-alm Jul 6, 2026
980b5dc
fix: resolve computed string-literal keys in object-literal extractio…
carlos-alm Jul 6, 2026
4a873a3
fix: add same-class bare-call fallback to incremental rebuild path (d…
carlos-alm Jul 6, 2026
8f3348a
fix: unify Object.defineProperty accessor fallback kind-filtering bet…
carlos-alm Jul 6, 2026
acf804c
refactor: share chunked statement-cache primitive between builder and…
carlos-alm Jul 6, 2026
986c851
refactor: cache prepared statements in applyEdgeTechniquesAfterNative…
carlos-alm Jul 6, 2026
71e9174
fix: replace fixed-depth directory-proximity check with symmetric dis…
carlos-alm Jul 6, 2026
d82ab58
fix: resolve merge conflicts with main
carlos-alm Jul 7, 2026
eae348c
perf: use a map lookup instead of linear scan in directoryDistance
carlos-alm Jul 8, 2026
bca424b
perf: memoize directoryDistance to fix hot-path regression
carlos-alm Jul 8, 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
164 changes: 146 additions & 18 deletions crates/codegraph-core/src/domain/graph/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,68 @@ fn resolve_import_path_inner(
relativize_to_root(&resolved.display().to_string().replace('\\', "/"), root_dir)
}

/// All ancestor directories of `dir`, starting with `dir` itself, walking up to the root.
fn ancestor_chain(dir: &str) -> Vec<String> {
let mut chain = vec![dir.to_string()];
let mut cur = dir.to_string();
while let Some(parent) = Path::new(&cur).parent() {
let parent_str = parent.display().to_string();
chain.push(parent_str.clone());
cur = parent_str;
}
chain
}

// directory_distance is on the hot path for every call-edge confidence
// score, invoked from inside compute_confidence's rayon `.par_iter()` caller
// (line ~330 below). The same directory pairs recur constantly across a
// build, so memoizing avoids rebuilding both ancestor chains and the lookup
// map every call. Thread-local (not a shared Mutex/DashMap) because rayon's
// worker pool is reused across the whole build — each worker accumulates its
// own useful cache with zero lock contention, at the cost of some redundant
// computation the first time a given pair is seen on each thread.
// distance(a, b) === distance(b, a) (symmetric tree distance), so the key is
// order-independent to halve the effective cache size per thread (#1769
// perf regression).
thread_local! {
static DIRECTORY_DISTANCE_CACHE: std::cell::RefCell<std::collections::HashMap<(String, String), usize>> =
std::cell::RefCell::new(std::collections::HashMap::new());
}

/// Directory-tree distance between two directories: hops up from `a` to the
/// nearest ancestor shared with `b`, plus hops down from there to `b`.
///
/// Symmetric and depth-independent — unlike a fixed-depth equality check
/// (e.g. comparing the parent-of-parent of `a` to the parent-of-parent of
/// `b`, as `compute_confidence` used to), this correctly scores both sibling
/// directories (common parent) and direct ancestor/descendant directories
/// (one nested inside the other) regardless of how deep either path is. The
/// fixed-depth check only matched when both files sat at the *same* depth,
/// so e.g. a file in `graph/algorithms/*.rs` calling a method declared in
/// the shallower `graph/model.rs` was scored as maximally distant (issue #1769).
fn directory_distance(a: &str, b: &str) -> usize {
let key = if a <= b { (a.to_string(), b.to_string()) } else { (b.to_string(), a.to_string()) };
if let Some(cached) = DIRECTORY_DISTANCE_CACHE.with(|c| c.borrow().get(&key).copied()) {
return cached;
}

let chain_a = ancestor_chain(a);
let chain_b = ancestor_chain(b);
let index_in_b: std::collections::HashMap<&str, usize> =
chain_b.iter().enumerate().map(|(j, d)| (d.as_str(), j)).collect();
let mut dist = usize::MAX;
for (i, dir_a) in chain_a.iter().enumerate() {
if let Some(&j) = index_in_b.get(dir_a.as_str()) {
dist = i + j;
break;
}
}
DIRECTORY_DISTANCE_CACHE.with(|c| c.borrow_mut().insert(key, dist));
dist
}
Comment thread
carlos-alm marked this conversation as resolved.

/// Compute proximity-based confidence for call resolution.
/// Mirrors `computeConfidence()` in builder.js.
/// Mirrors `computeConfidence()` in resolve.ts.
pub fn compute_confidence(
caller_file: &str,
target_file: &str,
Expand All @@ -275,24 +335,12 @@ pub fn compute_confidence(
.map(|p| p.display().to_string())
.unwrap_or_default();

if caller_dir == target_dir {
return 0.7;
}

let caller_parent = Path::new(&caller_dir)
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
let target_parent = Path::new(&target_dir)
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();

if caller_parent == target_parent {
return 0.5;
match directory_distance(&caller_dir, &target_dir) {
0 => 0.7, // same directory
1 => 0.6, // direct parent/child directory
2 => 0.5, // sibling directories, or a grandparent/grandchild pair
_ => 0.3,
}

0.3
}

/// Batch resolve multiple imports (parallelized with rayon).
Expand Down Expand Up @@ -430,4 +478,84 @@ mod tests {
);
assert_eq!(result, "src/utils.ts");
}

// Regression tests for #1769: a fixed-depth "grandparent equality" check
// used to compare the parent of `caller_dir` to the parent of `target_dir`,
// which only matched when both files sat at the *same* depth. A file in a
// subdirectory calling a method declared in its direct parent directory
// (e.g. `graph/algorithms/bfs.rs` calling `graph/model.rs`) was scored as
// maximally distant (0.3) purely because the two files were nested at
// different depths — well below the 0.5 threshold used by the call-edge
// resolver's typed-method lookup, silently dropping the call edge.

#[test]
fn compute_confidence_scores_parent_child_dirs_above_resolver_threshold() {
let conf = compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None);
assert!(conf >= 0.5, "expected >= 0.5, got {conf}");
}

#[test]
fn compute_confidence_is_symmetric_for_parent_child_dirs() {
let caller_deeper =
compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None);
let target_deeper =
compute_confidence("src/graph/model.rs", "src/graph/algorithms/bfs.rs", None);
assert_eq!(caller_deeper, target_deeper);
}

#[test]
fn compute_confidence_ranks_parent_child_between_same_dir_and_sibling() {
let same_dir = compute_confidence("src/graph/a.rs", "src/graph/b.rs", None);
let parent_child =
compute_confidence("src/graph/algorithms/bfs.rs", "src/graph/model.rs", None);
// True siblings: both one level below `src`, at equal depth.
let sibling = compute_confidence("src/graph/a.rs", "src/features/b.rs", None);
assert!(same_dir > parent_child);
assert!(parent_child > sibling);
}

#[test]
fn compute_confidence_scores_two_level_nesting_at_or_above_sibling_tier() {
// the graph/algorithms/leiden/*.rs -> graph/model.rs shape from #1769.
let conf = compute_confidence(
"src/graph/algorithms/leiden/cpm.rs",
"src/graph/model.rs",
None,
);
assert!(conf >= 0.5, "expected >= 0.5, got {conf}");
}

#[test]
fn compute_confidence_still_scores_unrelated_deep_files_as_distant() {
let conf = compute_confidence(
"src/graph/algorithms/leiden/cpm.rs",
"src/mcp/server.rs",
None,
);
assert!(conf < 0.5, "expected < 0.5, got {conf}");
}

#[test]
fn directory_distance_same_dir_is_zero() {
assert_eq!(directory_distance("src/graph", "src/graph"), 0);
}

#[test]
fn directory_distance_direct_parent_child_is_one() {
assert_eq!(directory_distance("src/graph/algorithms", "src/graph"), 1);
assert_eq!(directory_distance("src/graph", "src/graph/algorithms"), 1);
}

#[test]
fn directory_distance_siblings_is_two() {
// Both dirs are one level below `src` — true siblings at equal depth.
assert_eq!(directory_distance("src/graph", "src/features"), 2);
}

#[test]
fn directory_distance_unequal_depth_non_siblings_is_three() {
// `algorithms` is nested inside `graph`, which is a sibling of `features` —
// not a direct sibling pair despite sharing the `src` ancestor.
assert_eq!(directory_distance("src/graph/algorithms", "src/features"), 3);
}
}
65 changes: 61 additions & 4 deletions src/domain/graph/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,63 @@ function resolveImportPathJS(
return normalizePath(path.relative(rootDir, resolved));
}

/** All ancestor directories of `dir`, starting with `dir` itself, walking up to the root. */
function ancestorChain(dir: string): string[] {
const chain = [dir];
let cur = dir;
for (;;) {
const parent = path.dirname(cur);
if (parent === cur) return chain; // reached root ('.', '/', or a drive root)
chain.push(parent);
cur = parent;
}
}

/**
* Directory-tree distance between two directories: hops up from `a` to the
* nearest ancestor shared with `b`, plus hops down from there to `b`.
*
* Symmetric and depth-independent — unlike a fixed-depth equality check
* (e.g. comparing `dirname(dirname(a))` to `dirname(dirname(b))`, as this
* function used to), this correctly scores both sibling directories (common
* parent) and direct ancestor/descendant directories (one nested inside the
* other) regardless of how deep either path is. The fixed-depth check only
* matched when both files sat at the *same* depth, so e.g. a file in
* `graph/algorithms/*.ts` calling a method declared in the shallower
* `graph/model.ts` was scored as maximally distant (issue #1769).
*/
// directoryDistance is on the hot path for every call-edge confidence score
// (computeConfidence runs per candidate during ranking/filtering, not just
// once per emitted edge — see call-resolver.ts, resolver/strategy.ts,
// stages/build-edges.ts). The same directory pairs recur constantly across a
// build, so memoizing avoids rebuilding both ancestor chains and the lookup
// map on every call. distance(a, b) === distance(b, a) (symmetric tree
// distance), so the key is order-independent to halve the effective cache
// size. Never cleared: purely a function of two path strings, so a stale
// entry can't exist, and even a large repo's directory count keeps this
// bounded (#1769 perf regression — see PR discussion).
const directoryDistanceCache = new Map<string, number>();

function directoryDistance(a: string, b: string): number {
const key = a <= b ? `${a}|${b}` : `${b}|${a}`;
const cached = directoryDistanceCache.get(key);
if (cached !== undefined) return cached;

const chainA = ancestorChain(a);
const chainB = ancestorChain(b);
const indexInB = new Map<string, number>(chainB.map((d, idx) => [d, idx]));
let dist = Infinity;
for (let i = 0; i < chainA.length; i++) {
const j = indexInB.get(chainA[i]!);
if (j !== undefined) {
dist = i + j;
break;
}
}
directoryDistanceCache.set(key, dist);
return dist;
}
Comment thread
carlos-alm marked this conversation as resolved.

function computeConfidenceJS(
callerFile: string,
targetFile: string,
Expand All @@ -471,10 +528,10 @@ function computeConfidenceJS(
if (importedFrom === targetFile) return 1.0;
// Workspace-resolved imports get high confidence even across package boundaries
if (importedFrom && _workspaceResolvedPaths.has(importedFrom)) return 0.95;
if (path.dirname(callerFile) === path.dirname(targetFile)) return 0.7;
const callerParent = path.dirname(path.dirname(callerFile));
const targetParent = path.dirname(path.dirname(targetFile));
if (callerParent === targetParent) return 0.5;
const dist = directoryDistance(path.dirname(callerFile), path.dirname(targetFile));
if (dist === 0) return 0.7; // same directory
if (dist === 1) return 0.6; // direct parent/child directory
if (dist === 2) return 0.5; // sibling directories, or a grandparent/grandchild pair
return 0.3;
}

Expand Down
128 changes: 128 additions & 0 deletions tests/integration/issue-1769-parent-dir-receiver-typed-call.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Regression test for #1769: a function parameter typed with a class name,
* declared in a *different, less-deeply-nested directory*, must still
* resolve method calls on that parameter back to the class's declaration.
*
* Root cause: `computeConfidenceJS` (and its Rust mirror `compute_confidence`)
* scored directory proximity using a fixed-depth check — comparing the
* parent of the caller's directory to the parent of the target's directory.
* That check only matched when both files sat at the *same* depth, so a
* subdirectory file (e.g. `graph/algorithms/bfs.ts`) calling a method on a
* class declared in its direct parent directory (`graph/model.ts`) was
* scored as maximally distant (0.3) — well below the 0.5 threshold used by
* the call-edge resolver's typed-method lookup (`resolveViaTypedMethod` in
* src/domain/graph/resolver/strategy.ts), so the call edge was silently
* dropped even though the parameter's type annotation correctly resolved
* `foo: Foo` to `typeMap['foo'] = 'Foo'`.
*
* Setup mirrors the real-world shape from src/graph/algorithms/bfs.ts calling
* src/graph/model.ts:
* - model.ts (parent directory): `export class Foo { bar(x): number {...} }`
* - algorithms/consumer.ts (child directory): a function whose PARAMETER is
* typed `foo: Foo` (via type annotation, not `new Foo()` construction)
* calls `foo.bar(x)`.
*
* Expected: a `calls` edge from `useFoo` to `Foo.bar`, in both engines.
*/

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 TSCONFIG = JSON.stringify({
compilerOptions: {
target: 'es2022',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
},
include: ['**/*.ts'],
});

const MODEL_TS = `
export class Foo {
bar(x: number): number {
return x + 1;
}
}
`;

// Nested one directory deeper than model.ts — mirrors graph/algorithms/bfs.ts
// receiving a `graph: CodeGraph` parameter from graph/model.ts.
const CONSUMER_TS = `
import type { Foo } from '../model.js';

export function useFoo(foo: Foo, x: number): number {
return foo.bar(x);
}
`;

let tmpWasm: string;
let tmpNative: string;

function writeFixture(dir: string): void {
fs.writeFileSync(path.join(dir, 'tsconfig.json'), TSCONFIG);
// Both files live under a shared `graph/` directory — NOT at the fixture
// root — so their relative-path depths mirror the real src/graph/model.ts
// vs src/graph/algorithms/bfs.ts shape. Placing model.ts directly at the
// fixture root would make `path.dirname()` hit its "." fixed point at the
// same recursion depth for both files, which accidentally masks the #1769
// bug (the old fixed-depth check happens to coincide at the filesystem
// root regardless of the asymmetry it's supposed to detect).
fs.mkdirSync(path.join(dir, 'graph', 'algorithms'), { recursive: true });
fs.writeFileSync(path.join(dir, 'graph', 'model.ts'), MODEL_TS);
fs.writeFileSync(path.join(dir, 'graph', 'algorithms', 'consumer.ts'), CONSUMER_TS);
}

beforeAll(async () => {
tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1769-wasm-'));
writeFixture(tmpWasm);

tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1769-native-'));
writeFixture(tmpNative);

await Promise.all([
buildGraph(tmpWasm, { incremental: false, skipRegistry: true, engine: 'wasm' }),
buildGraph(tmpNative, { incremental: false, skipRegistry: true, engine: 'native' }),
]);
});

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

function hasCallEdge(dbPath: string, sourceName: string, targetName: string): boolean {
const db = new Database(dbPath, { readonly: true });
try {
const row = db
.prepare(
`SELECT 1
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' AND n1.name = ? AND n2.name = ?`,
)
.get(sourceName, targetName);
return row !== undefined;
} finally {
db.close();
}
}

describe('parameter-typed receiver call to a parent-directory class (#1769)', () => {
it('WASM: resolves foo.bar(x) to Foo.bar across a child->parent directory boundary', () => {
expect(hasCallEdge(path.join(tmpWasm, '.codegraph', 'graph.db'), 'useFoo', 'Foo.bar')).toBe(
true,
);
});

it('Native: resolves foo.bar(x) to Foo.bar across a child->parent directory boundary', () => {
expect(hasCallEdge(path.join(tmpNative, '.codegraph', 'graph.db'), 'useFoo', 'Foo.bar')).toBe(
true,
);
});
});
Loading
Loading