Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions __tests__/cli-check.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<void> {
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');
});
});
123 changes: 123 additions & 0 deletions __tests__/mcp-check-circular-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* 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<void> {
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<string, unknown>): Promise<string> =>
(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
});

/** Build an acyclic two-file graph: c.ts imports d.ts, d.ts imports nothing. */
async function withoutCycle(): Promise<void> {
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);
}
});

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');
});
});
68 changes: 68 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
59 changes: 59 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
];

/**
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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<string, unknown>): Promise<ToolResult> {
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
*/
Expand Down