chore: import upstream snapshot with attribution
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user