Skip to content

Commit 7a728a0

Browse files
committed
merge: resolve conflicts with main
Impact: 21 functions changed, 39 affected
2 parents 9fb2e6b + ecc6f81 commit 7a728a0

9 files changed

Lines changed: 546 additions & 33 deletions

File tree

crates/codegraph-core/src/cfg.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,8 @@ impl<'a> CfgBuilder<'a> {
803803
for_stmt.child_by_field_name(field).is_some()
804804
|| for_stmt.child_by_field_name("right").is_some()
805805
|| for_stmt.child_by_field_name("value").is_some()
806+
// Go: for-range has a range_clause child (no condition/right/value fields)
807+
|| has_child_of_kind(for_stmt, "range_clause")
806808
// Explicit iterator-style node kinds across supported languages:
807809
// JS: for_in_statement, Java: enhanced_for_statement,
808810
// C#/PHP: foreach_statement, Ruby: for
@@ -1134,6 +1136,14 @@ impl<'a> CfgBuilder<'a> {
11341136

11351137
// ─── Helpers ────────────────────────────────────────────────────────────
11361138

1139+
/// Returns true if `node` has a direct child whose node kind equals `kind`.
1140+
/// This is a shallow (one-level) check — it does not recurse into grandchildren.
1141+
fn has_child_of_kind(node: &Node, kind: &str) -> bool {
1142+
let mut cursor = node.walk();
1143+
let result = node.children(&mut cursor).any(|c| c.kind() == kind);
1144+
result
1145+
}
1146+
11371147
fn node_line(node: &Node) -> u32 {
11381148
node.start_position().row as u32 + 1
11391149
}

crates/codegraph-core/src/import_resolution.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,33 @@ fn file_exists(path: &str, known: Option<&HashSet<String>>) -> bool {
1010
known.map_or_else(|| Path::new(path).exists(), |set| set.contains(path))
1111
}
1212

13+
/// Resolve `.` and `..` components in a path without touching the filesystem.
14+
/// Unlike `PathBuf::components().collect()`, this properly collapses `..` by
15+
/// popping the previous component from the result.
16+
///
17+
/// NOTE: if the path begins with more `..` components than there are preceding
18+
/// components to pop (e.g. a purely relative `../../foo`), the excess `..`
19+
/// components are silently dropped. This function is therefore only correct
20+
/// when called on paths that have already been joined to a base directory with
21+
/// sufficient depth.
22+
fn clean_path(p: &Path) -> PathBuf {
23+
let mut result = PathBuf::new();
24+
for c in p.components() {
25+
match c {
26+
std::path::Component::ParentDir => {
27+
result.pop();
28+
}
29+
std::path::Component::CurDir => {}
30+
_ => result.push(c),
31+
}
32+
}
33+
result
34+
}
35+
1336
/// Normalize a path to use forward slashes and clean `.` / `..` segments
1437
/// (cross-platform consistency).
1538
fn normalize_path(p: &str) -> String {
16-
let cleaned: PathBuf = Path::new(p).components().collect();
39+
let cleaned = clean_path(Path::new(p));
1740
cleaned.display().to_string().replace('\\', "/")
1841
}
1942

@@ -111,7 +134,7 @@ fn resolve_import_path_inner(
111134

112135
// Relative import — normalize immediately to remove `.` / `..` segments
113136
let dir = Path::new(from_file).parent().unwrap_or(Path::new(""));
114-
let resolved: PathBuf = dir.join(import_source).components().collect();
137+
let resolved = clean_path(&dir.join(import_source));
115138
let resolved_str = resolved.display().to_string().replace('\\', "/");
116139

117140
// .js → .ts remap
@@ -246,3 +269,49 @@ pub fn resolve_imports_batch(
246269
})
247270
.collect()
248271
}
272+
273+
#[cfg(test)]
274+
mod tests {
275+
use super::*;
276+
277+
#[test]
278+
fn clean_path_collapses_parent_dirs() {
279+
assert_eq!(
280+
clean_path(Path::new("src/cli/commands/../../domain/graph/builder.js")),
281+
PathBuf::from("src/domain/graph/builder.js")
282+
);
283+
}
284+
285+
#[test]
286+
fn clean_path_skips_cur_dir() {
287+
assert_eq!(
288+
clean_path(Path::new("src/./foo.ts")),
289+
PathBuf::from("src/foo.ts")
290+
);
291+
}
292+
293+
#[test]
294+
fn clean_path_handles_absolute_root() {
295+
assert_eq!(
296+
clean_path(Path::new("/src/../foo.ts")),
297+
PathBuf::from("/foo.ts")
298+
);
299+
}
300+
301+
#[test]
302+
fn clean_path_mixed_segments() {
303+
assert_eq!(
304+
clean_path(Path::new("a/b/../c/./d/../e.js")),
305+
PathBuf::from("a/c/e.js")
306+
);
307+
}
308+
309+
#[test]
310+
fn clean_path_excess_parent_dirs_silently_dropped() {
311+
// Documents the known limitation: excess leading `..` are dropped
312+
assert_eq!(
313+
clean_path(Path::new("../../foo")),
314+
PathBuf::from("foo")
315+
);
316+
}
317+
}

scripts/incremental-benchmark.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ if (!isWorker()) {
5858
function walk(dir) {
5959
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
6060
if (entry.isDirectory()) { walk(path.join(dir, entry.name)); continue; }
61-
if (!entry.name.endsWith('.js')) continue;
61+
if (!entry.name.endsWith('.js') && !entry.name.endsWith('.ts') && !entry.name.endsWith('.tsx')) continue;
6262
const absFile = path.join(dir, entry.name);
6363
const content = fs.readFileSync(absFile, 'utf8');
6464
let match;

src/domain/graph/cycles.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ import { CodeGraph } from '../../graph/model.js';
44
import { loadNative } from '../../infrastructure/native.js';
55
import type { BetterSqlite3Database } from '../../types.js';
66

7+
/**
8+
* Engine parity note — function-level cycle counts
9+
*
10+
* The native (Rust) and WASM engines may report different function-level cycle
11+
* counts even on the same codebase. This is expected behavior, not a bug in
12+
* the cycle detection algorithm (Tarjan SCC is identical in both engines).
13+
*
14+
* Root cause: the native engine extracts slightly more symbols and resolves
15+
* more call edges than WASM (e.g. 10883 nodes / 4000 calls native vs 10857
16+
* nodes / 3986 calls WASM on the codegraph repo). The additional precision
17+
* can both create new edges and — more commonly — resolve previously ambiguous
18+
* calls to their correct targets, which breaks false cycles that WASM reports.
19+
*
20+
* For file-level cycles the engines are in parity because import edges are
21+
* resolved identically. The gap only manifests at function-level granularity
22+
* where call-site extraction differs between the Rust and WASM parsers.
23+
*
24+
* See: https://github.com/optave/codegraph/issues/597
25+
*/
726
export function findCycles(
827
db: BetterSqlite3Database,
928
opts: { fileLevel?: boolean; noTests?: boolean } = {},

src/features/cfg.ts

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@ interface FileSymbols {
8484
_langId?: string;
8585
}
8686

87+
/**
88+
* Check whether all function/method definitions in a single file already
89+
* have native CFG data (blocks populated by the Rust extractor).
90+
* cfg === null means no body (expected); cfg with empty blocks means not computed.
91+
*/
92+
function hasNativeCfgForFile(symbols: FileSymbols): boolean {
93+
return symbols.definitions
94+
.filter(
95+
(d) =>
96+
(d.kind === 'function' || d.kind === 'method') &&
97+
d.line > 0 &&
98+
d.endLine != null &&
99+
d.endLine > d.line &&
100+
!d.name.includes('.'),
101+
)
102+
.every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0);
103+
}
104+
87105
async function initCfgParsers(
88106
fileSymbols: Map<string, FileSymbols>,
89107
): Promise<{ parsers: unknown; getParserFn: unknown }> {
@@ -93,17 +111,7 @@ async function initCfgParsers(
93111
if (!symbols._tree) {
94112
const ext = path.extname(relPath).toLowerCase();
95113
if (CFG_EXTENSIONS.has(ext)) {
96-
const hasNativeCfg = symbols.definitions
97-
.filter(
98-
(d) =>
99-
(d.kind === 'function' || d.kind === 'method') &&
100-
d.line > 0 &&
101-
d.endLine != null &&
102-
d.endLine > d.line &&
103-
!d.name.includes('.'),
104-
)
105-
.every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0);
106-
if (!hasNativeCfg) {
114+
if (!hasNativeCfgForFile(symbols)) {
107115
needsFallback = true;
108116
break;
109117
}
@@ -136,11 +144,7 @@ function getTreeAndLang(
136144
let tree = symbols._tree;
137145
let langId = symbols._langId;
138146

139-
const allNative = symbols.definitions
140-
.filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
141-
.every((d) => d.cfg === null || (d.cfg?.blocks?.length ?? 0) > 0);
142-
143-
if (!tree && !allNative) {
147+
if (!tree && !hasNativeCfgForFile(symbols)) {
144148
if (!getParserFn) return null;
145149
langId = extToLang.get(ext);
146150
if (!langId || !CFG_RULES.has(langId)) return null;
@@ -253,14 +257,43 @@ function persistCfg(
253257

254258
// ─── Build-Time: Compute CFG for Changed Files ─────────────────────────
255259

260+
/**
261+
* Check if all function/method definitions across all files already have
262+
* native CFG data (blocks array populated by the Rust extractor).
263+
* When true, the WASM parser and JS CFG visitor can be fully bypassed.
264+
*/
265+
function allCfgNative(fileSymbols: Map<string, FileSymbols>): boolean {
266+
let hasCfgFile = false;
267+
for (const [relPath, symbols] of fileSymbols) {
268+
if (symbols._tree) continue; // already parsed via WASM; will use _tree in slow path
269+
const ext = path.extname(relPath).toLowerCase();
270+
if (!CFG_EXTENSIONS.has(ext)) continue;
271+
hasCfgFile = true;
272+
273+
if (!hasNativeCfgForFile(symbols)) return false;
274+
}
275+
// Return false when no CFG files found (empty map, all _tree, or all non-CFG
276+
// extensions) to avoid vacuously triggering the fast path.
277+
return hasCfgFile;
278+
}
279+
256280
export async function buildCFGData(
257281
db: BetterSqlite3Database,
258282
fileSymbols: Map<string, FileSymbols>,
259283
rootDir: string,
260284
_engineOpts?: unknown,
261285
): Promise<void> {
286+
// Fast path: when all function/method defs already have native CFG data,
287+
// skip WASM parser init, tree parsing, and JS visitor entirely — just persist.
288+
const allNative = allCfgNative(fileSymbols);
289+
262290
const extToLang = buildExtToLangMap();
263-
const { parsers, getParserFn } = await initCfgParsers(fileSymbols);
291+
let parsers: unknown = null;
292+
let getParserFn: unknown = null;
293+
294+
if (!allNative) {
295+
({ parsers, getParserFn } = await initCfgParsers(fileSymbols));
296+
}
264297

265298
const insertBlock = db.prepare(
266299
`INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label)
@@ -277,6 +310,35 @@ export async function buildCFGData(
277310
const ext = path.extname(relPath).toLowerCase();
278311
if (!CFG_EXTENSIONS.has(ext)) continue;
279312

313+
// Native fast path: skip tree/visitor setup when all CFG is pre-computed.
314+
// Only apply to files without _tree — files with _tree were WASM-parsed
315+
// and need the slow path (visitor) to compute CFG.
316+
if (allNative && !symbols._tree) {
317+
for (const def of symbols.definitions) {
318+
if (def.kind !== 'function' && def.kind !== 'method') continue;
319+
if (!def.line) continue;
320+
321+
const nodeId = getFunctionNodeId(db, def.name, relPath, def.line);
322+
if (!nodeId) continue;
323+
324+
// Always delete stale CFG rows (handles body-removed case)
325+
deleteCfgForNode(db, nodeId);
326+
if (!def.cfg?.blocks?.length) continue;
327+
328+
persistCfg(
329+
def.cfg as unknown as { blocks: CfgBuildBlock[]; edges: CfgBuildEdge[] },
330+
nodeId,
331+
insertBlock,
332+
insertEdge,
333+
);
334+
analyzed++;
335+
}
336+
continue;
337+
}
338+
339+
// When allNative=true, parsers/getParserFn are null. This is safe because
340+
// _tree files use symbols._tree directly in getTreeAndLang (the parser
341+
// code path is never reached). Non-_tree files are handled by the fast path above.
280342
const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn);
281343
if (!treeLang) continue;
282344
const { tree, langId } = treeLang;
@@ -309,9 +371,10 @@ export async function buildCFGData(
309371
if (r) cfg = { blocks: r.blocks, edges: r.edges };
310372
}
311373

374+
// Always purge stale rows (handles body-removed case)
375+
deleteCfgForNode(db, nodeId);
312376
if (!cfg || cfg.blocks.length === 0) continue;
313377

314-
deleteCfgForNode(db, nodeId);
315378
persistCfg(cfg, nodeId, insertBlock, insertEdge);
316379
analyzed++;
317380
}

src/mcp/server.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import { initMcpDefaults } from './middleware.js';
1919
import { buildToolList } from './tool-registry.js';
2020
import { TOOL_HANDLERS } from './tools/index.js';
2121

22+
/**
23+
* Module-level guard to register shutdown handlers only once per process.
24+
* Because tests use vi.resetModules(), this module-level variable resets
25+
* on each re-import — but the process-level flag on `process` persists.
26+
*/
27+
// biome-ignore lint/suspicious/noExplicitAny: MCP SDK server type is lazy-loaded
28+
let _activeServer: any = null;
29+
2230
export interface McpToolContext {
2331
dbPath: string | undefined;
2432
// biome-ignore lint/suspicious/noExplicitAny: lazy-loaded queries module
@@ -179,5 +187,63 @@ export async function startMCPServer(
179187

180188
// biome-ignore lint/suspicious/noExplicitAny: MCP SDK types are lazy-loaded and untyped
181189
const transport = new (StdioServerTransport as any)();
182-
await server.connect(transport);
190+
191+
// Graceful shutdown — when the client disconnects (e.g. session clear),
192+
// close the server cleanly so the process exits without error.
193+
// Track the active server at module level so handlers always reference
194+
// the latest instance (matters when tests call startMCPServer repeatedly).
195+
_activeServer = server;
196+
197+
// Register handlers once per process to avoid listener accumulation.
198+
// Use a process-level flag so it survives vi.resetModules() in tests.
199+
const g = globalThis as Record<string, unknown>;
200+
// biome-ignore lint/complexity/useLiteralKeys: bracket notation required by TS noPropertyAccessFromIndexSignature
201+
if (!g['__codegraph_shutdown_installed']) {
202+
// biome-ignore lint/complexity/useLiteralKeys: bracket notation required by TS noPropertyAccessFromIndexSignature
203+
g['__codegraph_shutdown_installed'] = true;
204+
205+
const shutdown = async () => {
206+
try {
207+
await _activeServer?.close();
208+
} catch {}
209+
process.exit(0);
210+
};
211+
const silentExit = (err: Error & { code?: string }) => {
212+
// Only suppress broken-pipe errors from closed stdio transport;
213+
// let real bugs surface with a non-zero exit code.
214+
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
215+
process.exit(0);
216+
}
217+
process.stderr.write(`Uncaught exception: ${err.stack ?? err.message}\n`);
218+
process.exit(1);
219+
};
220+
const silentReject = (reason: unknown) => {
221+
const err = reason instanceof Error ? reason : new Error(String(reason));
222+
const code = (err as Error & { code?: string }).code;
223+
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') {
224+
process.exit(0);
225+
}
226+
process.stderr.write(`Unhandled rejection: ${err.stack ?? err.message}\n`);
227+
process.exit(1);
228+
};
229+
230+
process.on('SIGINT', shutdown);
231+
process.on('SIGTERM', shutdown);
232+
process.on('SIGHUP', shutdown);
233+
process.on('uncaughtException', silentExit);
234+
process.on('unhandledRejection', silentReject);
235+
}
236+
237+
try {
238+
await server.connect(transport);
239+
} catch (err) {
240+
const code = (err as Error & { code?: string }).code;
241+
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') {
242+
process.exit(0);
243+
}
244+
process.stderr.write(
245+
`MCP transport connect failed: ${(err as Error).stack ?? (err as Error).message}\n`,
246+
);
247+
process.exit(1);
248+
}
183249
}

0 commit comments

Comments
 (0)