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
184 lines
7.5 KiB
TypeScript
184 lines
7.5 KiB
TypeScript
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
compareEdgesToBaseline,
|
|
computeCrossLayerEdges,
|
|
computeStronglyConnectedComponents,
|
|
findBackEdges,
|
|
findForbiddenDependencyViolations,
|
|
findUnclassifiedFiles,
|
|
findViolations,
|
|
readEdgeBaseline,
|
|
readLayerConfig,
|
|
scanArchitectureSources,
|
|
} from './architectureUtils';
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const config = readLayerConfig(repoRoot);
|
|
const sourceScan = scanArchitectureSources(repoRoot, config);
|
|
const violations = findViolations(repoRoot, config, sourceScan);
|
|
|
|
const facadeViolations = violations.filter((v) => v.kind === 'facade');
|
|
const leafViolations = violations.filter((v) => v.kind === 'leaf');
|
|
const leafExternalViolations = violations.filter((v) => v.kind === 'leaf-external');
|
|
const layerViolations = violations.filter((v) => v.kind === 'layer');
|
|
const pathViolations = violations.filter((v) => v.kind === 'path');
|
|
const unclassifiedFiles = findUnclassifiedFiles(repoRoot, config, sourceScan.sourceFiles);
|
|
|
|
if (facadeViolations.length > 0) {
|
|
console.error('Architecture boundary violations found:');
|
|
for (const violation of facadeViolations) {
|
|
console.error(
|
|
`- ${violation.importer} (${violation.importerLayer}) imports public facade via "${violation.specifier}"`,
|
|
);
|
|
}
|
|
console.error(
|
|
'\nInternal modules must import a narrower internal surface instead of src/index.ts.',
|
|
);
|
|
}
|
|
|
|
if (leafViolations.length > 0) {
|
|
console.error('Leaf-layer boundary violations found:');
|
|
for (const violation of leafViolations) {
|
|
console.error(
|
|
`- ${violation.importer} (${violation.importerLayer}) imports ${violation.imported} (${violation.importedLayer}) via "${violation.specifier}"`,
|
|
);
|
|
}
|
|
console.error('\nLeaf layers must not import other product layers.');
|
|
}
|
|
|
|
if (leafExternalViolations.length > 0) {
|
|
console.error('Leaf-layer external dependency violations found:');
|
|
for (const violation of leafExternalViolations) {
|
|
console.error(
|
|
`- ${violation.importer} (${violation.importerLayer}) imports external "${violation.specifier}"`,
|
|
);
|
|
}
|
|
console.error(
|
|
'\nLeaf layers may import only their allowlisted external packages (allowedExternal).',
|
|
);
|
|
}
|
|
|
|
if (layerViolations.length > 0) {
|
|
console.error('Layer dependency violations found:');
|
|
for (const violation of layerViolations) {
|
|
console.error(
|
|
`- ${violation.importer} (${violation.importerLayer}) imports ${violation.imported} (${violation.importedLayer}) via "${violation.specifier}"`,
|
|
);
|
|
}
|
|
console.error('\nLayers may import only their explicitly allowed dependencies.');
|
|
}
|
|
|
|
if (pathViolations.length > 0) {
|
|
console.error('Restricted layer import violations found:');
|
|
for (const violation of pathViolations) {
|
|
console.error(
|
|
`- ${violation.importer} (${violation.importerLayer}) imports ${violation.imported} (${violation.importedLayer}) via "${violation.specifier}"`,
|
|
);
|
|
}
|
|
console.error('\nRestricted layers may import only their explicitly allowed internal paths.');
|
|
}
|
|
|
|
if (unclassifiedFiles.length > 0) {
|
|
console.error('Unclassified source files found:');
|
|
for (const sourceFile of unclassifiedFiles) {
|
|
console.error(`- ${sourceFile}`);
|
|
}
|
|
console.error('\nEvery checked source file must belong to an explicit architecture layer.');
|
|
}
|
|
|
|
// ---- DAG progress checks (layer dependency graph) ----
|
|
const edges = computeCrossLayerEdges(repoRoot, config, sourceScan);
|
|
|
|
// 1. Strongly-connected-component ratchet: the largest dependency cycle may
|
|
// only shrink over time (toward a DAG, where every component has size 1).
|
|
const components = computeStronglyConnectedComponents(config, edges);
|
|
const cycles = components.filter((component) => component.length > 1);
|
|
const largestCycle = cycles[0]?.length ?? 1;
|
|
let sccRatchetFailed = false;
|
|
if (config.maxStronglyConnectedComponentSize !== undefined) {
|
|
if (largestCycle > config.maxStronglyConnectedComponentSize) {
|
|
sccRatchetFailed = true;
|
|
console.error(
|
|
`Layer dependency cycle grew: largest strongly-connected component has ${largestCycle} layers, ` +
|
|
`but maxStronglyConnectedComponentSize is ${config.maxStronglyConnectedComponentSize}.`,
|
|
);
|
|
for (const cycle of cycles) {
|
|
console.error(`- cycle: {${cycle.join(', ')}}`);
|
|
}
|
|
console.error(
|
|
'\nBreaking a cross-layer cycle should lower maxStronglyConnectedComponentSize in architecture/layers.json; new cycles are not allowed.',
|
|
);
|
|
} else if (largestCycle < config.maxStronglyConnectedComponentSize) {
|
|
console.log(
|
|
`Note: largest dependency cycle is now ${largestCycle} layers (ratchet is ${config.maxStronglyConnectedComponentSize}). ` +
|
|
'Lower maxStronglyConnectedComponentSize in architecture/layers.json to lock in the progress.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// 2. Per-edge baseline ratchet: no cross-layer edge may grow, and no new
|
|
// cross-layer edge may appear, relative to architecture/edge-baseline.json.
|
|
const baseline = readEdgeBaseline(repoRoot, config);
|
|
const { regressions, improvements } = compareEdgesToBaseline(edges, baseline);
|
|
if (regressions.length > 0) {
|
|
console.error('Cross-layer dependency baseline regressions found:');
|
|
for (const regression of regressions) {
|
|
const previously = regression.allowed === 0 ? 'new edge' : `was ${regression.allowed}`;
|
|
console.error(
|
|
`- ${regression.from} -> ${regression.to}: ${regression.count} imports (${previously})`,
|
|
);
|
|
}
|
|
console.error(
|
|
'\nNew or widened cross-layer dependencies are not allowed. Remove the import, or if it is intentional run `npm run architecture:baseline` and justify the change in review.',
|
|
);
|
|
}
|
|
if (improvements.length > 0) {
|
|
console.log(
|
|
`Note: ${improvements.length} cross-layer edge(s) shrank below baseline. Run \`npm run architecture:baseline\` to lock in the reduction.`,
|
|
);
|
|
}
|
|
|
|
// 3. Explicit forbidden-dependency locks (pairs whose cycle is fully broken).
|
|
const forbiddenDependencyViolations = findForbiddenDependencyViolations(config, edges);
|
|
if (forbiddenDependencyViolations.length > 0) {
|
|
console.error('Forbidden cross-layer dependencies found:');
|
|
for (const violation of forbiddenDependencyViolations) {
|
|
const layerPath = [violation.from, ...violation.path.map((edge) => edge.to)].join(' -> ');
|
|
console.error(`- ${violation.from} depends on ${violation.to} via ${layerPath} (forbidden)`);
|
|
for (const edge of violation.path) {
|
|
console.error(` ${edge.from} -> ${edge.to}: ${edge.count} imports`);
|
|
for (const file of edge.files) {
|
|
console.error(` ${file}`);
|
|
}
|
|
}
|
|
}
|
|
console.error(
|
|
'\nThese layer pairs are locked acyclic via forbiddenDependencies and must not regress directly or transitively.',
|
|
);
|
|
}
|
|
|
|
// 4. Informational: back-edges versus the target topological order.
|
|
if (config.tierOrder !== undefined) {
|
|
const backEdges = findBackEdges(edges, config.tierOrder);
|
|
if (backEdges.length > 0) {
|
|
const totalBackImports = backEdges.reduce((sum, edge) => sum + edge.count, 0);
|
|
console.log(
|
|
`\nRemaining back-edges versus target order (${backEdges.length} edges / ${totalBackImports} imports to reach a DAG):`,
|
|
);
|
|
for (const edge of [...backEdges].sort((left, right) => left.count - right.count)) {
|
|
console.log(`- ${edge.from} -> ${edge.to}: ${edge.count} imports`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const dagChecksFailed =
|
|
sccRatchetFailed || regressions.length > 0 || forbiddenDependencyViolations.length > 0;
|
|
|
|
if (violations.length > 0 || unclassifiedFiles.length > 0 || dagChecksFailed) {
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log('Architecture boundaries passed.');
|
|
}
|