mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 04:21:31 +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>
147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
/**
|
|
* What counts as a circular import: the strongly connected components of the
|
|
* file import graph, and nothing else the graph knows about two files.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { findFileCycles } from '../src/graph/file-cycles';
|
|
|
|
const pairs = (...edges: string[]) =>
|
|
edges.map((edge) => {
|
|
const [source, target] = edge.split('>') as [string, string];
|
|
return { source, target };
|
|
});
|
|
|
|
describe('findFileCycles', () => {
|
|
it('finds nothing in a graph with no loop, a diamond included', () => {
|
|
expect(findFileCycles(pairs('a>b', 'a>c', 'b>d', 'c>d'))).toEqual([]);
|
|
expect(findFileCycles([])).toEqual([]);
|
|
});
|
|
|
|
it('reports a knot once, with every file in it', () => {
|
|
// a>b>a is a loop and so is a>c>b>a. A walk meets b first and has nothing
|
|
// left to do at c>b, so it reported a>b and left c out.
|
|
expect(findFileCycles(pairs('a>b', 'b>a', 'a>c', 'c>b'))).toEqual([['a', 'b', 'c']]);
|
|
// Two loops through one file are one group, however many ways round there are.
|
|
expect(findFileCycles(pairs('a>b', 'b>a', 'a>c', 'c>a'))).toEqual([['a', 'b', 'c']]);
|
|
});
|
|
|
|
it('keeps separate knots apart, smallest first, and leaves out the files between them', () => {
|
|
expect(findFileCycles(pairs('p>q', 'q>r', 'r>p', 'q>x', 'x>a', 'a>b', 'b>a'))).toEqual([
|
|
['a', 'b'],
|
|
['p', 'q', 'r'],
|
|
]);
|
|
});
|
|
|
|
it('gives the same report for the pairs in any order', () => {
|
|
const edges = pairs('a>b', 'b>c', 'c>a', 'd>e', 'e>d', 'c>d');
|
|
expect(findFileCycles([...edges].reverse())).toEqual(findFileCycles(edges));
|
|
});
|
|
|
|
it('walks a chain deeper than the call stack', () => {
|
|
const length = 200_000;
|
|
const chain = Array.from({ length }, (_, i) => ({ source: `f${i}`, target: `f${i + 1}` }));
|
|
chain.push({ source: `f${length}`, target: 'f0' });
|
|
const cycles = findFileCycles(chain);
|
|
expect(cycles).toHaveLength(1);
|
|
expect(cycles[0]).toHaveLength(length + 1);
|
|
});
|
|
});
|
|
|
|
describe('findCircularDependencies', () => {
|
|
let dir: string;
|
|
let cg: CodeGraph | null = null;
|
|
|
|
async function index(files: Record<string, string>): Promise<CodeGraph> {
|
|
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);
|
|
}
|
|
cg = CodeGraph.initSync(dir);
|
|
await cg.indexAll();
|
|
return cg;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-file-cycles-'));
|
|
});
|
|
afterEach(() => {
|
|
cg?.close();
|
|
cg = null;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('finds a loop that runs through a barrel', async () => {
|
|
const graph = await index({
|
|
'src/index.ts': "export * from './a';\n",
|
|
'src/a.ts': "import { helper } from './helper';\nexport function a() { return helper(); }\n",
|
|
'src/helper.ts': "import { a } from './index';\nexport function helper() { return a; }\n",
|
|
});
|
|
expect(graph.findCircularDependencies()).toEqual([['src/a.ts', 'src/helper.ts', 'src/index.ts']]);
|
|
});
|
|
|
|
it('finds a loop in Python', async () => {
|
|
const graph = await index({
|
|
'a.py': 'from b import bb\n\ndef aa():\n return bb()\n',
|
|
'b.py': 'from a import aa\n\ndef bb():\n return aa()\n',
|
|
});
|
|
expect(graph.findCircularDependencies()).toEqual([['a.py', 'b.py']]);
|
|
});
|
|
|
|
it('does not take an interface call reaching a test fake for an import', async () => {
|
|
// Production code imports nothing from the test. The interface's call fans
|
|
// out to every implementer, the fake included, so the file edge list holds
|
|
// the loop src>test>src that no import makes.
|
|
const graph = await index({
|
|
'src/pool.ts': [
|
|
'export interface Worker { post(task: string): void }',
|
|
'export function run(worker: Worker) { worker.post("x"); }',
|
|
'',
|
|
].join('\n'),
|
|
'src/pool.test.ts': [
|
|
"import { run, Worker } from './pool';",
|
|
'class FakeWorker implements Worker { post(task: string): void { void task; } }',
|
|
'export function check() { run(new FakeWorker()); }',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const edgeList = graph.getFileDependencyPairs();
|
|
expect(edgeList).toContainEqual({ source: 'src/pool.ts', target: 'src/pool.test.ts' });
|
|
expect(edgeList).toContainEqual({ source: 'src/pool.test.ts', target: 'src/pool.ts' });
|
|
expect(graph.findCircularDependencies()).toEqual([]);
|
|
});
|
|
|
|
it('does not take two pages that link to each other for an import', async () => {
|
|
const graph = await index({
|
|
'package.json': JSON.stringify({ name: 'site', dependencies: { next: '15.0.0', react: '19.0.0' } }),
|
|
'app/page.tsx': [
|
|
"import Link from 'next/link';",
|
|
'export default function Home() { return <Link href="/about">About</Link>; }',
|
|
'',
|
|
].join('\n'),
|
|
'app/about/page.tsx': [
|
|
"import Link from 'next/link';",
|
|
'export default function About() { return <Link href="/">Home</Link>; }',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const edgeList = graph.getFileDependencyPairs();
|
|
expect(edgeList).toContainEqual({ source: 'app/page.tsx', target: 'app/about/page.tsx' });
|
|
expect(edgeList).toContainEqual({ source: 'app/about/page.tsx', target: 'app/page.tsx' });
|
|
expect(graph.findCircularDependencies()).toEqual([]);
|
|
});
|
|
|
|
it('does not take the files of one Go package calling each other for an import', async () => {
|
|
const graph = await index({
|
|
'go.mod': 'module example.com/m\n\ngo 1.21\n',
|
|
'one.go': 'package m\n\nfunc One() { Two() }\n',
|
|
'two.go': 'package m\n\nfunc Two() { One() }\n',
|
|
});
|
|
expect(graph.findCircularDependencies()).toEqual([]);
|
|
});
|
|
});
|