Files
promptfoo--promptfoo/scripts/checkArchitectureBoundaries.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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.');
}