Three boundaries the layer contract declares had no executable rule behind them, so the code drifted across all three while every gate stayed green. `src/contracts` reached back up into `src/application` for the shared `Result` carrier and the compatibility predicate. Neither package owned the shared vocabulary and the dependency pointed both ways. Both now live in contracts — the lower package — and application re-exports them, so no caller moves. A concrete adapter was not supposed to depend on another concrete adapter, but only adapter-to-presentation was enforced, and `diagnostics` imported a guard out of `telemetry`. The guard belongs to neither, so it moved to the adapter kernel. Stating the rule needed the checker to resolve `$1` in a `to` pattern against the importing module's own directory; the alternative is one rule per adapter group, which silently stops covering a group the moment one is added. Product assembly leaks out of bootstrap: generic presentation reads the installed-feature registries. That is a real refactor, so the rule freezes the exact set of modules doing it today rather than pretending it is fixed — a new edge fails. The two remaining open edges are named in the config, not silent. Each rule was verified by introducing the violation it forbids and confirming the gate rejects it. The documentation drifted the same way. README and the manual accessibility checklist both said six routes while ten were registered, which left the platform overview and three reference-resource screens outside the declared manual review scope without anyone deciding they should be. The scope is now derived from the route registry by `verify:documentation`, so the sentence cannot outlive the registry again. The review ledger also named a canonical path that does not exist in this tree; it is upstream provenance, and it now says so instead of looking like a broken repository reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import {
|
|
evaluatePromotionReadiness,
|
|
PROMOTION_FORMULA,
|
|
type GateResult,
|
|
} from "../src/application/policies/promotion-readiness.ts";
|
|
import {
|
|
loadCiGateContract,
|
|
indexCiGateContract,
|
|
} from "./contracts/ci-gates.ts";
|
|
import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts";
|
|
import {
|
|
ciContractReportSchema,
|
|
} from "./lib/ci-contract-report.ts";
|
|
import {
|
|
validateInstallScriptPolicy,
|
|
validatePackageScriptGraph,
|
|
} from "./lib/package-script-graph.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
|
|
const contract = await loadCiGateContract(process.cwd(), {
|
|
mode: removalFixtureMode ? "removal-fixture" : "canonical",
|
|
});
|
|
const index = indexCiGateContract(contract);
|
|
const [packageDocument, nodeVersion] = await Promise.all([
|
|
readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record<string, string> }),
|
|
readFile(".nvmrc", "utf8").then((value) => value.trim()),
|
|
]);
|
|
const packageScripts = packageDocument.scripts ?? {};
|
|
const failures: string[] = [];
|
|
|
|
if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) {
|
|
failures.push(".nvmrc must contain one exact Node.js semantic version");
|
|
}
|
|
for (const script of [
|
|
"build:release-candidate",
|
|
"verify:local-evidence",
|
|
"verify:promotion",
|
|
"generate:ci-workflow",
|
|
"check:ci-workflow",
|
|
"check:ci",
|
|
]) {
|
|
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
|
}
|
|
for (const removedScript of [
|
|
"build:release",
|
|
"verify:supply-chain",
|
|
"verify:supply-chain:promotion",
|
|
]) {
|
|
if (packageScripts[removedScript]) failures.push(`legacy package script remains ${removedScript}`);
|
|
}
|
|
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
|
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
|
}
|
|
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
|
|
failures.push(
|
|
...validateInstallScriptPolicy(
|
|
packageScripts,
|
|
[...new Set(contract.commands.map(({ script }) => script))],
|
|
),
|
|
);
|
|
|
|
const immutable = index.gates.get("FE-GATE-015");
|
|
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
|
|
if (JSON.stringify(immutableCommands) !== JSON.stringify(["build:release-candidate", "verify:local-evidence"])) {
|
|
failures.push("FE-GATE-015 must build candidate bytes once and verify local evidence only");
|
|
}
|
|
const architecture = index.gates.get("FE-GATE-010");
|
|
if (!architecture?.commandIds.some((id) => index.commands.get(id)?.script === "check:ci")) {
|
|
failures.push("a blocking gate must execute check:ci");
|
|
}
|
|
if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.path === "artifacts/quality/ci-contract.json")) {
|
|
failures.push("FE-GATE-010 must publish the typed CI contract report");
|
|
}
|
|
|
|
const expectedGateIds = Array.from(
|
|
{ length: 27 },
|
|
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
|
);
|
|
const passingResults: Record<string, GateResult> = Object.fromEntries(
|
|
expectedGateIds.map((gateId) => [gateId, "PASS"]),
|
|
);
|
|
const allPass = evaluatePromotionReadiness(passingResults);
|
|
const negativeFixtures: Array<{
|
|
readiness: keyof typeof PROMOTION_FORMULA;
|
|
failedGate: string;
|
|
passed: boolean;
|
|
}> = [];
|
|
for (const readiness of Object.keys(PROMOTION_FORMULA) as Array<keyof typeof PROMOTION_FORMULA>) {
|
|
const failedGate = PROMOTION_FORMULA[readiness][0];
|
|
if (!failedGate) throw new TypeError(`${readiness} has no configured gates`);
|
|
const evaluated = evaluatePromotionReadiness({ ...passingResults, [failedGate]: "FAIL" });
|
|
const passed = evaluated[readiness] === false;
|
|
negativeFixtures.push({ readiness, failedGate, passed });
|
|
if (!passed) failures.push(`${readiness} did not fail closed`);
|
|
}
|
|
if (!Object.values(allPass).every(Boolean)) failures.push("all-PASS formula did not produce every readiness state");
|
|
|
|
const renderedWorkflow = renderCiWorkflow(contract);
|
|
const workflowCheck = await generateCiWorkflow({
|
|
root: process.cwd(),
|
|
contract,
|
|
check: true,
|
|
});
|
|
if (!workflowCheck.matches) {
|
|
failures.push(
|
|
`generated workflow drift at byte ${workflowCheck.firstDifferenceByte ?? "missing"}, line ${workflowCheck.firstDifferenceLine ?? "missing"}`,
|
|
);
|
|
}
|
|
let checkedWorkflowBytes = Buffer.from(renderedWorkflow, "utf8");
|
|
try {
|
|
checkedWorkflowBytes = await readFile(workflowCheck.target);
|
|
} catch (error) {
|
|
failures.push(`generated workflow is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
const report = ciContractReportSchema.parse({
|
|
schemaVersion: 2,
|
|
nodeVersion,
|
|
gateCount: contract.gates.length,
|
|
commandDefinitionCount: contract.commands.length,
|
|
commandReferenceCount: contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0),
|
|
artifactCount: contract.artifacts.length,
|
|
jobCount: contract.jobs.length,
|
|
workflowSha256: createHash("sha256").update(checkedWorkflowBytes).digest("hex"),
|
|
durationStatus: contract.retention.durationStatus,
|
|
negativeFixtures,
|
|
failures,
|
|
passed: failures.length === 0,
|
|
});
|
|
await writeValidatedJsonArtifact({
|
|
path: "artifacts/quality/ci-contract.json",
|
|
schema: ciContractReportSchema,
|
|
value: report,
|
|
});
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n");
|