0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
buildEdgeBaseline,
|
|
type CrossLayerEdge,
|
|
compareEdgesToBaseline,
|
|
computeCrossLayerEdges,
|
|
computeStronglyConnectedComponents,
|
|
edgeKey,
|
|
findBackEdges,
|
|
findForbiddenDependencyViolations,
|
|
type LayerConfig,
|
|
readEdgeBaseline,
|
|
readLayerConfig,
|
|
scanArchitectureSources,
|
|
} from '../../scripts/architectureUtils';
|
|
|
|
function edge(from: string, to: string, count: number): CrossLayerEdge {
|
|
return { from, to, count, files: [] };
|
|
}
|
|
|
|
function configWith(layers: LayerConfig['layers']): LayerConfig {
|
|
return { publicFacade: 'src/index.ts', layers };
|
|
}
|
|
|
|
describe('computeCrossLayerEdges', () => {
|
|
let repoRoot: string;
|
|
|
|
beforeEach(() => {
|
|
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function write(relativePath: string, contents = ''): void {
|
|
const absolute = path.join(repoRoot, relativePath);
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, contents);
|
|
}
|
|
|
|
it('tallies cross-layer import edges and skips the facade importer and same-layer imports', () => {
|
|
write(
|
|
'architecture/layers.json',
|
|
JSON.stringify({
|
|
publicFacade: 'src/index.ts',
|
|
leafLayers: ['contracts'],
|
|
layers: [
|
|
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
|
{ name: 'app', roots: ['src/app'], allowedDependencies: ['core', 'contracts'] },
|
|
{ name: 'core', roots: ['src/core'], allowedDependencies: ['contracts'] },
|
|
{ name: 'contracts', roots: ['src/contracts'], allowedDependencies: [] },
|
|
],
|
|
}),
|
|
);
|
|
write('src/index.ts', "export * from './core/b.js';"); // facade importer is skipped
|
|
write('src/contracts/c.ts', 'export const c = 1;');
|
|
write('src/core/b.ts', "import { c } from '../contracts/c.js';");
|
|
write(
|
|
'src/app/a.ts',
|
|
"import { b } from '../core/b.js';\nimport { c } from '../contracts/c.js';",
|
|
);
|
|
write('src/app/a2.ts', "import { b } from '../core/b.js';"); // 2nd app->core importer
|
|
|
|
const config = readLayerConfig(repoRoot);
|
|
const sourceScan = scanArchitectureSources(repoRoot, config);
|
|
const edges = computeCrossLayerEdges(repoRoot, config, sourceScan);
|
|
|
|
expect(sourceScan.sourceFiles).toHaveLength(5);
|
|
expect(edges).toEqual([
|
|
{ from: 'app', to: 'contracts', count: 1, files: ['src/app/a.ts'] },
|
|
{ from: 'app', to: 'core', count: 2, files: ['src/app/a.ts', 'src/app/a2.ts'] },
|
|
{ from: 'core', to: 'contracts', count: 1, files: ['src/core/b.ts'] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('computeStronglyConnectedComponents', () => {
|
|
it('returns a multi-layer component for a cycle and singletons otherwise, largest first', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
|
]);
|
|
const edges = [edge('a', 'b', 1), edge('b', 'a', 1), edge('c', 'a', 1)];
|
|
|
|
expect(computeStronglyConnectedComponents(config, edges)).toEqual([['a', 'b'], ['c']]);
|
|
});
|
|
|
|
it('reports every layer as its own component when the graph is a DAG', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
]);
|
|
const components = computeStronglyConnectedComponents(config, [edge('a', 'b', 3)]);
|
|
expect(components.every((component) => component.length === 1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('findBackEdges', () => {
|
|
it('flags edges from a lower tier to a higher tier (cycle-causing)', () => {
|
|
const tierOrder = ['contracts', 'core', 'app'];
|
|
const edges = [
|
|
edge('app', 'core', 4), // high -> low: allowed
|
|
edge('core', 'contracts', 2), // high -> low: allowed
|
|
edge('contracts', 'core', 1), // low -> high: BACK
|
|
edge('core', 'app', 3), // low -> high: BACK
|
|
edge('app', 'unranked', 9), // touches a layer absent from tierOrder: ignored
|
|
];
|
|
|
|
expect(findBackEdges(edges, tierOrder)).toEqual([
|
|
edge('contracts', 'core', 1),
|
|
edge('core', 'app', 3),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('edge baseline', () => {
|
|
it('builds a baseline keyed by edge with import counts', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
|
]);
|
|
|
|
expect(buildEdgeBaseline([edge('a', 'b', 2), edge('a', 'c', 5)], config)).toEqual({
|
|
[edgeKey('a', 'b')]: 2,
|
|
[edgeKey('a', 'c')]: 5,
|
|
});
|
|
});
|
|
|
|
it('rejects edges that reference unclassified or unknown layers', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
]);
|
|
|
|
expect(() => buildEdgeBaseline([edge('unclassified', 'a', 1)], config)).toThrow(
|
|
'references an unknown layer',
|
|
);
|
|
expect(() => buildEdgeBaseline([edge('a', 'ghost', 1)], config)).toThrow(
|
|
'references an unknown layer',
|
|
);
|
|
});
|
|
|
|
it('reports grown/new edges as regressions and shrunk/removed edges as improvements', () => {
|
|
const baseline = { [edgeKey('a', 'b')]: 5, [edgeKey('c', 'd')]: 2, [edgeKey('g', 'h')]: 3 };
|
|
const edges = [
|
|
edge('a', 'b', 6), // grew -> regression
|
|
edge('c', 'd', 1), // shrank -> improvement
|
|
edge('e', 'f', 1), // new edge (baseline 0) -> regression
|
|
];
|
|
|
|
const { regressions, improvements } = compareEdgesToBaseline(edges, baseline);
|
|
|
|
expect(regressions).toEqual([
|
|
{ from: 'a', to: 'b', count: 6, allowed: 5 },
|
|
{ from: 'e', to: 'f', count: 1, allowed: 0 },
|
|
]);
|
|
expect(improvements).toEqual(
|
|
expect.arrayContaining([
|
|
{ from: 'c', to: 'd', count: 1, allowed: 2 },
|
|
{ from: 'g', to: 'h', count: 0, allowed: 3 }, // disappeared entirely
|
|
]),
|
|
);
|
|
expect(improvements).toHaveLength(2);
|
|
});
|
|
|
|
it('treats an unchanged edge as neither a regression nor an improvement', () => {
|
|
const result = compareEdgesToBaseline([edge('a', 'b', 4)], { [edgeKey('a', 'b')]: 4 });
|
|
expect(result.regressions).toEqual([]);
|
|
expect(result.improvements).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('readEdgeBaseline', () => {
|
|
let repoRoot: string;
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
]);
|
|
|
|
beforeEach(() => {
|
|
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-baseline-'));
|
|
fs.mkdirSync(path.join(repoRoot, 'architecture'), { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeBaseline(baseline: unknown): void {
|
|
fs.writeFileSync(
|
|
path.join(repoRoot, 'architecture/edge-baseline.json'),
|
|
JSON.stringify(baseline),
|
|
);
|
|
}
|
|
|
|
it('accepts positive integer counts for known layer edges', () => {
|
|
writeBaseline({ [edgeKey('a', 'b')]: 2 });
|
|
expect(readEdgeBaseline(repoRoot, config)).toEqual({ [edgeKey('a', 'b')]: 2 });
|
|
});
|
|
|
|
it('rejects baseline edges that reference unknown layers', () => {
|
|
writeBaseline({ [edgeKey('a', 'ghost')]: 2 });
|
|
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid edge');
|
|
});
|
|
|
|
it('rejects non-positive or non-integer import counts', () => {
|
|
writeBaseline({ [edgeKey('a', 'b')]: 0 });
|
|
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid import count');
|
|
|
|
writeBaseline({ [edgeKey('a', 'b')]: 1.5 });
|
|
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid import count');
|
|
});
|
|
});
|
|
|
|
describe('findForbiddenDependencyViolations', () => {
|
|
it('flags direct dependencies that violate an explicit forbiddenDependencies lock', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: ['c'], forbiddenDependencies: ['b'] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
|
]);
|
|
const edges = [edge('a', 'b', 1), edge('a', 'c', 3)];
|
|
|
|
expect(findForbiddenDependencyViolations(config, edges)).toEqual([
|
|
{ from: 'a', to: 'b', path: [edge('a', 'b', 1)] },
|
|
]);
|
|
});
|
|
|
|
it('flags transitive dependencies that violate an explicit forbiddenDependencies lock', () => {
|
|
const config = configWith([
|
|
{ name: 'a', roots: ['src/a'], allowedDependencies: ['c'], forbiddenDependencies: ['b'] },
|
|
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
|
{ name: 'c', roots: ['src/c'], allowedDependencies: ['b'] },
|
|
]);
|
|
const edges = [edge('a', 'c', 3), edge('c', 'b', 2)];
|
|
|
|
expect(findForbiddenDependencyViolations(config, edges)).toEqual([
|
|
{ from: 'a', to: 'b', path: [edge('a', 'c', 3), edge('c', 'b', 2)] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('readLayerConfig DAG policy validation', () => {
|
|
let repoRoot: string;
|
|
|
|
beforeEach(() => {
|
|
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-cfg-'));
|
|
fs.mkdirSync(path.join(repoRoot, 'src/core'), { recursive: true });
|
|
fs.writeFileSync(path.join(repoRoot, 'src/index.ts'), '');
|
|
fs.writeFileSync(path.join(repoRoot, 'src/core/a.ts'), '');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeConfig(extra: Record<string, unknown>): void {
|
|
fs.mkdirSync(path.join(repoRoot, 'architecture'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoRoot, 'architecture/layers.json'),
|
|
JSON.stringify({
|
|
publicFacade: 'src/index.ts',
|
|
layers: [
|
|
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
|
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
|
],
|
|
...extra,
|
|
}),
|
|
);
|
|
}
|
|
|
|
it('accepts a valid tierOrder, maxStronglyConnectedComponentSize, and forbiddenDependencies', () => {
|
|
writeConfig({
|
|
tierOrder: ['core', 'facade'],
|
|
maxStronglyConnectedComponentSize: 1,
|
|
layers: [
|
|
{
|
|
name: 'facade',
|
|
roots: ['src/index.ts'],
|
|
allowedDependencies: [],
|
|
forbiddenDependencies: ['core'],
|
|
},
|
|
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
|
],
|
|
});
|
|
expect(() => readLayerConfig(repoRoot)).not.toThrow();
|
|
});
|
|
|
|
it('rejects a tierOrder referencing an unknown layer', () => {
|
|
writeConfig({ tierOrder: ['core', 'ghost'] });
|
|
expect(() => readLayerConfig(repoRoot)).toThrow('tierOrder contains unknown layer "ghost"');
|
|
});
|
|
|
|
it('rejects a tierOrder that omits a configured layer', () => {
|
|
writeConfig({ tierOrder: ['core'] });
|
|
expect(() => readLayerConfig(repoRoot)).toThrow(
|
|
'tierOrder must list every layer. Missing: facade',
|
|
);
|
|
});
|
|
|
|
it('rejects a non-positive maxStronglyConnectedComponentSize', () => {
|
|
writeConfig({ maxStronglyConnectedComponentSize: 0 });
|
|
expect(() => readLayerConfig(repoRoot)).toThrow(
|
|
'maxStronglyConnectedComponentSize must be a positive integer',
|
|
);
|
|
});
|
|
|
|
it('rejects a layer that both allows and forbids the same dependency', () => {
|
|
writeConfig({
|
|
layers: [
|
|
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
|
{
|
|
name: 'core',
|
|
roots: ['src/core'],
|
|
allowedDependencies: ['facade'],
|
|
forbiddenDependencies: ['facade'],
|
|
},
|
|
],
|
|
});
|
|
expect(() => readLayerConfig(repoRoot)).toThrow('both allows and forbids dependency "facade"');
|
|
});
|
|
|
|
it('rejects a non-array forbiddenDependencies value', () => {
|
|
writeConfig({
|
|
layers: [
|
|
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
|
{
|
|
name: 'core',
|
|
roots: ['src/core'],
|
|
allowedDependencies: [],
|
|
forbiddenDependencies: 'facade',
|
|
},
|
|
],
|
|
});
|
|
expect(() => readLayerConfig(repoRoot)).toThrow(
|
|
'forbiddenDependencies must be an array of layer names',
|
|
);
|
|
});
|
|
});
|