feat: orchestrate blocking frontend quality gates

This commit is contained in:
donghyeon-ka
2026-07-25 21:39:04 +09:00
parent 6dd5b85c8c
commit c5e218d37a
14 changed files with 1057 additions and 3 deletions
+111
View File
@@ -0,0 +1,111 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluatePromotionReadiness,
PROMOTION_FORMULA,
} from "../src/application/policies/promotion-readiness.js";
const document = JSON.parse(await readFile("config/ci/gates.json", "utf8"));
const workflow = await readFile(document.providerAdapter, "utf8");
const failures = [];
const stageFormula = {
merge: PROMOTION_FORMULA.MERGE_READY,
release: PROMOTION_FORMULA.RELEASE_READY,
production: PROMOTION_FORMULA.PROD_PROMOTION_READY,
field: PROMOTION_FORMULA.FIELD_SLO_READY,
documentation: PROMOTION_FORMULA.DOCUMENTATION_READY,
};
for (const [stage, expectedGates] of Object.entries(stageFormula)) {
const actual = document.stages[stage]?.gates;
if (JSON.stringify(actual) !== JSON.stringify(expectedGates)) {
failures.push(`${stage} gate formula drift`);
}
}
const configuredGateIds = Object.keys(document.gates).sort();
const expectedGateIds = Array.from(
{ length: 26 },
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
);
if (JSON.stringify(configuredGateIds) !== JSON.stringify(expectedGateIds)) {
failures.push("gate registry must contain FE-GATE-001..026 exactly once");
}
for (const [gateId, gate] of Object.entries(document.gates)) {
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
}
}
const forbiddenWorkflowPatterns = [
/continue-on-error\s*:/,
/retention-days\s*:/,
/allow_failure\s*:/,
];
for (const pattern of forbiddenWorkflowPatterns) {
if (pattern.test(workflow)) {
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
}
}
for (const requiredToken of [
"merge_gate:",
"release_gate:",
"production_gate:",
"field_gate:",
"documentation_gate:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"actions/upload-artifact@v4",
"if: always()",
]) {
if (!workflow.includes(requiredToken)) {
failures.push(`workflow missing ${requiredToken}`);
}
}
const passingResults = Object.fromEntries(
expectedGateIds.map((gateId) => [gateId, /** @type {const} */ ("PASS")]),
);
const allPass = evaluatePromotionReadiness(passingResults);
const negativeFixtures = [];
for (const [readiness, gateIds] of Object.entries(PROMOTION_FORMULA)) {
const failedGate = gateIds[0];
const result = evaluatePromotionReadiness({
...passingResults,
[failedGate]: "FAIL",
});
const passed =
/** @type {Readonly<Record<string, boolean>>} */ (result)[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 report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
providerAdapter: document.providerAdapter,
gateCount: configuredGateIds.length,
noDowngrade: failures.every(
(failure) => !failure.includes("downgrade"),
),
durationStatus: document.retention.durationStatus,
negativeFixtures,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/ci-contract.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write("CI contract: 26 blocking gates and 4-tier graph PASS\n");
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "../src/application/policies/performance-budgets.js";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ??
process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json";
const input =
/** @type {{
+86
View File
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const gateId = process.argv
.slice(2)
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
const document =
/** @type {{
* gates: Record<string, {
* name: string,
* steps: Array<{
* script: string,
* args?: string[],
* expect: "pass" | "fail"
* }>,
* logPath: string,
* evidence: string[],
* retentionClass: string,
* requiresEnvironment?: string[]
* }>
* }} */ (JSON.parse(await readFile("config/ci/gates.json", "utf8")));
const gate = gateId ? document.gates[gateId] : undefined;
if (!gateId || !gate) {
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
process.exit(2);
}
const output = [];
let passed = true;
for (const variable of gate.requiresEnvironment ?? []) {
if (!process.env[variable]) {
output.push(`missing required environment: ${variable}`);
passed = false;
}
}
if (passed) {
for (const step of gate.steps) {
const result = spawnSync(
"corepack",
["pnpm", step.script, ...(step.args ?? [])],
{ encoding: "utf8", env: process.env },
);
output.push(
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
result.stdout,
result.stderr,
);
const exitedSuccessfully = result.status === 0;
const expectationMet =
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
if (!expectationMet) {
output.push(
`expectation failed: expected ${step.expect}, exit=${result.status}`,
);
passed = false;
break;
}
}
}
await mkdir(path.dirname(gate.logPath), { recursive: true });
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
if (passed) {
for (const evidencePath of gate.evidence) {
try {
await access(evidencePath);
} catch {
output.push(`missing evidence: ${evidencePath}`);
passed = false;
}
}
if (!passed) {
await writeFile(gate.logPath, `${output.filter(Boolean).join("\n")}\n`);
}
}
if (!passed) {
process.stderr.write(`${gateId} ${gate.name}: FAIL\n`);
process.exit(1);
}
process.stdout.write(
`${gateId} ${gate.name}: PASS (${gate.retentionClass})\n`,
);
@@ -0,0 +1,51 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
const ledger = JSON.parse(
await readFile("docs/architecture/review-ledger.json", "utf8"),
);
const results = [];
for (const [diagram, review] of Object.entries(ledger.reviews)) {
const content = await readFile(review.path, "utf8");
const hasDiagram = /```mermaid[\s\S]+```/.test(content);
const scorePass =
typeof ledger.reviewerThreshold === "number" &&
typeof review.score === "number" &&
review.score >= ledger.reviewerThreshold;
results.push({
diagram,
path: review.path,
hasDiagram,
reviewer: review.reviewer,
score: review.score,
scorePass,
passed:
hasDiagram &&
Boolean(review.reviewer) &&
scorePass &&
ledger.status === "PASS_SCOPED",
});
}
const passed = results.every((result) => result.passed);
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/documentation-review.json",
`${JSON.stringify(
{
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: ledger.status,
reviewerThreshold: ledger.reviewerThreshold,
results,
passed,
},
null,
2,
)}\n`,
);
if (!passed) {
process.stderr.write(
"Documentation readiness: FAIL_UNVERIFIED (reviewer threshold and signed reviews required)\n",
);
process.exit(1);
}
process.stdout.write("Documentation readiness: PASS_SCOPED\n");