From 3885c0958576fecb981093e5b4482592ece00e4e Mon Sep 17 00:00:00 2001 From: Goutham Krishna Mandati <140805539+goutham80808@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:59:48 +0530 Subject: [PATCH 1/5] feat(mcp): add codegraph_check to surface circular import cycles Wires the existing (previously uncalled) CodeGraph.findCircularDependencies() DFS detector into the MCP tool surface. No new algorithm; the handler formats the string[][] result as markdown. Tool is callable but not added to DEFAULT_MCP_TOOLS -- respects the deliberate 4-tool surface trim. --- __tests__/mcp-check-circular-imports.test.ts | 61 ++++++++++++++++++++ src/mcp/tools.ts | 59 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 __tests__/mcp-check-circular-imports.test.ts diff --git a/__tests__/mcp-check-circular-imports.test.ts b/__tests__/mcp-check-circular-imports.test.ts new file mode 100644 index 000000000..98f3ca6ac --- /dev/null +++ b/__tests__/mcp-check-circular-imports.test.ts @@ -0,0 +1,61 @@ +/** + * codegraph_check — surfaces file-level circular import cycles via the MCP + * tool surface. Backed by the existing (previously dead-code) + * CodeGraph.findCircularDependencies() DFS detector. + * + * Scope of this suite: the MCP wiring (tool def + dispatch + handler + + * formatting). The underlying algorithm is already covered elsewhere; we + * treat it as a black box and assert on the formatted tool output. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; + +describe('codegraph_check (circular import detection)', () => { + let dir: string; + let cg: CodeGraph; + let h: ToolHandler; + + beforeEach(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-check-')); + cg = null as unknown as CodeGraph; + h = null as unknown as ToolHandler; + }); + + afterEach(() => { + if (cg) try { cg.close(); } catch { /* already closed */ } + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Build a two-file import cycle a.ts <-> b.ts, index, and arm the handler. */ + async function withCycle(): Promise { + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'a.ts'), + "import { b } from './b';\nexport function a() { return b(); }\n", + ); + fs.writeFileSync( + path.join(dir, 'src', 'b.ts'), + "import { a } from './a';\nexport function b() { return a(); }\n", + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + h = new ToolHandler(cg); + } + + const text = async (args: Record): Promise => + (await h.execute('codegraph_check', args)).content.map((c) => (c as { text: string }).text).join('\n'); + + it('detects a two-file import cycle and names both files in the output', async () => { + await withCycle(); + const out = await text({}); + + expect(out).toMatch(/circular/i); // header mentions "circular" + expect(out).toContain('src/a.ts'); // both cycle members named + expect(out).toContain('src/b.ts'); + expect(out).toMatch(/(1 cycle|cycles?:\s*1)/i); // cycle count present + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 83f2e0c45..2686b78d3 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -597,6 +597,17 @@ export const tools: ToolDefinition[] = [ }, }, }, + { + name: 'codegraph_check', + description: + 'Detect circular file-import cycles in the indexed project. Returns the cycle count and each cycle as an ordered list of file paths. Use before a refactor or commit to catch import cycles that break builds; a clean result is a success, not an error.', + inputSchema: { + type: 'object', + properties: { + projectPath: projectPathProperty, + }, + }, + }, ]; /** @@ -1120,6 +1131,8 @@ export class ToolHandler { return await this.handleStatus(args); case 'codegraph_files': result = await this.handleFiles(args); break; + case 'codegraph_check': + result = await this.handleCheck(args); break; default: return this.errorResult(`Unknown tool: ${toolName}`); } @@ -3253,6 +3266,52 @@ export class ToolHandler { return lines.join('\n'); } + /** + * Handle codegraph_check — surface file-level circular import cycles. + * + * Backed by CodeGraph.findCircularDependencies() (src/graph/queries.ts), + * which runs a DFS over file-level `imports` edges with white/gray/black + * coloring. The algorithm is unchanged here; this method only formats its + * string[][] result for the MCP surface. + * + * Output contract: + * - No cycles → SUCCESS textResult ("No circular imports found"), never + * an error (per "Errors teach abandonment" — a clean graph is happy path). + * - N > 0 → header with cycle count, then one numbered section per + * cycle listing its file paths in DFS order. Cycles are sorted by first + * path for deterministic output; the underlying result set is unchanged. + */ + private async handleCheck(args: Record): Promise { + const cg = this.getCodeGraph(args.projectPath as string | undefined); + const rawCycles = cg.findCircularDependencies(); + + if (rawCycles.length === 0) { + return this.textResult('No circular imports found.'); + } + + // Deterministic ordering for stable tests + readable output. Sort by the + // first path in each cycle; the DFS result set itself is unchanged. + const cycles = [...rawCycles].sort((x, y) => { + const a = x[0] ?? ''; + const b = y[0] ?? ''; + return a < b ? -1 : a > b ? 1 : 0; + }); + + const lines: string[] = [ + `## Circular Imports — ${cycles.length} cycle${cycles.length === 1 ? '' : 's'} found`, + '', + ]; + cycles.forEach((cycle, i) => { + lines.push(`### Cycle ${i + 1} (${cycle.length} files)`); + for (const p of cycle) lines.push(`- ${p}`); + // Close the loop visually: last file imports the first. + if (cycle.length > 1) lines.push(`- ↳ ${cycle[0]} (back to start)`); + lines.push(''); + }); + + return this.textResult(this.truncateOutput(lines.join('\n').trim())); + } + /** * Handle codegraph_status */ From 75eb4fa3534188e3d474a9e80779a2cbc1b8f944 Mon Sep 17 00:00:00 2001 From: Goutham Krishna Mandati <140805539+goutham80808@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:01:26 +0530 Subject: [PATCH 2/5] test(mcp): cover codegraph_check no-cycle + multi-cycle cases --- __tests__/mcp-check-circular-imports.test.ts | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/__tests__/mcp-check-circular-imports.test.ts b/__tests__/mcp-check-circular-imports.test.ts index 98f3ca6ac..2d74bc9f1 100644 --- a/__tests__/mcp-check-circular-imports.test.ts +++ b/__tests__/mcp-check-circular-imports.test.ts @@ -58,4 +58,54 @@ describe('codegraph_check (circular import detection)', () => { expect(out).toContain('src/b.ts'); expect(out).toMatch(/(1 cycle|cycles?:\s*1)/i); // cycle count present }); + + /** Build an acyclic two-file graph: c.ts imports d.ts, d.ts imports nothing. */ + async function withoutCycle(): Promise { + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync( + path.join(dir, 'src', 'c.ts'), + "import { d } from './d';\nexport function c() { return d(); }\n", + ); + fs.writeFileSync( + path.join(dir, 'src', 'd.ts'), + "export function d() { return 42; }\n", + ); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + h = new ToolHandler(cg); + } + + it('reports a clean success message (not an error) when there are no cycles', async () => { + await withoutCycle(); + const result = await h.execute('codegraph_check', {}); + expect(result.isError).toBeFalsy(); // happy path, never isError + const out = result.content.map((c) => (c as { text: string }).text).join('\n'); + expect(out.toLowerCase()).toContain('no circular'); + }); + + it('reports each cycle separately when more than one exists', async () => { + // Two independent cycles: x<->y in src/, and p<->q in other/. + fs.mkdirSync(path.join(dir, 'src')); + fs.mkdirSync(path.join(dir, 'other')); + fs.writeFileSync(path.join(dir, 'src', 'x.ts'), + "import { y } from './y';\nexport function x() { return y(); }\n"); + fs.writeFileSync(path.join(dir, 'src', 'y.ts'), + "import { x } from './x';\nexport function y() { return x(); }\n"); + fs.writeFileSync(path.join(dir, 'other', 'p.ts'), + "import { q } from './q';\nexport function p() { return q(); }\n"); + fs.writeFileSync(path.join(dir, 'other', 'q.ts'), + "import { p } from './p';\nexport function q() { return p(); }\n"); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + h = new ToolHandler(cg); + + const out = await text({}); + expect(out).toMatch(/2 cycles/i); + expect(out).toMatch(/Cycle 1/); + expect(out).toMatch(/Cycle 2/); + // All four files appear somewhere in the output. + for (const f of ['src/x.ts', 'src/y.ts', 'other/p.ts', 'other/q.ts']) { + expect(out).toContain(f); + } + }); }); From c5f6d726a57c99c576d3d1ffee5f52e9e9e7d580 Mon Sep 17 00:00:00 2001 From: Goutham Krishna Mandati <140805539+goutham80808@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:03:54 +0530 Subject: [PATCH 3/5] test(mcp): pin codegraph_check as callable-but-unlisted (surface trim) --- __tests__/mcp-check-circular-imports.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/__tests__/mcp-check-circular-imports.test.ts b/__tests__/mcp-check-circular-imports.test.ts index 2d74bc9f1..f82d5f9dc 100644 --- a/__tests__/mcp-check-circular-imports.test.ts +++ b/__tests__/mcp-check-circular-imports.test.ts @@ -108,4 +108,16 @@ describe('codegraph_check (circular import detection)', () => { expect(out).toContain(f); } }); + + it('is callable directly but NOT listed in the default tool surface', async () => { + await withoutCycle(); + const result = await h.execute('codegraph_check', {}); + expect(result.isError).toBeFalsy(); // callable + + // Default surface (no cg armed): the 4-tool trim. codegraph_check is + // intentionally absent here — agents won't see it in tools/list unless + // CODEGRAPH_MCP_TOOLS re-enables it. + const unlisted = new ToolHandler(null).getTools().map((t) => t.name); + expect(unlisted).not.toContain('codegraph_check'); + }); }); From 445bea387739ed5c84601a16f8e48ab13b6d9fdb Mon Sep 17 00:00:00 2001 From: Goutham Krishna Mandati <140805539+goutham80808@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:08:19 +0530 Subject: [PATCH 4/5] feat(cli): add codegraph check subcommand for circular import detection Prints file-level import cycles and exits non-zero when any are found, so it works as a git pre-commit hook. Backed by the same findCircularDependencies() DFS as the codegraph_check MCP tool. --- __tests__/cli-check.test.ts | 87 +++++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 68 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 __tests__/cli-check.test.ts diff --git a/__tests__/cli-check.test.ts b/__tests__/cli-check.test.ts new file mode 100644 index 000000000..63931b60d --- /dev/null +++ b/__tests__/cli-check.test.ts @@ -0,0 +1,87 @@ +/** + * `codegraph check` — CLI subcommand that prints file-level circular import + * cycles and exits non-zero when any are found (so it works as a git + * pre-commit hook: `codegraph check || exit 1`). + * + * Indexing is done via the library (CodeGraph.initSync + indexAll), matching + * the proven cli-affected-paths.test.ts pattern — avoids interactive prompts + * and daemon spawning. The command under test is invoked end-to-end against + * the built binary in dist/. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +function run(cwd: string): RunResult { + try { + const stdout = execFileSync(process.execPath, [BIN, 'check', cwd], { + encoding: 'utf8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (e) { + const err = e as { stdout?: string; stderr?: string; status?: number }; + return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }; + } +} + +/** Index a temp project from a map of rel-path -> file content, then close. */ +async function indexProject(dir: string, files: Record): Promise { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + cg.close(); +} + +describe('codegraph check (CLI)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cli-check-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('exits 0 and reports no cycles on an acyclic project', async () => { + await indexProject(dir, { + 'src/c.ts': "import { d } from './d';\nexport function c() { return d(); }\n", + 'src/d.ts': "export function d() { return 42; }\n", + }); + + const res = run(dir); + expect(res.code).toBe(0); + expect(res.stdout.toLowerCase()).toContain('no circular'); + }); + + it('exits non-zero and names both files when a cycle exists', async () => { + await indexProject(dir, { + 'src/a.ts': "import { b } from './b';\nexport function a() { return b(); }\n", + 'src/b.ts': "import { a } from './a';\nexport function b() { return a(); }\n", + }); + + const res = run(dir); + // Cycle present → non-zero exit (git-hook ready). + expect(res.code).not.toBe(0); + // Output names both members of the cycle (asserted on stdout content so + // this fails for the right reason pre-implementation, not just exit code). + expect(res.stdout).toContain('src/a.ts'); + expect(res.stdout).toContain('src/b.ts'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 12facd5aa..9b6005882 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1857,6 +1857,74 @@ program } }); +/** + * codegraph check [path] + * + * Detect circular file-import cycles in an indexed project. Prints each cycle + * as an ordered list of file paths. Exits non-zero if any cycle is found, so + * the command drops into a git pre-commit hook directly: + * + * # .git/hooks/pre-commit + * codegraph check || exit 1 + * + * Backed by CodeGraph.findCircularDependencies() (the same DFS detector the + * codegraph_check MCP tool surfaces). + */ +program + .command('check [path]') + .description('Detect circular file-import cycles (exits non-zero if any found)') + .option('-j, --json', 'Output as JSON') + .action(async (pathArg: string | undefined, options: { json?: boolean }) => { + const projectPath = resolveProjectPath(pathArg); + + try { + if (!isInitialized(projectPath)) { + warn('Not initialized'); + info('Run "codegraph init" first'); + process.exitCode = 1; + return; + } + + const { default: CodeGraph } = await loadCodeGraph(); + const cg = await CodeGraph.open(projectPath); + try { + // Deterministic ordering (sort by first path) — same contract as the + // MCP handler, so CLI and MCP output stay consistent. + const cycles = [...cg.findCircularDependencies()].sort((x, y) => { + const a = x[0] ?? ''; + const b = y[0] ?? ''; + return a < b ? -1 : a > b ? 1 : 0; + }); + + if (options.json) { + console.log(JSON.stringify({ + projectPath, + cycleCount: cycles.length, + cycles, + })); + } else if (cycles.length === 0) { + console.log(chalk.green('No circular imports found.')); + } else { + console.log(chalk.bold(`\nCircular Imports — ${cycles.length} cycle${cycles.length === 1 ? '' : 's'} found\n`)); + cycles.forEach((cycle, i) => { + console.log(chalk.cyan(`Cycle ${i + 1} (${cycle.length} files):`)); + for (const p of cycle) console.log(` ${p}`); + if (cycle.length > 1) console.log(` ↳ ${cycle[0]} (back to start)`); + console.log(); + }); + } + + // Non-zero exit on cycles so this works as a git pre-commit gate. + if (cycles.length > 0) process.exitCode = 1; + } finally { + cg.destroy(); + } + } catch (err) { + error(`Check failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } + }); + /** * codegraph install */ From 3183d1e22d378c4d6b308bb3a466d45d96fb0fa4 Mon Sep 17 00:00:00 2001 From: Goutham Krishna Mandati <140805539+goutham80808@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:09:49 +0530 Subject: [PATCH 5/5] docs(changelog): note codegraph check under [Unreleased] --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9806578b3..d804023a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- New `codegraph check` command detects circular file-import cycles in an indexed project and prints each cycle's file paths, exiting non-zero when any are found — so it drops straight into a git pre-commit hook and catches import cycles before they break a build instead of after. The same detection is also available to an AI agent as `codegraph_check`, or enable it in an agent's tool list with `CODEGRAPH_MCP_TOOLS=check`. + ## [1.0.1] - 2026-06-13