mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
IMPORT_CYCLE_LIMIT,
|
|
IMPORT_CYCLE_WORK_LIMIT,
|
|
findImportCycles,
|
|
type ImportCycleReport,
|
|
} from '../../src/core/graph/import-cycles.js';
|
|
|
|
/**
|
|
* Unwrap a report that must be a COMPLETE enumeration. Written as an assertion
|
|
* rather than a conditional so a degraded report fails the test instead of
|
|
* silently yielding a representative list that looks like an answer.
|
|
*/
|
|
function cyclesOf(report: ImportCycleReport): readonly string[][] {
|
|
expect(report.enumeration).toBe('complete');
|
|
return (report as Extract<ImportCycleReport, { enumeration: 'complete' }>).cycles;
|
|
}
|
|
|
|
function componentCountOf(report: ImportCycleReport): number {
|
|
expect(report.enumeration).toBe('complete');
|
|
return (report as Extract<ImportCycleReport, { enumeration: 'complete' }>).componentCount;
|
|
}
|
|
|
|
/** Unwrap a report that must have degraded to one cycle per component. */
|
|
function representativesOf(report: ImportCycleReport): readonly string[][] {
|
|
expect(report.enumeration).toBe('component-representatives');
|
|
return (report as Extract<ImportCycleReport, { enumeration: 'component-representatives' }>)
|
|
.cycles;
|
|
}
|
|
|
|
/** Every consecutive pair of a reported cycle that is NOT an edge of the graph. */
|
|
function fabricatedSteps(
|
|
cycles: readonly string[][],
|
|
edges: readonly { source: string; target: string }[],
|
|
): string[] {
|
|
const present = new Set(edges.map(({ source, target }) => `${source}>${target}`));
|
|
return cycles
|
|
.flatMap((cycle) => cycle.slice(0, -1).map((node, index) => `${node}>${cycle[index + 1]}`))
|
|
.filter((step) => !present.has(step));
|
|
}
|
|
|
|
/** Every ordered pair of distinct nodes — the complete digraph on `nodes`. */
|
|
function completeDigraph(nodes: readonly string[]): { source: string; target: string }[] {
|
|
return nodes.flatMap((source) =>
|
|
nodes.filter((target) => target !== source).map((target) => ({ source, target })),
|
|
);
|
|
}
|
|
|
|
/** `a -> b` for every pair in a `a b` space-separated line, for readable fixtures. */
|
|
function edgesOf(...pairs: string[]): { source: string; target: string }[] {
|
|
return pairs.map((pair) => {
|
|
const [source, target] = pair.split(' ');
|
|
return { source, target };
|
|
});
|
|
}
|
|
|
|
describe('findImportCycles', () => {
|
|
it('reports no cycles for a DAG', () => {
|
|
const report = findImportCycles(edgesOf('a b', 'b c', 'a c', 'c d'));
|
|
expect(cyclesOf(report)).toEqual([]);
|
|
expect(componentCountOf(report)).toBe(0);
|
|
});
|
|
|
|
it('reports a self-import as a one-node cycle', () => {
|
|
expect(cyclesOf(findImportCycles(edgesOf('a a')))).toEqual([['a', 'a']]);
|
|
});
|
|
|
|
it('deduplicates repeated edges', () => {
|
|
expect(cyclesOf(findImportCycles(edgesOf('a a', 'a a', 'a a')))).toEqual([['a', 'a']]);
|
|
});
|
|
|
|
it('reports a two-node cycle', () => {
|
|
expect(cyclesOf(findImportCycles(edgesOf('a b', 'b a')))).toEqual([['a', 'b', 'a']]);
|
|
});
|
|
|
|
it('reports a self-import alongside the larger cycle that shares its node', () => {
|
|
// `a`'s self-loop and the a->b->a cycle are distinct elementary cycles that
|
|
// live in one strongly connected component. Reporting one component-
|
|
// representative would show only one of them.
|
|
const report = findImportCycles(edgesOf('a a', 'a b', 'b a'));
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'a'],
|
|
['a', 'b', 'a'],
|
|
]);
|
|
expect(componentCountOf(report)).toBe(1);
|
|
});
|
|
|
|
it('reports both loops of a figure-eight sharing one node', () => {
|
|
// a->b->a and a->c->a meet only at `a`: one SCC, two elementary cycles.
|
|
const report = findImportCycles(edgesOf('a b', 'b a', 'a c', 'c a'));
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'b', 'a'],
|
|
['a', 'c', 'a'],
|
|
]);
|
|
expect(componentCountOf(report)).toBe(1);
|
|
});
|
|
|
|
it('reports two disjoint cycles as two components', () => {
|
|
const report = findImportCycles(edgesOf('y z', 'z y', 'b a', 'a b'));
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'b', 'a'],
|
|
['y', 'z', 'y'],
|
|
]);
|
|
expect(componentCountOf(report)).toBe(2);
|
|
});
|
|
|
|
it('reports every elementary cycle of a three-node complete digraph', () => {
|
|
// K3 has exactly five elementary cycles: three 2-cycles and two 3-cycles
|
|
// (the two orientations of the triangle). Counted by hand.
|
|
const report = findImportCycles(edgesOf('a b', 'b a', 'a c', 'c a', 'b c', 'c b'));
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'b', 'a'],
|
|
['a', 'b', 'c', 'a'],
|
|
['a', 'c', 'a'],
|
|
['a', 'c', 'b', 'a'],
|
|
['b', 'c', 'b'],
|
|
]);
|
|
expect(componentCountOf(report)).toBe(1);
|
|
});
|
|
|
|
it('reports nested cycles that share a chain of nodes', () => {
|
|
// One SCC on a-b-c-d: the outer 4-cycle a->b->c->d->a, the inner 3-cycle
|
|
// a->b->c->a via the c->a chord, and the inner 2-cycle a->b->a via b->a.
|
|
const report = findImportCycles(edgesOf('a b', 'b c', 'c d', 'd a', 'c a', 'b a'));
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'b', 'a'],
|
|
['a', 'b', 'c', 'a'],
|
|
['a', 'b', 'c', 'd', 'a'],
|
|
]);
|
|
expect(componentCountOf(report)).toBe(1);
|
|
});
|
|
|
|
it('reports a cycle once regardless of which node the walk could enter it from', () => {
|
|
// Three entry points (x, y, z) all lead into the same b->c->d->b triangle.
|
|
// Rotation normalization roots it at its least node and emits it once.
|
|
const report = findImportCycles(
|
|
edgesOf('x b', 'y c', 'z d', 'b c', 'c d', 'd b', 'a x', 'a y', 'a z'),
|
|
);
|
|
expect(cyclesOf(report)).toEqual([['b', 'c', 'd', 'b']]);
|
|
});
|
|
|
|
it('roots every cycle at its lexicographically smallest node', () => {
|
|
// The only cycle is m->n->k->m. Its smallest node is `k`, so that is where
|
|
// the reported rotation starts and closes — not `m`, the edge-list head.
|
|
expect(cyclesOf(findImportCycles(edgesOf('m n', 'n k', 'k m')))).toEqual([
|
|
['k', 'm', 'n', 'k'],
|
|
]);
|
|
});
|
|
|
|
it('produces identical output for the same input twice', () => {
|
|
const edges = edgesOf('a b', 'b c', 'c a', 'c b', 'b a', 'd e', 'e d', 'e e');
|
|
expect(JSON.stringify(findImportCycles(edges))).toBe(JSON.stringify(findImportCycles(edges)));
|
|
});
|
|
|
|
it('produces identical output regardless of edge input order', () => {
|
|
// Determinism must come from the graph, not from the order rows arrived in.
|
|
const edges = edgesOf('a b', 'b c', 'c a', 'c b', 'b a');
|
|
expect(JSON.stringify(findImportCycles(edges))).toBe(
|
|
JSON.stringify(findImportCycles([...edges].reverse())),
|
|
);
|
|
});
|
|
|
|
it('counts every elementary cycle of a complete digraph', () => {
|
|
// K_n has sum over k=2..n of C(n,k) * (k-1)! elementary cycles.
|
|
// For n = 5 that is 10*1 + 10*2 + 5*6 + 1*24 = 84.
|
|
const nodes = ['a', 'b', 'c', 'd', 'e'];
|
|
const edges = completeDigraph(nodes);
|
|
expect(cyclesOf(findImportCycles(edges))).toHaveLength(84);
|
|
});
|
|
|
|
it('never emits the same cycle under two rotations', () => {
|
|
const nodes = ['a', 'b', 'c', 'd', 'e'];
|
|
const edges = completeDigraph(nodes);
|
|
// Canonicalize independently of the implementation's own rule: drop the
|
|
// repeated tail, then rotate to the smallest node. Duplicates under any
|
|
// rotation would collapse here and shrink the set.
|
|
const canonical = cyclesOf(findImportCycles(edges)).map((cycle) => {
|
|
const body = cycle.slice(0, -1);
|
|
const pivot = body.indexOf([...body].sort()[0]);
|
|
return [...body.slice(pivot), ...body.slice(0, pivot)].join('>');
|
|
});
|
|
expect(new Set(canonical).size).toBe(canonical.length);
|
|
});
|
|
|
|
it('closes every reported cycle back onto its first node', () => {
|
|
const cycles = cyclesOf(findImportCycles(edgesOf('a b', 'b c', 'c a', 'c b', 'a a')));
|
|
expect(cycles.map((cycle) => cycle[0] === cycle[cycle.length - 1])).toEqual(
|
|
cycles.map(() => true),
|
|
);
|
|
});
|
|
|
|
it('reports every node of a reported cycle exactly once', () => {
|
|
const cycles = cyclesOf(findImportCycles(edgesOf('a b', 'b c', 'c a', 'c b', 'b a')));
|
|
expect(cycles.map((cycle) => new Set(cycle.slice(0, -1)).size)).toEqual(
|
|
cycles.map((cycle) => cycle.length - 1),
|
|
);
|
|
});
|
|
|
|
it('reports only edges that exist between consecutive nodes of a cycle', () => {
|
|
const edges = edgesOf('a b', 'b c', 'c a', 'c b', 'b a', 'a c');
|
|
expect(fabricatedSteps(cyclesOf(findImportCycles(edges)), edges)).toEqual([]);
|
|
});
|
|
|
|
it('improves on one-cycle-per-component reporting for a single tangled component', () => {
|
|
// The regression this replaces: a->b->c->d->a plus a->z->a is ONE strongly
|
|
// connected component, and the previous implementation returned exactly one
|
|
// BFS path for it — hiding the other four cycles. All five are elementary,
|
|
// all five must be reported, and they are all in one component.
|
|
const report = findImportCycles(
|
|
edgesOf('a b', 'b c', 'c d', 'd a', 'a z', 'z a', 'b d', 'd b'),
|
|
);
|
|
expect(componentCountOf(report)).toBe(1);
|
|
expect(cyclesOf(report)).toEqual([
|
|
['a', 'b', 'c', 'd', 'a'],
|
|
['a', 'b', 'd', 'a'],
|
|
['a', 'z', 'a'],
|
|
['b', 'c', 'd', 'b'],
|
|
['b', 'd', 'b'],
|
|
]);
|
|
});
|
|
|
|
it('does not return a shortened elementary-cycle list when the cap is reached', () => {
|
|
// K5 has 84 cycles; a cap of 10 must not yield a 10-item list.
|
|
const nodes = ['a', 'b', 'c', 'd', 'e'];
|
|
const edges = completeDigraph(nodes);
|
|
expect(findImportCycles(edges, 10)).toEqual({
|
|
enumeration: 'component-representatives',
|
|
reason: 'cycles',
|
|
limit: 10,
|
|
componentCount: 1,
|
|
cycles: [['a', 'b', 'a']],
|
|
});
|
|
});
|
|
|
|
it('carries no count of elementary cycles when capped', () => {
|
|
// The point of failing closed: there is no field a caller could mistake for
|
|
// a complete answer.
|
|
const report = findImportCycles(edgesOf('a b', 'b a', 'a c', 'c a'), 1);
|
|
// The degraded report carries a list, but the type says what kind, and it
|
|
// carries NO count of elementary cycles -- there is no field a caller could
|
|
// read a cycle count from.
|
|
expect(Object.keys(report).sort()).toEqual([
|
|
'componentCount',
|
|
'cycles',
|
|
'enumeration',
|
|
'limit',
|
|
'reason',
|
|
]);
|
|
});
|
|
|
|
it('completes rather than capping when the cycle count exactly equals the cap', () => {
|
|
// Boundary: 2 cycles under a cap of 2 is a complete answer, not an overflow.
|
|
expect(cyclesOf(findImportCycles(edgesOf('a b', 'b a', 'a c', 'c a'), 2))).toHaveLength(2);
|
|
});
|
|
|
|
it('caps a graph whose cycle count exceeds the default limit', () => {
|
|
// K9 has 109_600 elementary cycles, well past IMPORT_CYCLE_LIMIT.
|
|
const nodes = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'];
|
|
const edges = completeDigraph(nodes);
|
|
expect(findImportCycles(edges)).toEqual({
|
|
enumeration: 'component-representatives',
|
|
reason: 'cycles',
|
|
limit: IMPORT_CYCLE_LIMIT,
|
|
componentCount: 1,
|
|
cycles: [['a', 'b', 'a']],
|
|
});
|
|
});
|
|
|
|
it('fails closed on the work budget even when few cycles have been found', () => {
|
|
// The cycle cap alone cannot bound runtime: this graph has exactly one
|
|
// cycle, so no cycle count would ever stop it. Only the work budget does.
|
|
const size = 400;
|
|
const edges = Array.from({ length: size }, (_, index) => ({
|
|
source: `r${String(index).padStart(4, '0')}`,
|
|
target: `r${String((index + 1) % size).padStart(4, '0')}`,
|
|
}));
|
|
expect(findImportCycles(edges, IMPORT_CYCLE_LIMIT, 50)).toEqual({
|
|
// The budget ran out inside the very first decomposition, so not even the
|
|
// tangle count is known -- and it reports nothing rather than zero.
|
|
enumeration: 'none',
|
|
reason: 'work',
|
|
limit: 50,
|
|
});
|
|
});
|
|
|
|
it('reports which bound stopped the search', () => {
|
|
const edges = edgesOf('a b', 'b a', 'a c', 'c a', 'b c', 'c b');
|
|
const byCycles = findImportCycles(edges, 1, IMPORT_CYCLE_WORK_LIMIT);
|
|
const byWork = findImportCycles(edges, IMPORT_CYCLE_LIMIT, 3);
|
|
expect([byCycles, byWork]).toEqual([
|
|
{
|
|
enumeration: 'component-representatives',
|
|
reason: 'cycles',
|
|
limit: 1,
|
|
componentCount: 1,
|
|
cycles: [['a', 'b', 'a']],
|
|
},
|
|
{ enumeration: 'none', reason: 'work', limit: 3 },
|
|
]);
|
|
});
|
|
|
|
it('leaves the default work budget untouched by a realistic import graph', () => {
|
|
// 100k files in a chain with fan-out, plus a handful of real tangles: the
|
|
// shape `check` actually runs on must finish, not trip the budget.
|
|
const size = 100_000;
|
|
const edges = Array.from({ length: size - 1 }, (_, index) => ({
|
|
source: `src/${String(index).padStart(6, '0')}.ts`,
|
|
target: `src/${String(index + 1).padStart(6, '0')}.ts`,
|
|
}));
|
|
edges.push(
|
|
{ source: 'src/000500.ts', target: 'src/000100.ts' },
|
|
{ source: 'src/030000.ts', target: 'src/029000.ts' },
|
|
);
|
|
expect(cyclesOf(findImportCycles(edges))).toHaveLength(2);
|
|
});
|
|
|
|
it('degrades to one representative per component when the cycle cap is exceeded', () => {
|
|
// Two independent tangles, six cycles between them, a cap of 2. The list is
|
|
// withheld, but the number a reader acts on survives.
|
|
const report = findImportCycles(
|
|
edgesOf('a b', 'b a', 'a c', 'c a', 'b c', 'c b', 'y z', 'z y'),
|
|
2,
|
|
);
|
|
expect(report).toEqual({
|
|
enumeration: 'component-representatives',
|
|
reason: 'cycles',
|
|
limit: 2,
|
|
componentCount: 2,
|
|
cycles: [
|
|
['a', 'b', 'a'],
|
|
['y', 'z', 'y'],
|
|
],
|
|
});
|
|
});
|
|
|
|
it('returns exactly one representative per cyclic component when capped', () => {
|
|
// Four independent tangles, each with several elementary cycles. Capped at
|
|
// 1, the report must name all four -- not the one it managed to enumerate.
|
|
const edges = edgesOf(
|
|
'a b',
|
|
'b a',
|
|
'a c',
|
|
'c a',
|
|
'b c',
|
|
'c b',
|
|
'j k',
|
|
'k j',
|
|
'j l',
|
|
'l j',
|
|
'p q',
|
|
'q r',
|
|
'r p',
|
|
'r q',
|
|
'q p',
|
|
's s',
|
|
);
|
|
const report = findImportCycles(edges, 1);
|
|
expect(representativesOf(report)).toEqual([
|
|
['a', 'b', 'a'],
|
|
['j', 'k', 'j'],
|
|
['p', 'q', 'p'],
|
|
['s', 's'],
|
|
]);
|
|
expect(
|
|
(report as Extract<ImportCycleReport, { enumeration: 'component-representatives' }>)
|
|
.componentCount,
|
|
).toBe(4);
|
|
});
|
|
|
|
it('reports representatives that are real cycles in the input graph', () => {
|
|
// A representative is only useful if a reader can follow it. Every
|
|
// consecutive pair must be an actual import edge, and it must close.
|
|
const edges = edgesOf('a b', 'b c', 'c a', 'c b', 'b a', 'a d', 'd a', 'm n', 'n o', 'o m');
|
|
const representatives = representativesOf(findImportCycles(edges, 1));
|
|
expect(fabricatedSteps(representatives, edges)).toEqual([]);
|
|
expect(representatives.map((cycle) => cycle[0] === cycle[cycle.length - 1])).toEqual(
|
|
representatives.map(() => true),
|
|
);
|
|
});
|
|
|
|
it('picks the shortest cycle through each component root as its representative', () => {
|
|
// The component holds a 2-cycle and a 4-cycle through `a`. BFS must return
|
|
// the short one -- a representative exists to be read, so length matters.
|
|
const report = findImportCycles(edgesOf('a b', 'b c', 'c d', 'd a', 'a z', 'z a'), 1);
|
|
expect(representativesOf(report)).toEqual([['a', 'z', 'a']]);
|
|
});
|
|
|
|
it('roots representatives at the component least node, like the complete list', () => {
|
|
const report = findImportCycles(edgesOf('m n', 'n k', 'k m', 'm k'), 1);
|
|
expect(representativesOf(report)).toEqual([['k', 'm', 'k']]);
|
|
});
|
|
|
|
it('produces identical degraded output for the same input twice', () => {
|
|
const edges = edgesOf('a b', 'b c', 'c a', 'c b', 'b a', 'd e', 'e d');
|
|
expect(JSON.stringify(findImportCycles(edges, 1))).toBe(
|
|
JSON.stringify(findImportCycles(edges, 1)),
|
|
);
|
|
});
|
|
|
|
it('handles deep import graphs without recursive traversal', () => {
|
|
const size = 20_000;
|
|
const edges = Array.from({ length: size - 1 }, (_, index) => ({
|
|
source: `src/${index}.ts`,
|
|
target: `src/${index + 1}.ts`,
|
|
}));
|
|
expect(cyclesOf(findImportCycles(edges))).toEqual([]);
|
|
});
|
|
|
|
it('handles a single deep cycle without recursive traversal', () => {
|
|
// One 20k-node cycle: the search stack reaches full depth before closing.
|
|
const size = 20_000;
|
|
const edges = Array.from({ length: size }, (_, index) => ({
|
|
source: `src/${String(index).padStart(6, '0')}.ts`,
|
|
target: `src/${String((index + 1) % size).padStart(6, '0')}.ts`,
|
|
}));
|
|
expect(cyclesOf(findImportCycles(edges))).toHaveLength(1);
|
|
});
|
|
|
|
it('ignores edges with an empty endpoint', () => {
|
|
expect(
|
|
cyclesOf(findImportCycles([...edgesOf('a b', 'b a'), { source: '', target: 'a' }])),
|
|
).toEqual([['a', 'b', 'a']]);
|
|
});
|
|
});
|