mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 00:01:39 +08:00
New `codegraph cycles [path] [--json]` prints the groups of files that all reach each other through imports. It exits 1 when there are any and 0 when there are none, so `codegraph cycles || exit 1` works as a pre-commit or CI check. The command stays a thin wrapper over CodeGraph.findCircularDependencies(), but the method itself had to change. It had no callers, and on real repos its walk wasn't fit to print: - It reported one list per back edge, so the number depends on the walk: 1,490 overlapping lists on excalidraw, 87 on gin, 44 on flask. - It left out files. A walk that meets a visited file stops there, so a file whose loop closes through one is never listed (now pinned by a test: a>b, b>a, a>c, c>b). - It counted every edge kind, and most of the graph's edges aren't imports. An interface's call fans out to a test's fake, so src/extraction/parse-pool.ts "depended" on its own test. Two Next.js pages that link to each other were a cycle. gin's files were a 55-file knot, though Go forbids import cycles. With exit 1 those are failures nobody can fix. The method now takes the strongly connected components of the file import graph, one group per knot. The Tarjan pass moves from the viewer's map into src/graph/file-cycles.ts and both surfaces use it, so the viewer's "Circular imports between files" panel, which was reading every edge too, now reports the same groups. Dependent counts on the map keep their own edge list. Validation: new cli-cycles and file-cycles suites, plus cycles in the read-commands suite (mid-rebuild, and old schema indexes); 4 of the 10 findCircularDependencies and finder tests fail with main's method and all 15 pass with this one. Against the TypeScript compiler's own module resolver over the same files (kit oracle): excalidraw 3 groups, 369 files, all identical; codegraph's src/ 3 of 4 identical, and the fourth needs src/resolution/name-matcher.ts, which at 1.6 MB is over the default size limit and so isn't parsed. flask reports 2 groups and gin none. It runs in under a second on each. Type-only imports count, as they aren't told apart in the graph. The MCP tool from PR #889 is left out on purpose: a new tool is rarely picked up, and this needs no agent steering. Reimplements PR #1012 by @jiangyq9, with the exit code from the CLI in PR #889 by @goutham80808. Fixes #888 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
4.2 KiB
TypeScript
117 lines
4.2 KiB
TypeScript
/**
|
|
* `codegraph cycles`: circular imports between files, end to end against the
|
|
* built binary. Exit 1 when there are any is the command's contract: it is what
|
|
* makes `codegraph cycles || exit 1` a pre-commit or CI check.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { spawnSync } 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');
|
|
|
|
function run(cwd: string, ...args: string[]) {
|
|
const res = spawnSync(process.execPath, [BIN, 'cycles', cwd, ...args], {
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
|
|
});
|
|
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/** `count` files in one import ring: each imports the next, the last the first. */
|
|
function ring(count: number): Record<string, string> {
|
|
const files: Record<string, string> = {};
|
|
for (let i = 0; i < count; i++) {
|
|
const next = (i + 1) % count;
|
|
files[`src/f${String(i).padStart(2, '0')}.ts`] =
|
|
`import { f${next} } from './f${String(next).padStart(2, '0')}';\nexport function f${i}() { return f${next}(); }\n`;
|
|
}
|
|
return files;
|
|
}
|
|
|
|
describe('codegraph cycles', () => {
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cycles-cli-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('lists the files of a circular import and exits 1', 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",
|
|
'src/plain.ts': "import { a } from './a';\nexport const plain = a;\n",
|
|
});
|
|
|
|
const res = run(dir);
|
|
expect(res.status).toBe(1);
|
|
expect(res.stdout).toContain('Circular imports: 1 group, 2 files');
|
|
expect(res.stdout).toContain('src/a.ts');
|
|
expect(res.stdout).toContain('src/b.ts');
|
|
expect(res.stdout).not.toContain('src/plain.ts');
|
|
});
|
|
|
|
it('prints the whole report as JSON and still exits 1', 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, '--json');
|
|
expect(res.status).toBe(1);
|
|
expect(JSON.parse(res.stdout)).toEqual({ count: 1, cycles: [['src/a.ts', 'src/b.ts']] });
|
|
});
|
|
|
|
it('says so and exits 0 when the project has none', async () => {
|
|
await indexProject(dir, {
|
|
'src/a.ts': 'export function a() { return 1; }\n',
|
|
'src/b.ts': "import { a } from './a';\nexport function b() { return a(); }\n",
|
|
});
|
|
|
|
const res = run(dir);
|
|
expect(res.status).toBe(0);
|
|
expect(res.stdout).toContain('No circular imports found');
|
|
|
|
const json = run(dir, '--json');
|
|
expect(json.status).toBe(0);
|
|
expect(JSON.parse(json.stdout)).toEqual({ count: 0, cycles: [] });
|
|
});
|
|
|
|
it('shows the first files of a long group, and --json all of them', async () => {
|
|
await indexProject(dir, ring(14));
|
|
|
|
const res = run(dir);
|
|
expect(res.status).toBe(1);
|
|
expect(res.stdout).toContain('1. 14 files');
|
|
expect(res.stdout).toContain('src/f11.ts');
|
|
expect(res.stdout).not.toContain('src/f12.ts');
|
|
expect(res.stdout).toContain('... and 2 more (--json lists them all)');
|
|
|
|
const parsed = JSON.parse(run(dir, '--json').stdout) as { cycles: string[][] };
|
|
expect(parsed.cycles).toHaveLength(1);
|
|
expect(parsed.cycles[0]).toHaveLength(14);
|
|
});
|
|
|
|
it('fails plainly on a project with no index', () => {
|
|
const res = run(dir);
|
|
expect(res.status).toBe(1);
|
|
expect(res.stderr + res.stdout).toContain('not initialized');
|
|
});
|
|
});
|