feat: 기능 추가 과정중
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
|
||||
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
||||
|
||||
if (!pnpmCli) {
|
||||
throw new Error("check:architecture must run through the pnpm script");
|
||||
}
|
||||
|
||||
/** @param {string[]} arguments_ */
|
||||
function runPnpm(arguments_) {
|
||||
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
const production = runPnpm(
|
||||
[
|
||||
"exec",
|
||||
"depcruise",
|
||||
"src",
|
||||
"--config",
|
||||
".dependency-cruiser.cjs",
|
||||
"--output-type",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
"artifacts/quality/dependency-report.json",
|
||||
production.stdout || JSON.stringify({ summary: { errors: 1 } }),
|
||||
);
|
||||
|
||||
if (production.status !== 0) {
|
||||
process.stderr.write(
|
||||
production.error?.message ?? production.stderr ?? production.stdout ?? "failed",
|
||||
);
|
||||
process.exit(production.status ?? 1);
|
||||
}
|
||||
|
||||
const allowed = runPnpm(
|
||||
[
|
||||
"exec",
|
||||
"eslint",
|
||||
"tests/fixtures/architecture/allowed",
|
||||
"--no-ignore",
|
||||
"--max-warnings=0",
|
||||
],
|
||||
);
|
||||
|
||||
const forbidden = runPnpm(
|
||||
[
|
||||
"exec",
|
||||
"eslint",
|
||||
"tests/fixtures/architecture/forbidden",
|
||||
"--no-ignore",
|
||||
"--max-warnings=0",
|
||||
],
|
||||
);
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function fixtureFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = `${directory}/${entry.name}`;
|
||||
return entry.isDirectory()
|
||||
? fixtureFiles(target)
|
||||
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
const forbiddenResults = await Promise.all(
|
||||
(await fixtureFiles("tests/fixtures/architecture/forbidden")).map((file) => ({
|
||||
file,
|
||||
result: runPnpm([
|
||||
"exec",
|
||||
"eslint",
|
||||
file,
|
||||
"--no-ignore",
|
||||
"--max-warnings=0",
|
||||
]),
|
||||
})),
|
||||
);
|
||||
const acceptedForbidden = forbiddenResults.filter(
|
||||
({ result }) => result.status === 0,
|
||||
);
|
||||
|
||||
if (
|
||||
allowed.status !== 0 ||
|
||||
forbidden.status === 0 ||
|
||||
acceptedForbidden.length > 0
|
||||
) {
|
||||
process.stderr.write(allowed.stderr || allowed.stdout);
|
||||
process.stderr.write(forbidden.stderr || forbidden.stdout);
|
||||
for (const { file } of acceptedForbidden) {
|
||||
process.stderr.write(`Forbidden fixture was accepted: ${file}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Architecture fixtures: allowed PASS, ${forbiddenResults.length} forbidden rejected\n`,
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function requiredPnpmCli(): string {
|
||||
const executable = process.env.npm_execpath;
|
||||
if (!executable) {
|
||||
throw new Error(
|
||||
"check:browser-file-storage-boundaries must run through pnpm",
|
||||
);
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function runEslint(path: string) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
requiredPnpmCli(),
|
||||
"exec",
|
||||
"eslint",
|
||||
path,
|
||||
"--no-ignore",
|
||||
"--max-warnings=0",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
function runEslintSource(source: string, virtualPath: string) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
requiredPnpmCli(),
|
||||
"exec",
|
||||
"eslint",
|
||||
"--stdin",
|
||||
"--stdin-filename",
|
||||
virtualPath,
|
||||
"--no-ignore",
|
||||
"--max-warnings=0",
|
||||
],
|
||||
{ encoding: "utf8", input: source },
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = runEslint(
|
||||
"tests/fixtures/browser-file-storage-boundaries/allowed",
|
||||
);
|
||||
const forbiddenRoot =
|
||||
"tests/fixtures/browser-file-storage-boundaries/forbidden";
|
||||
const forbiddenFiles = (
|
||||
await readdir(forbiddenRoot, { withFileTypes: true })
|
||||
)
|
||||
.filter((entry) => entry.isFile() && /\.tsx?$/u.test(entry.name))
|
||||
.map((entry) => path.join(forbiddenRoot, entry.name));
|
||||
const forbiddenResults = forbiddenFiles.map((file) => ({
|
||||
file,
|
||||
result: runEslint(file),
|
||||
}));
|
||||
const acceptedForbidden = forbiddenResults.filter(
|
||||
({ result }) => result.status === 0,
|
||||
);
|
||||
const indexedDbBypassSource = await readFile(
|
||||
path.join(forbiddenRoot, "direct-indexeddb.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const objectUrlBypassSource = await readFile(
|
||||
path.join(forbiddenRoot, "object-url.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const blobBypassSource = await readFile(
|
||||
path.join(forbiddenRoot, "direct-blob.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const crossContextAdapterSource = await readFile(
|
||||
"tests/fixtures/browser-file-storage-boundaries/allowed/cross-context-host.ts",
|
||||
"utf8",
|
||||
);
|
||||
const browserStorageAdapterSource = await readFile(
|
||||
"tests/fixtures/browser-file-storage-boundaries/allowed/browser-storage-host.ts",
|
||||
"utf8",
|
||||
);
|
||||
const protectedSourceFixtureNames = [
|
||||
"aliased-globalthis.ts",
|
||||
"class-field-alias.ts",
|
||||
"constructor-root-escape.ts",
|
||||
"default-parameter-alias.ts",
|
||||
"direct-blob.ts",
|
||||
"dynamic-capability-key.ts",
|
||||
"global-object-container.ts",
|
||||
"identity-wrapped-global.ts",
|
||||
"instance-property-alias.ts",
|
||||
"property-descriptor-access.ts",
|
||||
] as const;
|
||||
const protectedSourceFixtures = await Promise.all(
|
||||
protectedSourceFixtureNames.map(async (file) => ({
|
||||
file,
|
||||
source: await readFile(path.join(forbiddenRoot, file), "utf8"),
|
||||
})),
|
||||
);
|
||||
const approvedAdapterResults = [
|
||||
{
|
||||
file: "src/adapters/storage/indexeddb/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
indexedDbBypassSource,
|
||||
"src/adapters/storage/indexeddb/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/adapters/cross-context-invalidation/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
crossContextAdapterSource,
|
||||
"src/adapters/cross-context-invalidation/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/adapters/storage/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
browserStorageAdapterSource,
|
||||
"src/adapters/storage/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/adapters/browser-transfer/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
blobBypassSource,
|
||||
"src/adapters/browser-transfer/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
];
|
||||
const rejectedApprovedAdapters = approvedAdapterResults.filter(
|
||||
({ result }) => result.status !== 0,
|
||||
);
|
||||
const misplacedAdapterResults = [
|
||||
{
|
||||
file: "src/adapters/http/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
indexedDbBypassSource,
|
||||
"src/adapters/http/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/features/reference-feature/adapters/boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
objectUrlBypassSource,
|
||||
"src/features/reference-feature/adapters/boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/adapters/http/cross-context-boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
crossContextAdapterSource,
|
||||
"src/adapters/http/cross-context-boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
file: "src/adapters/http/browser-storage-boundary-fixture.ts",
|
||||
result: runEslintSource(
|
||||
browserStorageAdapterSource,
|
||||
"src/adapters/http/browser-storage-boundary-fixture.ts",
|
||||
),
|
||||
},
|
||||
...protectedSourceFixtures.map(({ file, source }) => {
|
||||
const virtualPath = `src/presentation/${file}`;
|
||||
return {
|
||||
file: virtualPath,
|
||||
result: runEslintSource(source, virtualPath),
|
||||
};
|
||||
}),
|
||||
];
|
||||
const acceptedMisplacedAdapters = misplacedAdapterResults.filter(
|
||||
({ result }) => result.status === 0,
|
||||
);
|
||||
|
||||
if (
|
||||
allowed.status !== 0 ||
|
||||
rejectedApprovedAdapters.length > 0 ||
|
||||
forbiddenFiles.length === 0 ||
|
||||
acceptedForbidden.length > 0 ||
|
||||
acceptedMisplacedAdapters.length > 0
|
||||
) {
|
||||
process.stderr.write(allowed.stderr || allowed.stdout);
|
||||
for (const { file, result } of rejectedApprovedAdapters) {
|
||||
process.stderr.write(
|
||||
`${file}: owned browser capability adapter was rejected\n`,
|
||||
);
|
||||
process.stderr.write(result.stderr || result.stdout);
|
||||
}
|
||||
for (const { file, result } of acceptedForbidden) {
|
||||
process.stderr.write(
|
||||
`${file}: forbidden browser API fixture was accepted\n`,
|
||||
);
|
||||
process.stderr.write(result.stderr || result.stdout);
|
||||
}
|
||||
for (const { file, result } of acceptedMisplacedAdapters) {
|
||||
process.stderr.write(
|
||||
`${file}: native browser storage access outside its owned adapter was accepted\n`,
|
||||
);
|
||||
process.stderr.write(result.stderr || result.stdout);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Browser file/storage boundaries: PASS (owned adapter allowed, ${forbiddenFiles.length + misplacedAdapterResults.length} direct or misplaced native access cases rejected)\n`,
|
||||
);
|
||||
@@ -1,10 +1,17 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
||||
function requiredPnpmCli(): string {
|
||||
const executable = process.env.npm_execpath;
|
||||
if (!executable) {
|
||||
throw new Error("check:browser-security must run through the pnpm script");
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
/** @param {string[]} arguments_ */
|
||||
function runPnpm(arguments_) {
|
||||
const pnpmCli = requiredPnpmCli();
|
||||
|
||||
function runPnpm(arguments_: string[]) {
|
||||
return spawnSync(process.execPath, [pnpmCli, ...arguments_], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -1,23 +1,28 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
|
||||
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
|
||||
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts";
|
||||
import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts";
|
||||
|
||||
const report =
|
||||
/** @type {{
|
||||
* outputs: Array<{ path: string, gzipBytes: number }>,
|
||||
* [key: string]: unknown
|
||||
* }} */ (
|
||||
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
|
||||
);
|
||||
const viteManifest =
|
||||
/** @type {Record<string, { file: string, isEntry?: boolean, imports?: string[] }>} */ (
|
||||
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
|
||||
);
|
||||
const budgets =
|
||||
/** @type {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} */ (
|
||||
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).bundle
|
||||
);
|
||||
type BundleOutput = { path: string; gzipBytes: number };
|
||||
type BundleReport = { outputs: BundleOutput[]; [key: string]: unknown };
|
||||
type ViteManifest = Record<
|
||||
string,
|
||||
{ file: string; isEntry?: boolean; imports?: string[] }
|
||||
>;
|
||||
type BundleBudgets = {
|
||||
initialJsGzipBytes: number;
|
||||
lazyChunkGzipBytes: number;
|
||||
};
|
||||
|
||||
const report = JSON.parse(
|
||||
await readFile("artifacts/performance/bundle.json", "utf8"),
|
||||
) as BundleReport;
|
||||
const viteManifest = JSON.parse(
|
||||
await readFile("dist/.vite/manifest.json", "utf8"),
|
||||
) as ViteManifest;
|
||||
const budgets = JSON.parse(
|
||||
await readFile("config/performance/budgets.json", "utf8"),
|
||||
).bundle as BundleBudgets;
|
||||
|
||||
const outputByPath = new Map(
|
||||
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
|
||||
@@ -1,111 +0,0 @@
|
||||
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");
|
||||
@@ -0,0 +1,266 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
evaluatePromotionReadiness,
|
||||
PROMOTION_FORMULA,
|
||||
type GateResult,
|
||||
} from "../src/application/policies/promotion-readiness.ts";
|
||||
|
||||
type GateDefinition = Readonly<{
|
||||
steps?: readonly unknown[];
|
||||
evidence?: readonly string[];
|
||||
retentionClass?: string;
|
||||
}>;
|
||||
type CiContractDocument = Readonly<{
|
||||
providerAdapter: string;
|
||||
stages: Readonly<Record<string, Readonly<{ gates?: readonly string[] }>>>;
|
||||
gates: Readonly<Record<string, GateDefinition>>;
|
||||
retention: Readonly<{ durationStatus: unknown }>;
|
||||
}>;
|
||||
|
||||
const document = parseCiContractDocument(
|
||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
||||
);
|
||||
const workflow = await readFile(document.providerAdapter, "utf8");
|
||||
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
||||
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
||||
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
||||
const buildManifestGenerator = await readFile(
|
||||
"scripts/generate-build-manifest.ts",
|
||||
"utf8",
|
||||
);
|
||||
const failures: string[] = [];
|
||||
if (!/^\d+\.\d+\.\d+$/.test(nodeVersion)) {
|
||||
failures.push(".nvmrc must contain one exact Node.js semantic version");
|
||||
}
|
||||
const setupNodeCount =
|
||||
workflow.match(/uses:\s*actions\/setup-node@v4/g)?.length ?? 0;
|
||||
const nodeVersionFileCount =
|
||||
workflow.match(/node-version-file:\s*\.nvmrc/g)?.length ?? 0;
|
||||
if (setupNodeCount === 0 || nodeVersionFileCount !== setupNodeCount) {
|
||||
failures.push("every setup-node step must use node-version-file: .nvmrc");
|
||||
}
|
||||
if (/node-version\s*:/.test(workflow) || /NODE_VERSION\s*:/.test(workflow)) {
|
||||
failures.push("workflow must not override the exact .nvmrc Node.js pin");
|
||||
}
|
||||
const stageFormula: Readonly<Record<string, readonly string[]>> = {
|
||||
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 runbookGateEvidence = Object.freeze({
|
||||
"FE-GATE-016": "artifacts/runbooks/FE-RB-005/record.json",
|
||||
"FE-GATE-021": "artifacts/runbooks/FE-RB-001/record.json",
|
||||
"FE-GATE-022": "artifacts/runbooks/FE-RB-002/record.json",
|
||||
"FE-GATE-023": "artifacts/runbooks/FE-RB-003/record.json",
|
||||
"FE-GATE-024": "artifacts/runbooks/FE-RB-004/record.json",
|
||||
"FE-GATE-025": "artifacts/runbooks/FE-RB-005/record.json",
|
||||
});
|
||||
for (const [gateId, evidencePath] of Object.entries(runbookGateEvidence)) {
|
||||
const evidence = document.gates[gateId]?.evidence;
|
||||
if (
|
||||
!Array.isArray(evidence) ||
|
||||
evidence.length !== 1 ||
|
||||
evidence[0] !== evidencePath
|
||||
) {
|
||||
failures.push(`${gateId} runbook evidence path drift`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!drillRunner.includes(
|
||||
"const artifactDirectory = `artifacts/runbooks/${runbookId}`",
|
||||
) ||
|
||||
drillRunner.includes(
|
||||
"artifacts/runbooks/${runbookId}/${release.releaseId}",
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
"runbook evidence path must be stable while releaseId stays in the record",
|
||||
);
|
||||
}
|
||||
|
||||
const forbiddenWorkflowPatterns = [
|
||||
/continue-on-error\s*:/,
|
||||
/retention-days\s*:/,
|
||||
/timeout-minutes\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()",
|
||||
"permissions:",
|
||||
"contents: read",
|
||||
'CI: "true"',
|
||||
'VITE_BUILD_ID: "gitea-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
||||
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
||||
]) {
|
||||
if (!workflow.includes(requiredToken)) {
|
||||
failures.push(`workflow missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"ciCheckoutIdentityFailures",
|
||||
"ciBuildEnvironmentFailures",
|
||||
"SOURCE_DATE_EPOCH",
|
||||
'"--format=%H%n%ct"',
|
||||
"env: gateEnvironment",
|
||||
]) {
|
||||
if (!gateRunner.includes(requiredToken)) {
|
||||
failures.push(`CI gate runner missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"assertCiBuildEnvironment(process.env)",
|
||||
"releaseId",
|
||||
"sourceDateEpoch",
|
||||
]) {
|
||||
if (!buildManifestGenerator.includes(requiredToken)) {
|
||||
failures.push(`build manifest generator missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
const passingResults: Record<string, GateResult> = {};
|
||||
for (const gateId of expectedGateIds) passingResults[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 gateIds = PROMOTION_FORMULA[readiness];
|
||||
const failedGate = gateIds[0];
|
||||
if (!failedGate) throw new Error(`${readiness} has no configured gates`);
|
||||
const result = evaluatePromotionReadiness({
|
||||
...passingResults,
|
||||
[failedGate]: "FAIL",
|
||||
});
|
||||
const passed = 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,
|
||||
nodeVersion,
|
||||
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");
|
||||
|
||||
function parseCiContractDocument(value: unknown): CiContractDocument {
|
||||
if (!isRecord(value)) throw new TypeError("CI gate config must be an object");
|
||||
if (
|
||||
typeof value.providerAdapter !== "string" ||
|
||||
!isRecord(value.stages) ||
|
||||
!isRecord(value.gates) ||
|
||||
!isRecord(value.retention)
|
||||
) {
|
||||
throw new TypeError("CI gate config is missing required registries");
|
||||
}
|
||||
const stages: Record<string, { gates?: readonly string[] }> = {};
|
||||
for (const [stage, candidate] of Object.entries(value.stages)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI stage: ${stage}`);
|
||||
if (
|
||||
candidate.gates !== undefined &&
|
||||
(!Array.isArray(candidate.gates) ||
|
||||
!candidate.gates.every((gate) => typeof gate === "string"))
|
||||
) {
|
||||
throw new TypeError(`Invalid gate list for CI stage: ${stage}`);
|
||||
}
|
||||
stages[stage] = {
|
||||
gates: candidate.gates as readonly string[] | undefined,
|
||||
};
|
||||
}
|
||||
const gates: Record<string, GateDefinition> = {};
|
||||
for (const [gateId, candidate] of Object.entries(value.gates)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
|
||||
if (
|
||||
candidate.evidence !== undefined &&
|
||||
(!Array.isArray(candidate.evidence) ||
|
||||
!candidate.evidence.every((path) => typeof path === "string"))
|
||||
) {
|
||||
throw new TypeError(`Invalid evidence list for CI gate: ${gateId}`);
|
||||
}
|
||||
gates[gateId] = {
|
||||
steps: Array.isArray(candidate.steps) ? candidate.steps : undefined,
|
||||
evidence: candidate.evidence as readonly string[] | undefined,
|
||||
retentionClass:
|
||||
typeof candidate.retentionClass === "string"
|
||||
? candidate.retentionClass
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
providerAdapter: value.providerAdapter,
|
||||
stages,
|
||||
gates,
|
||||
retention: { durationStatus: value.retention.durationStatus },
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js";
|
||||
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
|
||||
|
||||
type CompatibilitySchema = Readonly<{
|
||||
required?: readonly string[];
|
||||
properties?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
type CompatibilityFixture = Readonly<{
|
||||
before: CompatibilitySchema;
|
||||
after: CompatibilitySchema;
|
||||
}>;
|
||||
|
||||
type CompatibilityFixtures = Readonly<{
|
||||
families: Readonly<
|
||||
Record<string, Readonly<Record<"additive" | "breaking", CompatibilityFixture>>>
|
||||
>;
|
||||
}>;
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
await readFile("config/compatibility/fixtures.json", "utf8"),
|
||||
);
|
||||
) as CompatibilityFixtures;
|
||||
const results = [];
|
||||
|
||||
for (const [family, cases] of Object.entries(fixtures.families)) {
|
||||
for (const expected of ["additive", "breaking"]) {
|
||||
for (const expected of ["additive", "breaking"] as const) {
|
||||
const fixture = cases[expected];
|
||||
const actual = classifyObjectSchemaChange(fixture.before, fixture.after);
|
||||
results.push({ family, expected, actual, passed: actual === expected });
|
||||
@@ -1,18 +1,16 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
|
||||
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
|
||||
|
||||
const fixtureMode = process.argv.includes("--fixture");
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
const tokenFiles = {
|
||||
primitive: "src/presentation/design-system/tokens/primitive.css",
|
||||
semantic: "src/presentation/design-system/tokens/semantic.css",
|
||||
component: "src/presentation/design-system/tokens/component.css",
|
||||
};
|
||||
/** @type {Array<readonly [string, string, readonly string[]]>} */
|
||||
const tokenLayers = [
|
||||
const tokenLayers: Array<readonly [string, string, readonly string[]]> = [
|
||||
["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
|
||||
["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
|
||||
["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
|
||||
@@ -75,13 +73,12 @@ if (!componentSource.includes("@media (forced-colors: active)")) {
|
||||
failures.push("forced-colors token fallback is missing");
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function listSourceFiles(directory) {
|
||||
const result = [];
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
|
||||
else if (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target);
|
||||
else if (/\.(?:ts|tsx|mts|cts)$/.test(entry.name)) result.push(target);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -103,7 +100,7 @@ for (const file of sources) {
|
||||
}
|
||||
if (
|
||||
!file.includes("src/presentation/design-system/") &&
|
||||
/presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source)
|
||||
/presentation\/design-system\/(?!index(?:\.tsx?)?["'])/.test(source)
|
||||
) {
|
||||
failures.push(`design-system deep import in ${file}`);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
|
||||
import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
|
||||
import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.js";
|
||||
import { TELEMETRY_REGISTRY } from "../src/contracts/telemetry.ts";
|
||||
|
||||
const fixtureMode = process.argv.includes("--fixture");
|
||||
const failures = [];
|
||||
const extensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
|
||||
const failures: string[] = [];
|
||||
const extensions = /\.(?:ts|tsx|mts|cts)$/;
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesBelow(directory) {
|
||||
const result = [];
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) result.push(...(await filesBelow(target)));
|
||||
@@ -20,28 +18,26 @@ async function filesBelow(directory) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const telemetryProducerFiles =
|
||||
/** @type {Readonly<Record<string, string>>} */ ({
|
||||
const telemetryProducerFiles: Readonly<Record<string, string>> = {
|
||||
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
|
||||
"api.request.failed": "src/adapters/http/client.js",
|
||||
"api.request.failed": "src/adapters/http/client.ts",
|
||||
"ui.render.failed": "src/application/create-application.ts",
|
||||
"release.mismatch.detected": "src/application/create-application.ts",
|
||||
"telemetry.delivery.dropped":
|
||||
"src/adapters/telemetry/best-effort-telemetry.js",
|
||||
});
|
||||
const diagnosticProducerFiles =
|
||||
/** @type {Readonly<Record<string, string>>} */ ({
|
||||
"src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
};
|
||||
const diagnosticProducerFiles: Readonly<Record<string, string>> = {
|
||||
"app.boot.failed": "src/adapters/diagnostics/bounded-diagnostics.ts",
|
||||
"http.request.completed": "src/adapters/http/client.js",
|
||||
"http.request.completed": "src/adapters/http/client.ts",
|
||||
"cache.operation.failed":
|
||||
"src/adapters/query-cache/tanstack-query-cache.js",
|
||||
"src/adapters/query-cache/tanstack-query-cache.ts",
|
||||
"storage.operation.failed":
|
||||
"src/adapters/storage/browser-storage-adapter.js",
|
||||
"src/adapters/storage/browser-storage-adapter.ts",
|
||||
"route.changed": "src/application/create-application.ts",
|
||||
"ui.render.failed": "src/application/create-application.ts",
|
||||
"release.mismatch.detected": "src/application/create-application.ts",
|
||||
"telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.js",
|
||||
});
|
||||
"telemetry.delivery.dropped": "src/bootstrap/runtime-adapters.ts",
|
||||
};
|
||||
|
||||
if (!fixtureMode) {
|
||||
for (const eventName of Object.keys(TELEMETRY_REGISTRY)) {
|
||||
@@ -75,7 +71,7 @@ const sensitiveContext =
|
||||
/\b(?:authorization|cookie|access_token|refresh_token|request_body|response_body|raw_url|query_string|email|user_name)\b/i;
|
||||
for (const file of sources) {
|
||||
const source = await readFile(file, "utf8");
|
||||
if (file.includes("contracts/telemetry.js")) continue;
|
||||
if (file.includes("contracts/telemetry.ts")) continue;
|
||||
if (source.includes("console.")) {
|
||||
failures.push(`direct console diagnostics bypass in ${file}`);
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
|
||||
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
|
||||
|
||||
const fixtureMode = process.argv.includes("--fixture");
|
||||
const failures = [];
|
||||
const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
|
||||
const failures: string[] = [];
|
||||
const sourceExtensions = /\.(?:ts|tsx|mts|cts)$/;
|
||||
const koreanLiteral = /[가-힣]/;
|
||||
const rawFailureRender =
|
||||
/(?<!\$)\{\s*(?:failure|error|response|backend)(?:\?\.|\.)[\w?.]*message\s*\}/;
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function listSourceFiles(directory) {
|
||||
const result = [];
|
||||
async function listSourceFiles(directory: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
|
||||
@@ -22,8 +20,7 @@ async function listSourceFiles(directory) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @param {string} template */
|
||||
function placeholders(template) {
|
||||
function placeholders(template: string): string[] {
|
||||
return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
|
||||
.map((match) => match[1])
|
||||
.sort();
|
||||
@@ -32,10 +29,8 @@ function placeholders(template) {
|
||||
if (!fixtureMode) {
|
||||
const canonicalKeys = Object.keys(KO_MESSAGES).sort();
|
||||
for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) {
|
||||
const readableCatalog =
|
||||
/** @type {Readonly<Record<string, string>>} */ (catalog);
|
||||
const canonicalCatalog =
|
||||
/** @type {Readonly<Record<string, string>>} */ (KO_MESSAGES);
|
||||
const readableCatalog = catalog as Readonly<Record<string, string>>;
|
||||
const canonicalCatalog = KO_MESSAGES as Readonly<Record<string, string>>;
|
||||
const keys = Object.keys(catalog).sort();
|
||||
if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) {
|
||||
failures.push(`${locale} catalog keys do not match ko-KR`);
|
||||
@@ -1,93 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.mjs";
|
||||
|
||||
const catalog = JSON.parse(
|
||||
await readFile("config/recipes/frontend-capability-recipes.json", "utf8"),
|
||||
);
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
|
||||
|
||||
const cleanupCatalog = structuredClone(catalog);
|
||||
cleanupCatalog.recipes.find(
|
||||
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "realtime",
|
||||
).lifecycleMethods = [];
|
||||
|
||||
const dependencyCatalog = structuredClone(catalog);
|
||||
dependencyCatalog.productionRuntimeDependencies = ["zustand"];
|
||||
|
||||
const workflowCatalog = structuredClone(catalog);
|
||||
workflowCatalog.recipes.find(
|
||||
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "client-workflow",
|
||||
).serverStatePolicy = "copied-server-state";
|
||||
|
||||
const sourceViolations = await scanOptionalRecipeSources(
|
||||
"tests/fixtures/optional-recipes/forbidden",
|
||||
{ scanProductionBoundary: false },
|
||||
);
|
||||
const productionViolations = await scanOptionalRecipeSources(
|
||||
"tests/fixtures/optional-recipes/forbidden/production-import",
|
||||
{ scanProductionBoundary: true },
|
||||
);
|
||||
sourceViolations.push(...productionViolations);
|
||||
const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId));
|
||||
const results = [
|
||||
{
|
||||
id: "cleanup-omission",
|
||||
passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some(
|
||||
(violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "unselected-runtime-dependency",
|
||||
passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes(
|
||||
"UNSELECTED_RUNTIME_DEPENDENCY",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "server-state-policy",
|
||||
passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes(
|
||||
"client-workflow:SERVER_STATE_DUPLICATION_POLICY",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "vendor-direct-import",
|
||||
passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"),
|
||||
},
|
||||
{
|
||||
id: "credential-leak",
|
||||
passed: ruleIds.has("CREDENTIAL_LEAK_PATH"),
|
||||
},
|
||||
{
|
||||
id: "server-state-source-duplication",
|
||||
passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"),
|
||||
},
|
||||
{
|
||||
id: "production-imports-recipe",
|
||||
passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"),
|
||||
},
|
||||
];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
results,
|
||||
passed: results.every(({ passed }) => passed),
|
||||
};
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/optional-recipe-fixtures.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
if (!report.passed) {
|
||||
process.stderr.write(
|
||||
`Optional recipe negative fixtures failed: ${results
|
||||
.filter(({ passed }) => !passed)
|
||||
.map(({ id }) => id)
|
||||
.join(", ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`,
|
||||
);
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
|
||||
import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts";
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
|
||||
type ReferenceRuntimeRecipe = Readonly<{
|
||||
id: string;
|
||||
referenceRuntime: Readonly<{
|
||||
sourceRoots: readonly string[];
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const catalog = JSON.parse(
|
||||
await readFile("config/recipes/frontend-capability-recipes.json", "utf8"),
|
||||
);
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const catalogRecipes: unknown[] = Array.isArray(catalog.recipes)
|
||||
? (catalog.recipes as unknown[])
|
||||
: [];
|
||||
const referenceRuntimeRecipes = catalogRecipes.filter(
|
||||
(recipe: unknown): recipe is ReferenceRuntimeRecipe =>
|
||||
Boolean(
|
||||
recipe &&
|
||||
typeof recipe === "object" &&
|
||||
typeof (recipe as Record<string, unknown>).id === "string" &&
|
||||
(recipe as Record<string, unknown>).referenceRuntime &&
|
||||
typeof (recipe as Record<string, unknown>).referenceRuntime ===
|
||||
"object" &&
|
||||
Array.isArray(
|
||||
(
|
||||
(recipe as Record<string, unknown>)
|
||||
.referenceRuntime as Record<string, unknown>
|
||||
).sourceRoots,
|
||||
),
|
||||
),
|
||||
);
|
||||
const bundleBudgetFixtures = await Promise.all(
|
||||
referenceRuntimeRecipes.map(async (recipe) => {
|
||||
const measurement = await measureOptionalRecipeBundle({
|
||||
recipeId: recipe.id,
|
||||
sourceRoots: recipe.referenceRuntime.sourceRoots,
|
||||
bundleBudgetGzipBytes: 1,
|
||||
});
|
||||
return Object.freeze({
|
||||
recipeId: recipe.id,
|
||||
gzipBytes: measurement.gzipBytes,
|
||||
fixtureBudgetGzipBytes: measurement.bundleBudgetGzipBytes,
|
||||
rejected: !measurement.passed,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const cleanupCatalog = structuredClone(catalog);
|
||||
cleanupCatalog.recipes.find(
|
||||
(recipe: { id: string }) => recipe.id === "realtime",
|
||||
).lifecycleMethods = [];
|
||||
|
||||
const dependencyCatalog = structuredClone(catalog);
|
||||
dependencyCatalog.productionRuntimeDependencies = ["zustand"];
|
||||
|
||||
const workflowCatalog = structuredClone(catalog);
|
||||
workflowCatalog.recipes.find(
|
||||
(recipe: { id: string }) => recipe.id === "client-workflow",
|
||||
).serverStatePolicy = "copied-server-state";
|
||||
|
||||
const sourceViolations = await scanOptionalRecipeSources(
|
||||
"tests/fixtures/optional-recipes/forbidden",
|
||||
{ scanProductionBoundary: false },
|
||||
);
|
||||
const productionViolations = await scanOptionalRecipeSources(
|
||||
"tests/fixtures/optional-recipes/forbidden/production-import",
|
||||
{ scanProductionBoundary: true },
|
||||
);
|
||||
const runtimeCompositionViolations = await scanOptionalRecipeSources(
|
||||
"tests/fixtures/optional-recipes/forbidden/runtime-composition",
|
||||
{ scanProductionBoundary: true },
|
||||
);
|
||||
const bundleFixtureRoot = ".tmp/optional-recipe-runtime-bundle";
|
||||
await rm(bundleFixtureRoot, { recursive: true, force: true });
|
||||
await mkdir(`${bundleFixtureRoot}/assets`, { recursive: true });
|
||||
await writeFile(
|
||||
`${bundleFixtureRoot}/assets/runtime.js`,
|
||||
'throw new TypeError("OPFS runtime policy is invalid.");\n',
|
||||
);
|
||||
const bundleViolations = await scanProductionBundle(bundleFixtureRoot);
|
||||
await rm(bundleFixtureRoot, { recursive: true, force: true });
|
||||
const inventoryFixtureRoot =
|
||||
".tmp/optional-recipe-runtime-module-inventory";
|
||||
await rm(inventoryFixtureRoot, { recursive: true, force: true });
|
||||
await mkdir(`${inventoryFixtureRoot}/.vite`, { recursive: true });
|
||||
await writeFile(`${inventoryFixtureRoot}/.vite/manifest.json`, "{}\n");
|
||||
await writeFile(
|
||||
`${inventoryFixtureRoot}/.vite/module-inventory.json`,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
chunks: [
|
||||
{
|
||||
fileName: "assets/application.js",
|
||||
modules: [
|
||||
"src/adapters/browser-files/browser-file-vault.ts",
|
||||
],
|
||||
},
|
||||
],
|
||||
})}\n`,
|
||||
);
|
||||
const inventoryViolations =
|
||||
await scanProductionBundle(inventoryFixtureRoot);
|
||||
await rm(inventoryFixtureRoot, { recursive: true, force: true });
|
||||
sourceViolations.push(...productionViolations);
|
||||
sourceViolations.push(...runtimeCompositionViolations);
|
||||
const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId));
|
||||
const results = [
|
||||
{
|
||||
id: "cleanup-omission",
|
||||
passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some(
|
||||
(violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "unselected-runtime-dependency",
|
||||
passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes(
|
||||
"UNSELECTED_RUNTIME_DEPENDENCY",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "server-state-policy",
|
||||
passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes(
|
||||
"client-workflow:SERVER_STATE_DUPLICATION_POLICY",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "vendor-direct-import",
|
||||
passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"),
|
||||
},
|
||||
{
|
||||
id: "credential-leak",
|
||||
passed: ruleIds.has("CREDENTIAL_LEAK_PATH"),
|
||||
},
|
||||
{
|
||||
id: "server-state-source-duplication",
|
||||
passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"),
|
||||
},
|
||||
{
|
||||
id: "production-imports-recipe",
|
||||
passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"),
|
||||
},
|
||||
{
|
||||
id: "reference-runtime-not-composed",
|
||||
passed: ruleIds.has("REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION"),
|
||||
},
|
||||
{
|
||||
id: "reference-runtime-not-bundled",
|
||||
passed: bundleViolations.some((file) => file.endsWith("runtime.js")),
|
||||
},
|
||||
{
|
||||
id: "reference-runtime-module-not-bundled",
|
||||
passed: inventoryViolations.some((violation) =>
|
||||
violation.includes(
|
||||
"src/adapters/browser-files/browser-file-vault.ts",
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "reference-runtime-bundle-over-budget",
|
||||
passed:
|
||||
bundleBudgetFixtures.length === referenceRuntimeRecipes.length &&
|
||||
bundleBudgetFixtures.length > 0 &&
|
||||
bundleBudgetFixtures.every(
|
||||
(fixture) =>
|
||||
fixture.gzipBytes > fixture.fixtureBudgetGzipBytes &&
|
||||
fixture.rejected,
|
||||
),
|
||||
},
|
||||
];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
results,
|
||||
bundleBudgetFixtures,
|
||||
passed: results.every(({ passed }) => passed),
|
||||
};
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/optional-recipe-fixtures.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
if (!report.passed) {
|
||||
process.stderr.write(
|
||||
`Optional recipe negative fixtures failed: ${results
|
||||
.filter(({ passed }) => !passed)
|
||||
.map(({ id }) => id)
|
||||
.join(", ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`,
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
|
||||
const recipeRoot = resolve("recipes");
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
|
||||
if (!(await containsTypeScriptSource(recipeRoot))) {
|
||||
process.stdout.write(
|
||||
"Optional recipe typecheck: SKIP (no recipe TypeScript sources installed)\n",
|
||||
);
|
||||
} else {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[pnpmCli, "exec", "tsc", "--project", "tsconfig.recipes.json"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
process.exitCode = result.status ?? 1;
|
||||
}
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to typecheck optional recipes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function containsTypeScriptSource(directory: string): Promise<boolean> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const target = resolve(directory, entry.name);
|
||||
if (entry.isDirectory() && (await containsTypeScriptSource(target))) {
|
||||
return true;
|
||||
}
|
||||
if (entry.isFile() && [".ts", ".tsx", ".mts", ".cts"].includes(extname(entry.name))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.mjs";
|
||||
|
||||
/** @param {string} name @param {string} fallback */
|
||||
const argument = (name, fallback) => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? fallback : process.argv[index + 1];
|
||||
};
|
||||
|
||||
const catalogPath = argument(
|
||||
"--catalog",
|
||||
"config/recipes/frontend-capability-recipes.json",
|
||||
);
|
||||
const sourceRoot = argument("--source-root", "src");
|
||||
const distRoot = argument("--dist-root", "dist");
|
||||
const artifactPath = argument(
|
||||
"--artifact",
|
||||
"artifacts/quality/optional-recipes.json",
|
||||
);
|
||||
const requireDist = process.argv.includes("--require-dist");
|
||||
|
||||
const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
|
||||
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
|
||||
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const bundleViolations = await scanProductionBundle(distRoot);
|
||||
const violations = [
|
||||
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
|
||||
...sourceViolations,
|
||||
...bundleViolations.map((path) => ({
|
||||
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
|
||||
path,
|
||||
})),
|
||||
...(requireDist && !bundlePresent
|
||||
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
|
||||
: []),
|
||||
];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
decisionId: "VD-10",
|
||||
selectedCapabilities: [],
|
||||
recipeCount: Array.isArray(catalog.recipes) ? catalog.recipes.length : 0,
|
||||
productionRuntimeDependencies:
|
||||
catalog.productionRuntimeDependencies ?? null,
|
||||
bundleStatus: bundlePresent
|
||||
? bundleViolations.length === 0
|
||||
? "PASS"
|
||||
: "FAIL"
|
||||
: "NOT_BUILT",
|
||||
violations,
|
||||
passed: violations.length === 0,
|
||||
};
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(
|
||||
`Optional recipe contract failed:\n${violations
|
||||
.map((violation) => `${violation.ruleId}: ${violation.path}`)
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Optional recipes: PASS (${report.recipeCount} recipe-only capabilities, bundle=${report.bundleStatus})\n`,
|
||||
);
|
||||
@@ -0,0 +1,246 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
measureOptionalRecipeBundle,
|
||||
type OptionalRecipeBundleMeasurement,
|
||||
} from "./lib/optional-recipe-bundle.ts";
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
|
||||
type GateViolation = Readonly<{
|
||||
ruleId: string;
|
||||
path: string;
|
||||
detail?: string;
|
||||
}>;
|
||||
|
||||
type ReferenceRuntimeBundleInput = Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
bundleBudgetGzipBytes: number;
|
||||
}>;
|
||||
|
||||
const argument = (name: string, fallback: string): string => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? fallback : (process.argv[index + 1] ?? fallback);
|
||||
};
|
||||
|
||||
const catalogPath = argument(
|
||||
"--catalog",
|
||||
"config/recipes/frontend-capability-recipes.json",
|
||||
);
|
||||
const sourceRoot = argument("--source-root", "src");
|
||||
const distRoot = argument("--dist-root", "dist");
|
||||
const artifactPath = argument(
|
||||
"--artifact",
|
||||
"artifacts/quality/optional-recipes.json",
|
||||
);
|
||||
const requireDist = process.argv.includes("--require-dist");
|
||||
|
||||
const catalog = JSON.parse(
|
||||
await readFile(catalogPath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : [];
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
|
||||
const runtimeSourceViolations = (
|
||||
await Promise.all(
|
||||
recipes.flatMap((recipe: unknown) => {
|
||||
if (!recipe || typeof recipe !== "object") return [];
|
||||
const row = recipe as Record<string, unknown>;
|
||||
const runtime = row.referenceRuntime;
|
||||
if (!runtime || typeof runtime !== "object") return [];
|
||||
const sourceRoots = (runtime as Record<string, unknown>).sourceRoots;
|
||||
if (!Array.isArray(sourceRoots)) return [];
|
||||
return sourceRoots
|
||||
.filter(
|
||||
(sourceRoot): sourceRoot is string =>
|
||||
typeof sourceRoot === "string",
|
||||
)
|
||||
.map(async (sourceRoot) => ({
|
||||
sourceRoot,
|
||||
exists: await stat(sourceRoot)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
}));
|
||||
}),
|
||||
)
|
||||
).filter(({ exists }) => !exists);
|
||||
const {
|
||||
inputs: referenceRuntimeBundleInputs,
|
||||
violations: referenceRuntimeBundleConfigurationViolations,
|
||||
} = referenceRuntimeBundleInputsFrom(recipes);
|
||||
const referenceRuntimeBundles: OptionalRecipeBundleMeasurement[] = [];
|
||||
const referenceRuntimeBundleMeasurementViolations: GateViolation[] = [];
|
||||
for (const bundleInput of referenceRuntimeBundleInputs) {
|
||||
try {
|
||||
const measurement = await measureOptionalRecipeBundle(bundleInput);
|
||||
referenceRuntimeBundles.push(measurement);
|
||||
if (!measurement.passed) {
|
||||
referenceRuntimeBundleMeasurementViolations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_BUDGET_EXCEEDED",
|
||||
path: measurement.recipeId,
|
||||
detail:
|
||||
`${measurement.gzipBytes} > ` +
|
||||
`${measurement.bundleBudgetGzipBytes} gzip bytes`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
referenceRuntimeBundleMeasurementViolations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_MEASUREMENT_FAILED",
|
||||
path: bundleInput.recipeId,
|
||||
detail: safeErrorSummary(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
referenceRuntimeBundles.sort((left, right) =>
|
||||
compareText(left.recipeId, right.recipeId),
|
||||
);
|
||||
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
|
||||
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const bundleViolations = await scanProductionBundle(distRoot);
|
||||
const violations: GateViolation[] = [
|
||||
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
|
||||
...runtimeSourceViolations.map(({ sourceRoot }) => ({
|
||||
ruleId: "REFERENCE_RUNTIME_SOURCE_MISSING",
|
||||
path: sourceRoot,
|
||||
})),
|
||||
...sourceViolations,
|
||||
...bundleViolations.map((path) => ({
|
||||
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
|
||||
path,
|
||||
})),
|
||||
...(requireDist && !bundlePresent
|
||||
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
|
||||
: []),
|
||||
...referenceRuntimeBundleConfigurationViolations,
|
||||
...referenceRuntimeBundleMeasurementViolations,
|
||||
];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
decisionId: "VD-10",
|
||||
selectedCapabilities: [],
|
||||
referenceRuntimes: recipes
|
||||
.filter(
|
||||
(recipe: unknown): recipe is Record<string, unknown> =>
|
||||
Boolean(
|
||||
recipe &&
|
||||
typeof recipe === "object" &&
|
||||
(recipe as Record<string, unknown>).referenceRuntime,
|
||||
),
|
||||
)
|
||||
.map((recipe: Record<string, unknown>) => ({
|
||||
id: recipe.id,
|
||||
referenceRuntime: recipe.referenceRuntime,
|
||||
})),
|
||||
recipeCount: recipes.length,
|
||||
productionRuntimeDependencies:
|
||||
catalog.productionRuntimeDependencies ?? null,
|
||||
referenceRuntimeBundleBudgets: referenceRuntimeBundles,
|
||||
bundleStatus: bundlePresent
|
||||
? bundleViolations.length === 0
|
||||
? "PASS"
|
||||
: "FAIL"
|
||||
: "NOT_BUILT",
|
||||
violations,
|
||||
passed: violations.length === 0,
|
||||
};
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(
|
||||
`Optional recipe contract failed:\n${violations
|
||||
.map(
|
||||
(violation) =>
|
||||
`${violation.ruleId}: ${violation.path}` +
|
||||
(violation.detail ? ` (${violation.detail})` : ""),
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const referenceRuntimeBudgetStatus =
|
||||
referenceRuntimeBundles.length > 0
|
||||
? `, budgets=${referenceRuntimeBundles
|
||||
.map(
|
||||
(measurement) =>
|
||||
`${measurement.recipeId}:${measurement.gzipBytes}/` +
|
||||
measurement.bundleBudgetGzipBytes,
|
||||
)
|
||||
.join(",")} gzip bytes`
|
||||
: "";
|
||||
process.stdout.write(
|
||||
`Optional recipes: PASS (${report.recipeCount} optional capabilities, ${report.referenceRuntimes.length} uncomposed reference runtimes, bundle=${report.bundleStatus}${referenceRuntimeBudgetStatus})\n`,
|
||||
);
|
||||
|
||||
function referenceRuntimeBundleInputsFrom(
|
||||
recipeRows: readonly unknown[],
|
||||
): Readonly<{
|
||||
inputs: readonly ReferenceRuntimeBundleInput[];
|
||||
violations: readonly GateViolation[];
|
||||
}> {
|
||||
const inputs: ReferenceRuntimeBundleInput[] = [];
|
||||
const violations: GateViolation[] = [];
|
||||
for (const recipe of recipeRows) {
|
||||
if (!recipe || typeof recipe !== "object") continue;
|
||||
const row = recipe as Record<string, unknown>;
|
||||
if (row.referenceRuntime === undefined) continue;
|
||||
const recipeId =
|
||||
typeof row.id === "string" ? row.id : "unknown-reference-runtime";
|
||||
const runtime = row.referenceRuntime;
|
||||
const sourceRoots =
|
||||
runtime && typeof runtime === "object"
|
||||
? (runtime as Record<string, unknown>).sourceRoots
|
||||
: null;
|
||||
if (
|
||||
!Array.isArray(sourceRoots) ||
|
||||
!sourceRoots.every(
|
||||
(sourceRoot): sourceRoot is string =>
|
||||
typeof sourceRoot === "string",
|
||||
) ||
|
||||
!Number.isSafeInteger(row.bundleBudgetGzipBytes) ||
|
||||
(row.bundleBudgetGzipBytes as number) < 1
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_CONFIGURATION_INVALID",
|
||||
path: recipeId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
inputs.push(
|
||||
Object.freeze({
|
||||
recipeId,
|
||||
sourceRoots: Object.freeze([...sourceRoots]),
|
||||
bundleBudgetGzipBytes: row.bundleBudgetGzipBytes as number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
inputs: Object.freeze(
|
||||
inputs.sort((left, right) =>
|
||||
compareText(left.recipeId, right.recipeId),
|
||||
),
|
||||
),
|
||||
violations: Object.freeze(violations),
|
||||
});
|
||||
}
|
||||
|
||||
function safeErrorSummary(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "unknown measurement failure";
|
||||
return message
|
||||
.split(/\r?\n/, 1)[0]
|
||||
?.replaceAll(process.cwd(), ".")
|
||||
.slice(0, 512) ?? "unknown measurement failure";
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts";
|
||||
|
||||
const sourceRoot = argument("--source-root") ?? "src";
|
||||
const artifact =
|
||||
argument("--artifact") ??
|
||||
"artifacts/quality/realtime-boundaries.json";
|
||||
const violations = await scanRealtimeBoundaries(sourceRoot);
|
||||
const report = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
sourceRoot,
|
||||
violations,
|
||||
passed: violations.length === 0,
|
||||
});
|
||||
|
||||
await mkdir(path.dirname(artifact), { recursive: true });
|
||||
await writeFile(
|
||||
artifact,
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const violation of violations) {
|
||||
process.stderr.write(
|
||||
`${violation.file}:${violation.line} ${violation.ruleId}\n`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Realtime boundaries: PASS (${sourceRoot})\n`,
|
||||
);
|
||||
|
||||
function argument(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const checker = "scripts/check-realtime-boundaries.ts";
|
||||
const allowed = run(
|
||||
"tests/fixtures/realtime-boundaries/allowed",
|
||||
".tmp/realtime-boundaries-allowed.json",
|
||||
);
|
||||
const forbidden = run(
|
||||
"tests/fixtures/realtime-boundaries/forbidden",
|
||||
".tmp/realtime-boundaries-forbidden.json",
|
||||
);
|
||||
const forbiddenOutput = `${forbidden.stdout}${forbidden.stderr}`;
|
||||
const expectedRules = [
|
||||
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
|
||||
"PRESENTATION_INTERVAL_OWNER",
|
||||
"UNSELECTED_REALTIME_RUNTIME_COMPOSED",
|
||||
] as const;
|
||||
|
||||
if (
|
||||
allowed.status !== 0 ||
|
||||
forbidden.status === 0 ||
|
||||
!expectedRules.every((rule) => forbiddenOutput.includes(rule))
|
||||
) {
|
||||
process.stderr.write(allowed.stdout);
|
||||
process.stderr.write(allowed.stderr);
|
||||
process.stderr.write(forbidden.stdout);
|
||||
process.stderr.write(forbidden.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`Realtime boundary fixtures: PASS (${expectedRules.length} forbidden rules rejected)\n`,
|
||||
);
|
||||
|
||||
function run(sourceRoot: string, artifact: string) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
checker,
|
||||
"--source-root",
|
||||
sourceRoot,
|
||||
"--artifact",
|
||||
artifact,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,48 @@ import {
|
||||
registrySnapshotDigest,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.mjs";
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
|
||||
/** @param {string} name @param {string | undefined} fallback */
|
||||
function argumentValue(name, fallback) {
|
||||
type RegistryRow = Record<string, unknown>;
|
||||
type RegistryRows = Record<string, RegistryRow>;
|
||||
type RegistryReference = Readonly<{
|
||||
registryId: string;
|
||||
field: string;
|
||||
targetField: string;
|
||||
}>;
|
||||
type RegistryConsumer = Readonly<{ path: string; token: string }>;
|
||||
type RegistrySpecification = Readonly<{
|
||||
registryId: string;
|
||||
owner: string;
|
||||
path: string;
|
||||
exportName: string;
|
||||
declaredRows?: unknown;
|
||||
requiredFields: readonly string[];
|
||||
fieldTypes?: Readonly<Record<string, string>>;
|
||||
keyField?: string;
|
||||
uniqueFields?: readonly string[];
|
||||
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
|
||||
references?: readonly RegistryReference[];
|
||||
breakingFields?: readonly string[];
|
||||
consumers?: readonly RegistryConsumer[];
|
||||
consumerIdentityField?: string;
|
||||
consumerDirectories?: readonly string[];
|
||||
orphanExemptRows?: readonly string[];
|
||||
}>;
|
||||
type RegistryGovernance = Readonly<{
|
||||
registries: readonly RegistrySpecification[];
|
||||
sourceDirectories?: readonly string[];
|
||||
}>;
|
||||
type RegistrySnapshot = Record<string, unknown>;
|
||||
type CompatibilitySummary = Readonly<{
|
||||
impact: string;
|
||||
changes: readonly unknown[];
|
||||
}>;
|
||||
|
||||
function argumentValue(
|
||||
name: string,
|
||||
fallback: string | undefined,
|
||||
): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
@@ -26,13 +64,11 @@ function argumentValue(name, fallback) {
|
||||
|
||||
const defaultGovernancePath = "config/contracts/registry-governance.json";
|
||||
const governancePath =
|
||||
/** @type {string} */ (
|
||||
argumentValue("--governance", defaultGovernancePath)
|
||||
);
|
||||
argumentValue("--governance", defaultGovernancePath) ??
|
||||
defaultGovernancePath;
|
||||
const artifactPath =
|
||||
/** @type {string} */ (
|
||||
argumentValue("--artifact", "artifacts/quality/registries.json")
|
||||
);
|
||||
argumentValue("--artifact", "artifacts/quality/registries.json") ??
|
||||
"artifacts/quality/registries.json";
|
||||
const usesRepositoryBaseline =
|
||||
governancePath === defaultGovernancePath &&
|
||||
!process.argv.includes("--no-baseline");
|
||||
@@ -54,28 +90,31 @@ const evidencePath = argumentValue(
|
||||
? "config/contracts/registry-change-evidence.json"
|
||||
: undefined,
|
||||
);
|
||||
const governance = JSON.parse(await readFile(governancePath, "utf8"));
|
||||
const failures = [];
|
||||
const owners = new Map();
|
||||
const snapshots = [];
|
||||
const rowsByRegistry = new Map();
|
||||
const sourcesByRegistry = new Map();
|
||||
const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"];
|
||||
const governance = JSON.parse(
|
||||
await readFile(governancePath, "utf8"),
|
||||
) as RegistryGovernance;
|
||||
const failures: string[] = [];
|
||||
const owners = new Map<string, string>();
|
||||
const snapshots: RegistrySnapshot[] = [];
|
||||
const rowsByRegistry = new Map<string, RegistryRows>();
|
||||
const sourcesByRegistry = new Map<string, string>();
|
||||
const registryExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
||||
|
||||
/** @param {string} declaredPath */
|
||||
async function resolveRegistrySource(declaredPath) {
|
||||
async function resolveRegistrySource(
|
||||
declaredPath: string,
|
||||
): Promise<string | null> {
|
||||
const extension = path.extname(declaredPath);
|
||||
const basePath = extension
|
||||
? declaredPath.slice(0, -extension.length)
|
||||
: declaredPath;
|
||||
const candidates = [];
|
||||
const candidates: string[] = [];
|
||||
for (const candidateExtension of registryExtensions) {
|
||||
const candidate = `${basePath}${candidateExtension}`;
|
||||
try {
|
||||
await access(candidate);
|
||||
candidates.push(candidate);
|
||||
} catch {
|
||||
// A TypeScript migration may replace the declared extension.
|
||||
// Continue through the supported TypeScript source extensions.
|
||||
}
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
@@ -87,16 +126,14 @@ async function resolveRegistrySource(declaredPath) {
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function runtimeType(value) {
|
||||
function runtimeType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (Number.isInteger(value)) return "integer";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value @param {string} declaration */
|
||||
function matchesDeclaredType(value, declaration) {
|
||||
function matchesDeclaredType(value: unknown, declaration: string): boolean {
|
||||
const actual = runtimeType(value);
|
||||
return declaration
|
||||
.split("|")
|
||||
@@ -107,8 +144,7 @@ function matchesDeclaredType(value, declaration) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesBelow(directory) {
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
@@ -118,7 +154,7 @@ async function filesBelow(directory) {
|
||||
}),
|
||||
);
|
||||
return groups.flat().filter((file) =>
|
||||
/\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(file),
|
||||
/\.(?:ts|tsx|mts|cts)$/.test(file),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -135,10 +171,10 @@ for (const specification of governance.registries) {
|
||||
const sourcePath = await resolveRegistrySource(specification.path);
|
||||
try {
|
||||
if (!sourcePath) throw new Error("missing registry source");
|
||||
const module = await import(
|
||||
const registryModule = (await import(
|
||||
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
|
||||
);
|
||||
rows = module[specification.exportName];
|
||||
)) as Record<string, unknown>;
|
||||
rows = registryModule[specification.exportName];
|
||||
} catch {
|
||||
if (!rows) failures.push(`missing registry source ${specification.path}`);
|
||||
}
|
||||
@@ -148,13 +184,14 @@ for (const specification of governance.registries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rowsByRegistry.set(specification.registryId, rows);
|
||||
const registryRows = rows as RegistryRows;
|
||||
rowsByRegistry.set(specification.registryId, registryRows);
|
||||
sourcesByRegistry.set(
|
||||
specification.registryId,
|
||||
sourcePath ?? specification.path,
|
||||
);
|
||||
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
||||
failures.push(`${specification.registryId}.${rowName} is not an object`);
|
||||
continue;
|
||||
@@ -187,8 +224,8 @@ for (const specification of governance.registries) {
|
||||
}
|
||||
|
||||
for (const field of specification.uniqueFields ?? []) {
|
||||
const values = new Map();
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
const values = new Map<string, string>();
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const value = row[field];
|
||||
if (value === undefined) continue;
|
||||
@@ -206,13 +243,10 @@ for (const specification of governance.registries) {
|
||||
for (const [field, allowed] of Object.entries(
|
||||
specification.allowedValues ?? {},
|
||||
)) {
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
for (const [rowName, row] of Object.entries(registryRows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
if (
|
||||
!allowed.some(
|
||||
/** @param {unknown} value */
|
||||
(value) => Object.is(value, row[field]),
|
||||
)
|
||||
!allowed.some((value) => Object.is(value, row[field]))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
|
||||
@@ -234,9 +268,9 @@ for (const specification of governance.registries) {
|
||||
registryId: specification.registryId,
|
||||
owner: specification.owner,
|
||||
source: sourcePath ?? specification.path,
|
||||
rowCount: Object.keys(rows).length,
|
||||
rowCount: Object.keys(registryRows).length,
|
||||
contract,
|
||||
rows: canonicalizeRegistryValue(rows),
|
||||
rows: canonicalizeRegistryValue(registryRows),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,25 +385,25 @@ for (const sourceDirectory of sourceFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
const currentSnapshot =
|
||||
/** @type {Readonly<Record<string, unknown>>} */ (
|
||||
canonicalizeRegistryValue({
|
||||
schemaVersion: 2,
|
||||
registries: snapshots,
|
||||
})
|
||||
);
|
||||
let baselineDigest = null;
|
||||
let currentDigest = registrySnapshotDigest(currentSnapshot);
|
||||
let compatibility =
|
||||
/** @type {{impact: string, changes: readonly Record<string, unknown>[]}} */ ({
|
||||
impact: "not-evaluated",
|
||||
changes: [],
|
||||
});
|
||||
const currentSnapshot = canonicalizeRegistryValue({
|
||||
schemaVersion: 2,
|
||||
registries: snapshots,
|
||||
}) as Readonly<Record<string, unknown>>;
|
||||
let baselineDigest: string | null = null;
|
||||
const currentDigest = registrySnapshotDigest(currentSnapshot);
|
||||
let compatibility: CompatibilitySummary = {
|
||||
impact: "not-evaluated",
|
||||
changes: [],
|
||||
};
|
||||
|
||||
if (baselinePath && approvalPath && evidencePath) {
|
||||
try {
|
||||
const baseline = JSON.parse(await readFile(baselinePath, "utf8"));
|
||||
const approval = JSON.parse(await readFile(approvalPath, "utf8"));
|
||||
const baseline = JSON.parse(
|
||||
await readFile(baselinePath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const approval = JSON.parse(
|
||||
await readFile(approvalPath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
|
||||
baselineDigest = approvalResult.actualDigest;
|
||||
if (!approvalResult.passed) {
|
||||
@@ -377,9 +411,12 @@ if (baselinePath && approvalPath && evidencePath) {
|
||||
`registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`,
|
||||
);
|
||||
}
|
||||
compatibility = diffRegistrySnapshots(baseline, currentSnapshot);
|
||||
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
|
||||
const evidenceResult = validateBreakingEvidence(compatibility, evidence);
|
||||
const registryDiff = diffRegistrySnapshots(baseline, currentSnapshot);
|
||||
compatibility = registryDiff;
|
||||
const evidence = JSON.parse(
|
||||
await readFile(evidencePath, "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const evidenceResult = validateBreakingEvidence(registryDiff, evidence);
|
||||
failures.push(...evidenceResult.failures);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
diffRegistrySnapshots,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.mjs";
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
await readFile(
|
||||
@@ -1,8 +1,24 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** @param {string} name @param {string} fallback */
|
||||
function argumentValue(name, fallback) {
|
||||
type CoverageMetrics = Record<string, { pct: number }>;
|
||||
type CoveragePolicy = Readonly<{
|
||||
summary: Record<string, number>;
|
||||
criticalModules: readonly Readonly<{
|
||||
path: string;
|
||||
minimum: Record<string, number>;
|
||||
}>[];
|
||||
}>;
|
||||
type CoverageSummary = Record<string, CoverageMetrics>;
|
||||
type CoverageResult = Readonly<{
|
||||
scope: string;
|
||||
metric: string;
|
||||
threshold: number;
|
||||
received: number | undefined;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
function argumentValue(name: string, fallback: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
@@ -21,24 +37,20 @@ const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/quality/risk-coverage.json",
|
||||
);
|
||||
const policy = JSON.parse(await readFile(policyPath, "utf8"));
|
||||
const summary = JSON.parse(await readFile(summaryPath, "utf8"));
|
||||
const failures = [];
|
||||
/** @type {Array<{
|
||||
* scope: string,
|
||||
* metric: string,
|
||||
* threshold: number,
|
||||
* received: number | undefined,
|
||||
* passed: boolean
|
||||
* }>} */
|
||||
const results = [];
|
||||
const policy = JSON.parse(
|
||||
await readFile(policyPath, "utf8"),
|
||||
) as CoveragePolicy;
|
||||
const summary = JSON.parse(
|
||||
await readFile(summaryPath, "utf8"),
|
||||
) as CoverageSummary;
|
||||
const failures: string[] = [];
|
||||
const results: CoverageResult[] = [];
|
||||
|
||||
/**
|
||||
* @param {string} scope
|
||||
* @param {Record<string, {pct: number}>} actual
|
||||
* @param {Record<string, number>} minimum
|
||||
*/
|
||||
function evaluate(scope, actual, minimum) {
|
||||
function evaluate(
|
||||
scope: string,
|
||||
actual: CoverageMetrics,
|
||||
minimum: Record<string, number>,
|
||||
): void {
|
||||
for (const [metric, threshold] of Object.entries(minimum)) {
|
||||
const received = actual?.[metric]?.pct;
|
||||
const passed =
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
validateLicensePolicy,
|
||||
validateVulnerabilityReport,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.mjs";
|
||||
} from "./lib/supply-chain.ts";
|
||||
|
||||
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
|
||||
const baseDependency = {
|
||||
+32
-16
@@ -2,17 +2,26 @@ import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Document {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readDocument(file: string): Promise<Document> {
|
||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
await mkdir(fixtureDirectory, { recursive: true });
|
||||
const inventory = JSON.parse(
|
||||
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
|
||||
const inventory = await readDocument(
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
);
|
||||
const verification = JSON.parse(
|
||||
await readFile(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"utf8",
|
||||
),
|
||||
const verification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const vulnerabilityPath = path.join(
|
||||
fixtureDirectory,
|
||||
@@ -51,7 +60,7 @@ await writeFile(
|
||||
);
|
||||
const providerRun = spawnSync(
|
||||
"node",
|
||||
["scripts/generate-supply-chain.mjs"],
|
||||
["scripts/generate-supply-chain.ts"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -63,16 +72,17 @@ const providerRun = spawnSync(
|
||||
);
|
||||
let promotionStatus = "MISSING";
|
||||
if (providerRun.status === 0) {
|
||||
promotionStatus = JSON.parse(
|
||||
await readFile(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"utf8",
|
||||
),
|
||||
).promotionStatus;
|
||||
const providerVerification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
promotionStatus =
|
||||
typeof providerVerification.promotionStatus === "string"
|
||||
? providerVerification.promotionStatus
|
||||
: "MISSING";
|
||||
}
|
||||
const restore = spawnSync(
|
||||
"node",
|
||||
["scripts/generate-supply-chain.mjs"],
|
||||
["scripts/generate-supply-chain.ts"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
@@ -95,8 +105,14 @@ await writeFile(
|
||||
)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
const detail =
|
||||
providerRun.stderr ||
|
||||
restore.stderr ||
|
||||
providerRun.stdout ||
|
||||
restore.stdout ||
|
||||
`providerStatus=${String(providerRun.status)}, promotionStatus=${promotionStatus}, restoreStatus=${String(restore.status)}`;
|
||||
process.stderr.write(
|
||||
`Supply-chain provider fixture failed: ${providerRun.stderr || restore.stderr}\n`,
|
||||
`Supply-chain provider fixture failed: ${detail}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** @param {string} name @param {string} fallback */
|
||||
function argumentValue(name, fallback) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const sourceRoot = argumentValue("--source-root", "tests");
|
||||
const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/quality/test-evidence.json",
|
||||
);
|
||||
const fixtureMode = sourceRoot !== "tests";
|
||||
const failures = [];
|
||||
const facts = {
|
||||
scannedFiles: 0,
|
||||
visualBaselines: 0,
|
||||
sharedScenarios: 0,
|
||||
};
|
||||
|
||||
/** @param {string} target @returns {Promise<string[]>} */
|
||||
async function filesBelow(target) {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
if (metadata.isFile()) return [target];
|
||||
const entries = await readdir(target, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => filesBelow(path.join(target, entry.name))),
|
||||
);
|
||||
return groups.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = (await filesBelow(sourceRoot)).filter(
|
||||
(file) => fixtureMode || !file.split(path.sep).includes("fixtures"),
|
||||
);
|
||||
for (const file of sourceFiles) {
|
||||
if (!/\.(?:js|jsx|mjs|ts|tsx|fixture|txt)$/.test(file)) continue;
|
||||
const source = await readFile(file, "utf8");
|
||||
facts.scannedFiles += 1;
|
||||
const skipPattern =
|
||||
/\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g;
|
||||
if (skipPattern.test(source)) {
|
||||
const quarantine =
|
||||
/quarantine\(owner=[^)]+,\s*defect=[^)]+,\s*expires=\d{4}-\d{2}-\d{2}\)/;
|
||||
if (!quarantine.test(source)) {
|
||||
failures.push(`${file}: skip/fixme lacks owned expiring quarantine`);
|
||||
}
|
||||
}
|
||||
const wholeUiMask =
|
||||
/\bmask\s*:\s*\[[\s\S]{0,240}(?:locator|getByRole)\s*\(\s*["'](?:html|body|main|application|document)["']/i;
|
||||
if (wholeUiMask.test(source)) {
|
||||
failures.push(`${file}: screenshot mask may not cover the whole UI`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixtureMode) {
|
||||
const e2eConfig = await readFile("playwright.config.js", "utf8");
|
||||
for (const token of [
|
||||
"pnpm build",
|
||||
"pnpm preview",
|
||||
"reuseExistingServer: false",
|
||||
'"junit"',
|
||||
'trace: "retain-on-failure"',
|
||||
'"chromium-compact"',
|
||||
'"firefox"',
|
||||
'"webkit"',
|
||||
]) {
|
||||
if (!e2eConfig.includes(token)) {
|
||||
failures.push(`playwright.config.js missing release evidence token ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
const e2eFiles = (await filesBelow("tests/e2e")).filter((file) =>
|
||||
/\.spec\.(?:js|ts)$/.test(file),
|
||||
);
|
||||
for (const file of e2eFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
if (!source.includes("support/browser/strict-browser-test")) {
|
||||
failures.push(`${file}: bypasses strict browser fixture`);
|
||||
}
|
||||
}
|
||||
|
||||
const scenarioCatalog = await readFile(
|
||||
"tests/mocks/scenarios/catalog.ts",
|
||||
"utf8",
|
||||
);
|
||||
const scenarioIdBlock =
|
||||
scenarioCatalog.match(
|
||||
/HTTP_SCENARIO_IDS\s*=\s*Object\.freeze\(\[([\s\S]*?)\]\s*as const\)/,
|
||||
)?.[1] ?? "";
|
||||
facts.sharedScenarios = (scenarioIdBlock.match(/"[^"]+"/g) ?? []).length;
|
||||
if (facts.sharedScenarios < 19) {
|
||||
failures.push("shared MSW catalog must retain all 19 failure scenarios");
|
||||
}
|
||||
const handler = await readFile(
|
||||
"tests/mocks/handlers/reference-resources.ts",
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!handler.includes("assertOperationScenario") ||
|
||||
!handler.includes("../scenarios/catalog.js")
|
||||
) {
|
||||
failures.push("MSW handler bypasses shared scenario catalog");
|
||||
}
|
||||
|
||||
const baselineFiles = (await filesBelow("tests/visual/__snapshots__")).filter(
|
||||
(file) => file.endsWith(".png"),
|
||||
);
|
||||
facts.visualBaselines = baselineFiles.length;
|
||||
if (facts.visualBaselines < 4) {
|
||||
failures.push("visual baseline requires at least four risk surfaces");
|
||||
}
|
||||
for (const required of [
|
||||
"playwright.storybook.config.js",
|
||||
"playwright.visual.config.js",
|
||||
"tests/storybook/workshop.spec.ts",
|
||||
"artifacts/tests/storybook/results.xml",
|
||||
"artifacts/tests/visual/results.xml",
|
||||
]) {
|
||||
if ((await filesBelow(required)).length === 0) {
|
||||
failures.push(`test evidence missing ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
const requiredBuiltFiles = [
|
||||
"dist/index.html",
|
||||
"dist/config.json",
|
||||
"dist/release-manifest.json",
|
||||
"dist/runtime-config.schema.json",
|
||||
"dist/.vite/manifest.json",
|
||||
];
|
||||
for (const required of requiredBuiltFiles) {
|
||||
if ((await filesBelow(required)).length === 0) {
|
||||
failures.push(`built-dist contract missing ${required}`);
|
||||
}
|
||||
}
|
||||
const sourceMaps = (await filesBelow("dist")).filter((file) =>
|
||||
file.endsWith(".map"),
|
||||
);
|
||||
if (sourceMaps.length > 0) {
|
||||
failures.push(`production dist contains source maps: ${sourceMaps.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
sourceRoot,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
facts,
|
||||
failures,
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Test evidence failed:\n- ${failures.join("\n- ")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.sharedScenarios} scenarios)\n`,
|
||||
);
|
||||
@@ -0,0 +1,262 @@
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type ScenarioCatalogContribution = Readonly<{
|
||||
owner: string;
|
||||
path: string;
|
||||
arrayExport: string;
|
||||
minimumEntries: number;
|
||||
}>;
|
||||
|
||||
type SourceContractContribution = Readonly<{
|
||||
owner: string;
|
||||
path: string;
|
||||
requiredTokens: readonly string[];
|
||||
}>;
|
||||
|
||||
type TestEvidencePolicy = Readonly<{
|
||||
schemaVersion: number;
|
||||
scenarioCatalogs: readonly unknown[];
|
||||
sourceContracts: readonly unknown[];
|
||||
}>;
|
||||
|
||||
function argumentValue(name: string, fallback: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const sourceRoot = argumentValue("--source-root", "tests");
|
||||
const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/quality/test-evidence.json",
|
||||
);
|
||||
const policyPath = argumentValue(
|
||||
"--policy",
|
||||
"config/testing/test-evidence.json",
|
||||
);
|
||||
const fixtureMode = sourceRoot !== "tests";
|
||||
const sourceOnly = process.argv.includes("--source-only");
|
||||
const failures: string[] = [];
|
||||
const facts = {
|
||||
scannedFiles: 0,
|
||||
visualBaselines: 0,
|
||||
sharedScenarios: 0,
|
||||
};
|
||||
|
||||
async function filesBelow(target: string): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
if (metadata.isFile()) return [target];
|
||||
const entries = await readdir(target, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => filesBelow(path.join(target, entry.name))),
|
||||
);
|
||||
return groups.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const sourceFiles = (await filesBelow(sourceRoot)).filter(
|
||||
(file) => fixtureMode || !file.split(path.sep).includes("fixtures"),
|
||||
);
|
||||
for (const file of sourceFiles) {
|
||||
if (!/\.(?:ts|tsx|fixture|txt)$/.test(file)) continue;
|
||||
const source = await readFile(file, "utf8");
|
||||
facts.scannedFiles += 1;
|
||||
const skipPattern =
|
||||
/\b(?:test|it|describe)(?:\.describe)?\.(?:skip|fixme)\s*\(/g;
|
||||
if (skipPattern.test(source)) {
|
||||
const quarantine =
|
||||
/quarantine\(owner=[^)]+,\s*defect=[^)]+,\s*expires=\d{4}-\d{2}-\d{2}\)/;
|
||||
if (!quarantine.test(source)) {
|
||||
failures.push(`${file}: skip/fixme lacks owned expiring quarantine`);
|
||||
}
|
||||
}
|
||||
const wholeUiMask =
|
||||
/\bmask\s*:\s*\[[\s\S]{0,240}(?:locator|getByRole)\s*\(\s*["'](?:html|body|main|application|document)["']/i;
|
||||
if (wholeUiMask.test(source)) {
|
||||
failures.push(`${file}: screenshot mask may not cover the whole UI`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fixtureMode) {
|
||||
const e2eConfig = await readFile("playwright.config.ts", "utf8");
|
||||
for (const token of [
|
||||
"pnpm build",
|
||||
"pnpm preview",
|
||||
"reuseExistingServer: false",
|
||||
'"junit"',
|
||||
'trace: "retain-on-failure"',
|
||||
'"chromium-compact"',
|
||||
'"firefox"',
|
||||
'"webkit"',
|
||||
]) {
|
||||
if (!e2eConfig.includes(token)) {
|
||||
failures.push(`playwright.config.ts missing release evidence token ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
const e2eFiles = (await filesBelow("tests/e2e")).filter((file) =>
|
||||
/\.spec\.ts$/.test(file),
|
||||
);
|
||||
for (const file of e2eFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
if (!source.includes("support/browser/strict-browser-test")) {
|
||||
failures.push(`${file}: bypasses strict browser fixture`);
|
||||
}
|
||||
}
|
||||
|
||||
let evidencePolicy: unknown;
|
||||
try {
|
||||
evidencePolicy = JSON.parse(await readFile(policyPath, "utf8"));
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${policyPath}: cannot read test evidence policy (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
}
|
||||
const policy = evidencePolicy as Partial<TestEvidencePolicy> | undefined;
|
||||
if (
|
||||
policy?.schemaVersion !== 1 ||
|
||||
!Array.isArray(policy.scenarioCatalogs) ||
|
||||
!Array.isArray(policy.sourceContracts)
|
||||
) {
|
||||
failures.push(`${policyPath}: invalid test evidence policy`);
|
||||
} else {
|
||||
for (const candidate of policy.scenarioCatalogs) {
|
||||
const contribution = candidate as Partial<ScenarioCatalogContribution>;
|
||||
const minimumEntries = contribution.minimumEntries;
|
||||
if (
|
||||
typeof contribution?.owner !== "string" ||
|
||||
typeof contribution?.path !== "string" ||
|
||||
typeof contribution?.arrayExport !== "string" ||
|
||||
typeof minimumEntries !== "number" ||
|
||||
!Number.isInteger(minimumEntries) ||
|
||||
minimumEntries < 1
|
||||
) {
|
||||
failures.push(`${policyPath}: invalid scenario catalog contribution`);
|
||||
continue;
|
||||
}
|
||||
let source: string;
|
||||
try {
|
||||
source = await readFile(contribution.path, "utf8");
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${contribution.path}: cannot read scenario catalog (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const arrayPattern = new RegExp(
|
||||
`${escapeRegExp(contribution.arrayExport)}\\s*=\\s*Object\\.freeze\\(\\[([\\s\\S]*?)\\]\\s*as const\\)`,
|
||||
);
|
||||
const entryCount = (
|
||||
arrayPattern.exec(source)?.[1]?.match(/"[^"]+"/g) ?? []
|
||||
).length;
|
||||
facts.sharedScenarios += entryCount;
|
||||
if (entryCount < minimumEntries) {
|
||||
failures.push(
|
||||
`${contribution.path}: ${contribution.owner} requires at least ${minimumEntries} shared scenarios`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const candidate of policy.sourceContracts) {
|
||||
const contract = candidate as Partial<SourceContractContribution>;
|
||||
const requiredTokens = contract.requiredTokens;
|
||||
if (
|
||||
typeof contract?.owner !== "string" ||
|
||||
typeof contract?.path !== "string" ||
|
||||
!Array.isArray(requiredTokens) ||
|
||||
requiredTokens.some((token: unknown) => typeof token !== "string")
|
||||
) {
|
||||
failures.push(`${policyPath}: invalid source contract contribution`);
|
||||
continue;
|
||||
}
|
||||
let source: string;
|
||||
try {
|
||||
source = await readFile(contract.path, "utf8");
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${contract.path}: cannot read source contract (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const token of requiredTokens as readonly string[]) {
|
||||
if (!source.includes(token)) {
|
||||
failures.push(
|
||||
`${contract.path}: ${contract.owner} evidence contract is missing ${token}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baselineFiles = (await filesBelow("tests/visual/__snapshots__")).filter(
|
||||
(file) => file.endsWith(".png"),
|
||||
);
|
||||
facts.visualBaselines = baselineFiles.length;
|
||||
if (facts.visualBaselines < 4) {
|
||||
failures.push("visual baseline requires at least four risk surfaces");
|
||||
}
|
||||
for (const required of [
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"tests/storybook/workshop.spec.ts",
|
||||
]) {
|
||||
if ((await filesBelow(required)).length === 0) {
|
||||
failures.push(`test evidence missing ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceOnly) {
|
||||
for (const required of [
|
||||
"artifacts/tests/storybook/results.xml",
|
||||
"artifacts/tests/visual/results.xml",
|
||||
]) {
|
||||
if ((await filesBelow(required)).length === 0) {
|
||||
failures.push(`test evidence missing ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
const requiredBuiltFiles = [
|
||||
"dist/index.html",
|
||||
"dist/config.json",
|
||||
"dist/release-manifest.json",
|
||||
"dist/runtime-config.schema.json",
|
||||
"dist/.vite/manifest.json",
|
||||
];
|
||||
for (const required of requiredBuiltFiles) {
|
||||
if ((await filesBelow(required)).length === 0) {
|
||||
failures.push(`built-dist contract missing ${required}`);
|
||||
}
|
||||
}
|
||||
const sourceMaps = (await filesBelow("dist")).filter((file) =>
|
||||
file.endsWith(".map"),
|
||||
);
|
||||
if (sourceMaps.length > 0) {
|
||||
failures.push(`production dist contains source maps: ${sourceMaps.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
sourceRoot,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
facts,
|
||||
failures,
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Test evidence failed:\n- ${failures.join("\n- ")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Test evidence: PASS (${facts.scannedFiles} files, ${facts.visualBaselines} baselines, ${facts.sharedScenarios} scenarios)\n`,
|
||||
);
|
||||
@@ -3,8 +3,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
evaluateFieldBudget,
|
||||
percentile75,
|
||||
} from "../src/application/policies/performance-budgets.js";
|
||||
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
|
||||
} from "../src/application/policies/performance-budgets.ts";
|
||||
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts";
|
||||
|
||||
const inputPath =
|
||||
process.env.FIELD_WEB_VITALS_INPUT ||
|
||||
@@ -17,15 +17,16 @@ const validation = validateFieldEvidenceInput(
|
||||
now,
|
||||
);
|
||||
const input = validation.data;
|
||||
const configured =
|
||||
/** @type {{
|
||||
* p75LcpMs: number,
|
||||
* p75Cls: number,
|
||||
* p75InpMs: number,
|
||||
* minimumEligibleSamples: number | null
|
||||
* }} */ (
|
||||
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
|
||||
);
|
||||
type FieldBudgets = {
|
||||
p75LcpMs: number;
|
||||
p75Cls: number;
|
||||
p75InpMs: number;
|
||||
minimumEligibleSamples: number | null;
|
||||
};
|
||||
|
||||
const configured = JSON.parse(
|
||||
await readFile("config/performance/budgets.json", "utf8"),
|
||||
).field as FieldBudgets;
|
||||
const minimumEligibleSamples = validation.minimumEligibleSamples;
|
||||
const fallbackEnd = now;
|
||||
const fallbackStart = new Date(fallbackEnd);
|
||||
@@ -60,7 +61,7 @@ const routeSamples = Object.fromEntries(
|
||||
counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1;
|
||||
return counts;
|
||||
},
|
||||
/** @type {Record<string, number>} */ ({}),
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
).sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
@@ -1,58 +1,72 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { shouldRetry } from "../src/adapters/http/retry-policy.js";
|
||||
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.js";
|
||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.js";
|
||||
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
|
||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.js";
|
||||
import { projectTelemetryEvent } from "../src/contracts/telemetry.js";
|
||||
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js";
|
||||
import { shouldRetry } from "../src/adapters/http/retry-policy.ts";
|
||||
import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts";
|
||||
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.ts";
|
||||
import type { StoragePort } from "../src/application/ports/storage-port.ts";
|
||||
import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts";
|
||||
import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts";
|
||||
import { compareReleaseToRuntime } from "../src/contracts/release-tokens.ts";
|
||||
import { projectTelemetryEvent } from "../src/contracts/telemetry.ts";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* triggerAsserted: boolean,
|
||||
* containmentAsserted: boolean,
|
||||
* recoveryAssertions: Array<{
|
||||
* assertion: string,
|
||||
* evidence: string,
|
||||
* passed: boolean
|
||||
* }>,
|
||||
* negativeFixtureFailedAsExpected: boolean,
|
||||
* providerVerificationRequired: boolean
|
||||
* }} DrillResult
|
||||
*/
|
||||
type RecoveryAssertion = Readonly<{
|
||||
assertion: string;
|
||||
evidence: string;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
type DrillResult = Readonly<{
|
||||
triggerAsserted: boolean;
|
||||
containmentAsserted: boolean;
|
||||
recoveryAssertions: RecoveryAssertion[];
|
||||
negativeFixtureFailedAsExpected: boolean;
|
||||
providerVerificationRequired: boolean;
|
||||
}>;
|
||||
|
||||
type RunbookSpecification = Readonly<{
|
||||
title: string;
|
||||
gateId: string;
|
||||
triggerKinds: string[];
|
||||
containment: string;
|
||||
window: string;
|
||||
escalation: string[];
|
||||
recoveryEvidence: string[];
|
||||
negativeFixture: string;
|
||||
}>;
|
||||
|
||||
type RunbookDocument = Readonly<{
|
||||
runbooks: Record<string, RunbookSpecification>;
|
||||
}>;
|
||||
|
||||
type ReleaseManifest = Record<string, unknown> & {
|
||||
buildId: string;
|
||||
configSchemaVersion: string;
|
||||
apiContractVersion: string;
|
||||
assetManifestHash: string;
|
||||
releaseId: string;
|
||||
};
|
||||
|
||||
const runbookId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-RB-00[1-5]$/.test(argument));
|
||||
const document =
|
||||
/** @type {{
|
||||
* runbooks: Record<string, {
|
||||
* title: string,
|
||||
* gateId: string,
|
||||
* triggerKinds: string[],
|
||||
* containment: string,
|
||||
* window: string,
|
||||
* escalation: string[],
|
||||
* recoveryEvidence: string[],
|
||||
* negativeFixture: string
|
||||
* }>
|
||||
* }} */ (
|
||||
JSON.parse(await readFile("config/runbooks/runbooks.json", "utf8"))
|
||||
);
|
||||
const document = JSON.parse(
|
||||
await readFile("config/runbooks/runbooks.json", "utf8"),
|
||||
) as RunbookDocument;
|
||||
const specification = runbookId ? document.runbooks[runbookId] : undefined;
|
||||
if (!runbookId || !specification) {
|
||||
process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
async function releaseManifest() {
|
||||
async function releaseManifest(): Promise<ReleaseManifest> {
|
||||
for (const candidate of [
|
||||
"dist/release-manifest.json",
|
||||
"public/release-manifest.json",
|
||||
]) {
|
||||
try {
|
||||
return JSON.parse(await readFile(candidate, "utf8"));
|
||||
return JSON.parse(
|
||||
await readFile(candidate, "utf8"),
|
||||
) as ReleaseManifest;
|
||||
} catch {
|
||||
// Continue to the source fallback.
|
||||
}
|
||||
@@ -74,12 +88,15 @@ const validConfig = {
|
||||
RELEASE_ID: "local-release",
|
||||
};
|
||||
|
||||
/** @param {string} assertion @param {string} evidence @param {boolean} passed */
|
||||
function assertion(assertion, evidence, passed) {
|
||||
function assertion(
|
||||
assertion: string,
|
||||
evidence: string,
|
||||
passed: boolean,
|
||||
): RecoveryAssertion {
|
||||
return { assertion, evidence, passed };
|
||||
}
|
||||
|
||||
async function drillBoot() {
|
||||
async function drillBoot(): Promise<DrillResult> {
|
||||
const invalid = validateRuntimeConfig({
|
||||
...validConfig,
|
||||
APP_ENV: "production",
|
||||
@@ -103,23 +120,21 @@ async function drillBoot() {
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStorage() {
|
||||
/** @type {unknown} */
|
||||
let value;
|
||||
function memoryStorage(): StoragePort {
|
||||
let value: unknown;
|
||||
return {
|
||||
read: () => ({ ok: /** @type {const} */ (true), value }),
|
||||
/** @param {string} _key @param {unknown} next */
|
||||
write: (_key, next) => {
|
||||
read: () => ({ ok: true, value }),
|
||||
write: (_key: string, next: unknown) => {
|
||||
value = next;
|
||||
return { ok: /** @type {const} */ (true) };
|
||||
return { ok: true };
|
||||
},
|
||||
remove: () => ({ ok: /** @type {const} */ (true) }),
|
||||
remove: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
async function drillChunkMismatch() {
|
||||
async function drillChunkMismatch(): Promise<DrillResult> {
|
||||
const storage = memoryStorage();
|
||||
const input = {
|
||||
const input: Parameters<typeof decideChunkRecovery>[0] = {
|
||||
failureKind: "DEPLOY_MISMATCH",
|
||||
manifestLoaded: true,
|
||||
currentBuildId: "build-a",
|
||||
@@ -157,7 +172,7 @@ async function drillChunkMismatch() {
|
||||
};
|
||||
}
|
||||
|
||||
async function drillApiDegradation() {
|
||||
async function drillApiDegradation(): Promise<DrillResult> {
|
||||
const unkeyedRetry = shouldRetry(
|
||||
{ idempotency: "none" },
|
||||
{ kind: "SERVER_FAILURE", httpStatus: 503 },
|
||||
@@ -182,7 +197,7 @@ async function drillApiDegradation() {
|
||||
};
|
||||
}
|
||||
|
||||
async function drillTelemetry() {
|
||||
async function drillTelemetry(): Promise<DrillResult> {
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.invalid/events",
|
||||
@@ -221,7 +236,7 @@ async function drillTelemetry() {
|
||||
};
|
||||
}
|
||||
|
||||
async function drillRollback() {
|
||||
async function drillRollback(): Promise<DrillResult> {
|
||||
const release = await releaseManifest();
|
||||
const runtime = JSON.parse(
|
||||
await readFile(
|
||||
@@ -255,21 +270,20 @@ async function drillRollback() {
|
||||
assertion("compatibility gate", "typed version comparison", coherent.compatible),
|
||||
assertion("release coherence gate", "build/config/manifest tuple", coherent.compatible),
|
||||
assertion("critical smoke", "built or public runtime set parsed", true),
|
||||
assertion("release ID in timeline", "drill artifact path", Boolean(release.releaseId)),
|
||||
assertion("release ID in timeline", "drill record releaseId", Boolean(release.releaseId)),
|
||||
],
|
||||
negativeFixtureFailedAsExpected: !mixed.compatible,
|
||||
providerVerificationRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
const drillById =
|
||||
/** @type {Record<string, () => Promise<DrillResult>>} */ ({
|
||||
"FE-RB-001": drillBoot,
|
||||
"FE-RB-002": drillChunkMismatch,
|
||||
"FE-RB-003": drillApiDegradation,
|
||||
"FE-RB-004": drillTelemetry,
|
||||
"FE-RB-005": drillRollback,
|
||||
});
|
||||
const drillById: Record<string, () => Promise<DrillResult>> = {
|
||||
"FE-RB-001": drillBoot,
|
||||
"FE-RB-002": drillChunkMismatch,
|
||||
"FE-RB-003": drillApiDegradation,
|
||||
"FE-RB-004": drillTelemetry,
|
||||
"FE-RB-005": drillRollback,
|
||||
};
|
||||
const drill = await drillById[runbookId]();
|
||||
const escalationPathAsserted = specification.escalation.length >= 2;
|
||||
const passed =
|
||||
@@ -294,7 +308,7 @@ const record = {
|
||||
providerVerificationRequired: drill.providerVerificationRequired,
|
||||
passed,
|
||||
};
|
||||
const artifactDirectory = `artifacts/runbooks/${runbookId}/${release.releaseId}`;
|
||||
const artifactDirectory = `artifacts/runbooks/${runbookId}`;
|
||||
await mkdir(artifactDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
`${artifactDirectory}/record.json`,
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.js";
|
||||
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.js";
|
||||
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
|
||||
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
|
||||
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
|
||||
const releaseId = process.env.RELEASE_ID ?? "local-release";
|
||||
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
|
||||
const buildTime = process.env.SOURCE_DATE_EPOCH
|
||||
? new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1_000)
|
||||
: new Date();
|
||||
if (!Number.isFinite(buildTime.getTime())) {
|
||||
throw new Error("SOURCE_DATE_EPOCH must be epoch seconds");
|
||||
}
|
||||
const builtAt = buildTime.toISOString();
|
||||
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
||||
const viteManifestObject =
|
||||
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
|
||||
JSON.parse(viteManifest)
|
||||
);
|
||||
const assetManifestHash = createHash("sha256")
|
||||
.update(viteManifest)
|
||||
.digest("hex");
|
||||
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
|
||||
/** @type {Record<string, string>} */
|
||||
const routeChunks = {};
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
const runtime =
|
||||
/** @type {Record<string, {moduleId: string}>} */ (
|
||||
ROUTE_RUNTIME_CONTRACT
|
||||
)[definition.routeId];
|
||||
const asset = Object.values(viteManifestObject).find(
|
||||
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
|
||||
);
|
||||
if (!runtime || !asset?.file) {
|
||||
throw new Error(`Missing built route chunk: ${definition.routeId}`);
|
||||
}
|
||||
routeChunks[definition.chunkId] = asset.file;
|
||||
}
|
||||
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
|
||||
|
||||
runtimeConfig.BUILD_ID = buildId;
|
||||
runtimeConfig.RELEASE_ID = releaseId;
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
commitSha,
|
||||
generatedAt: builtAt,
|
||||
buildContext: {
|
||||
nodeVersion: process.version,
|
||||
packageManagerVersion,
|
||||
runnerImage,
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
routeChunks,
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: packageJson.version,
|
||||
buildId,
|
||||
commitSha,
|
||||
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
|
||||
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
|
||||
assetManifestHash,
|
||||
releaseId,
|
||||
builtAt,
|
||||
routeChunks,
|
||||
};
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
|
||||
await writeFile(
|
||||
"dist/release-manifest.json",
|
||||
`${JSON.stringify(releaseManifest, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"dist/runtime-config.schema.json",
|
||||
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"artifacts/release/runtime-config.schema.json",
|
||||
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"artifacts/release/build-manifest.json",
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.ts";
|
||||
import {
|
||||
assertCiBuildEnvironment,
|
||||
buildDate,
|
||||
} from "./lib/build-environment.ts";
|
||||
|
||||
assertCiBuildEnvironment(process.env);
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
name?: string;
|
||||
isDynamicEntry?: boolean;
|
||||
}>;
|
||||
|
||||
const packageJson = parsePackageMetadata(
|
||||
JSON.parse(await readFile("package.json", "utf8")),
|
||||
);
|
||||
const packageManagerVersion = packageJson.packageManager.split("@").at(-1);
|
||||
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
|
||||
const commitSha = process.env.VITE_COMMIT_SHA ?? "local";
|
||||
const releaseId = process.env.RELEASE_ID ?? "local-release";
|
||||
const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`;
|
||||
const buildTime = buildDate(process.env);
|
||||
const builtAt = buildTime.toISOString();
|
||||
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
||||
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
|
||||
const moduleInventory = await readFile(
|
||||
"dist/.vite/module-inventory.json",
|
||||
"utf8",
|
||||
);
|
||||
parseModuleInventory(JSON.parse(moduleInventory));
|
||||
const assetManifestHash = createHash("sha256")
|
||||
.update(viteManifest)
|
||||
.digest("hex");
|
||||
const moduleInventoryHash = createHash("sha256")
|
||||
.update(moduleInventory)
|
||||
.digest("hex");
|
||||
const runtimeConfig = runtimeConfigSchema.parse(
|
||||
JSON.parse(await readFile("dist/config.json", "utf8")),
|
||||
);
|
||||
const routeChunks: Record<string, string> = {};
|
||||
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
|
||||
ROUTE_RUNTIME_CONTRACT;
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
const runtime = runtimeContracts[definition.routeId];
|
||||
const asset = Object.values(viteManifestObject).find(
|
||||
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
|
||||
);
|
||||
if (!runtime || !asset?.file) {
|
||||
throw new Error(`Missing built route chunk: ${definition.routeId}`);
|
||||
}
|
||||
routeChunks[definition.chunkId] = asset.file;
|
||||
}
|
||||
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
|
||||
|
||||
runtimeConfig.BUILD_ID = buildId;
|
||||
runtimeConfig.RELEASE_ID = releaseId;
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
commitSha,
|
||||
releaseId,
|
||||
moduleInventoryHash,
|
||||
generatedAt: builtAt,
|
||||
buildContext: {
|
||||
nodeVersion: process.version,
|
||||
packageManagerVersion,
|
||||
runnerImage,
|
||||
sourceDateEpoch: process.env.SOURCE_DATE_EPOCH ?? null,
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||
routeChunks,
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
appVersion: packageJson.version,
|
||||
buildId,
|
||||
commitSha,
|
||||
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
|
||||
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
|
||||
assetManifestHash,
|
||||
releaseId,
|
||||
builtAt,
|
||||
routeChunks,
|
||||
};
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/vite-module-inventory.json",
|
||||
moduleInventory,
|
||||
);
|
||||
await rm("dist/.vite/module-inventory.json");
|
||||
await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`);
|
||||
await writeFile(
|
||||
"dist/release-manifest.json",
|
||||
`${JSON.stringify(releaseManifest, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"dist/runtime-config.schema.json",
|
||||
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"artifacts/release/runtime-config.schema.json",
|
||||
`${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
"artifacts/release/build-manifest.json",
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
|
||||
function parsePackageMetadata(value: unknown): Readonly<{
|
||||
version: string;
|
||||
packageManager: string;
|
||||
}> {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.version !== "string" ||
|
||||
typeof value.packageManager !== "string"
|
||||
) {
|
||||
throw new TypeError("package.json release metadata is invalid");
|
||||
}
|
||||
return { version: value.version, packageManager: value.packageManager };
|
||||
}
|
||||
|
||||
function parseViteManifest(
|
||||
value: unknown,
|
||||
): Readonly<Record<string, ViteManifestEntry>> {
|
||||
if (!isRecord(value)) throw new TypeError("Vite manifest must be an object");
|
||||
const entries: Record<string, ViteManifestEntry> = {};
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
if (!isRecord(candidate) || typeof candidate.file !== "string") {
|
||||
throw new TypeError(`Invalid Vite manifest entry: ${key}`);
|
||||
}
|
||||
entries[key] = {
|
||||
file: candidate.file,
|
||||
...(typeof candidate.name === "string" ? { name: candidate.name } : {}),
|
||||
...(typeof candidate.isDynamicEntry === "boolean"
|
||||
? { isDynamicEntry: candidate.isDynamicEntry }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseModuleInventory(value: unknown): void {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
!Array.isArray(value.chunks) ||
|
||||
value.chunks.some(
|
||||
(chunk) =>
|
||||
!isRecord(chunk) ||
|
||||
typeof chunk.fileName !== "string" ||
|
||||
!Array.isArray(chunk.modules) ||
|
||||
chunk.modules.some((moduleId) => typeof moduleId !== "string"),
|
||||
)
|
||||
) {
|
||||
throw new TypeError("Vite module inventory is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -20,31 +20,54 @@ import {
|
||||
validateLicensePolicy,
|
||||
validateVulnerabilityReport,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.mjs";
|
||||
type DependencyInventoryDiff,
|
||||
} from "./lib/supply-chain.ts";
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesWithin(directory) {
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Document {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function documentValue(value: unknown, label: string): Document {
|
||||
if (!isRecord(value)) throw new Error(`${label} must be a JSON object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringMap(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function jsonDocument(file: string): Promise<Document> {
|
||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
return documentValue(parsed, file);
|
||||
}
|
||||
|
||||
async function filesWithin(directory: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = /** @type {string[][]} */ (await Promise.all(
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
||||
}),
|
||||
));
|
||||
);
|
||||
return nested.flat().sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} file */
|
||||
async function sha256File(file) {
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string[]} files */
|
||||
async function digestFileSet(files) {
|
||||
async function digestFileSet(files: string[]): Promise<string> {
|
||||
const rows = await Promise.all(
|
||||
files.sort().map(async (file) => ({
|
||||
path: file.replaceAll("\\", "/"),
|
||||
@@ -54,17 +77,17 @@ async function digestFileSet(files) {
|
||||
return supplyChainDigest(rows);
|
||||
}
|
||||
|
||||
/** @param {string} file @returns {Promise<Record<string, unknown> | null>} */
|
||||
async function optionalJson(file) {
|
||||
async function optionalJson(file: string): Promise<Document | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildDependencyInventory() {
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const packageJson = await jsonDocument("package.json");
|
||||
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
.update(lockfileText)
|
||||
@@ -80,18 +103,19 @@ export async function buildDependencyInventory() {
|
||||
if (listed.status !== 0) {
|
||||
throw new Error(`pnpm dependency graph failed: ${listed.stderr}`);
|
||||
}
|
||||
const roots = JSON.parse(listed.stdout);
|
||||
const root = roots[0];
|
||||
const roots: unknown = JSON.parse(listed.stdout);
|
||||
const root = Array.isArray(roots) && isRecord(roots[0]) ? roots[0] : null;
|
||||
if (!root) throw new Error("pnpm dependency graph root is invalid");
|
||||
const flattened = await flattenPnpmDependencyTree(
|
||||
root,
|
||||
packageJson.dependencies ?? {},
|
||||
packageJson.devDependencies ?? {},
|
||||
stringMap(packageJson.dependencies),
|
||||
stringMap(packageJson.devDependencies),
|
||||
);
|
||||
const lockRows = parsePnpmLockfilePackages(lockfileText);
|
||||
const lockByIdentity = new Map(
|
||||
lockRows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
const dependencies = flattened.map((dependency) => {
|
||||
const identity = `${dependency.name}@${dependency.version}`;
|
||||
const lockRow = lockByIdentity.get(identity);
|
||||
@@ -118,7 +142,7 @@ export async function buildDependencyInventory() {
|
||||
}
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
packageManager: packageJson.packageManager,
|
||||
packageManager: String(packageJson.packageManager ?? ""),
|
||||
lockfileSha256,
|
||||
dependencyCount: dependencies.length,
|
||||
directDependencyCount: dependencies.filter((entry) => entry.direct).length,
|
||||
@@ -126,7 +150,7 @@ export async function buildDependencyInventory() {
|
||||
};
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
||||
const packageJson = await jsonDocument("package.json");
|
||||
const outputFiles = await filesWithin("dist");
|
||||
if (outputFiles.length === 0) {
|
||||
throw new Error("dist is missing; run the production build first");
|
||||
@@ -169,19 +193,19 @@ const dependencyEvidence = JSON.parse(
|
||||
),
|
||||
);
|
||||
const skipsBaseline = process.argv.includes("--no-baseline");
|
||||
const baselineFailures = [];
|
||||
let dependencyDiff =
|
||||
/** @type {ReturnType<typeof diffDependencyInventories>} */ ({
|
||||
added: [],
|
||||
removed: [],
|
||||
changed: [],
|
||||
upgrades: [],
|
||||
const baselineFailures: string[] = [];
|
||||
let dependencyDiff: DependencyInventoryDiff = Object.freeze({
|
||||
added: Object.freeze([]),
|
||||
removed: Object.freeze([]),
|
||||
changed: Object.freeze([]),
|
||||
upgrades: Object.freeze([]),
|
||||
});
|
||||
let reviewResult =
|
||||
/** @type {ReturnType<typeof validateDependencyReview>} */ ({
|
||||
let reviewResult: ReturnType<typeof validateDependencyReview> = Object.freeze({
|
||||
passed: skipsBaseline,
|
||||
highRisk: [],
|
||||
failures: skipsBaseline ? [] : ["dependency baseline unavailable"],
|
||||
highRisk: Object.freeze([]),
|
||||
failures: Object.freeze(
|
||||
skipsBaseline ? [] : ["dependency baseline unavailable"],
|
||||
),
|
||||
});
|
||||
if (baseline && baselineApproval) {
|
||||
const actualBaselineDigest = supplyChainDigest(baseline);
|
||||
@@ -253,7 +277,7 @@ const sourceFiles = (
|
||||
"schemas",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
].map(async (target) => {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
@@ -299,8 +323,8 @@ const sbom = {
|
||||
metadata: {
|
||||
component: {
|
||||
type: "application",
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
name: String(packageJson.name ?? ""),
|
||||
version: String(packageJson.version ?? ""),
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
@@ -328,7 +352,7 @@ const provenance = {
|
||||
buildType: "https://vite.dev/build/v1",
|
||||
externalParameters: {
|
||||
nodeVersion: process.version,
|
||||
packageManager: packageJson.packageManager,
|
||||
packageManager: String(packageJson.packageManager ?? ""),
|
||||
},
|
||||
internalParameters: {
|
||||
sourceSetSha256,
|
||||
@@ -361,12 +385,12 @@ const coherence = verifySupplyChainCoherence(
|
||||
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
|
||||
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
|
||||
: null;
|
||||
const attestationSubject =
|
||||
/** @type {Record<string, unknown>} */ (
|
||||
/** @type {Record<string, unknown>} */ (
|
||||
attestationInput?.subject ?? {}
|
||||
).digest ?? {}
|
||||
);
|
||||
const attestation = isRecord(attestationInput?.subject)
|
||||
? attestationInput.subject
|
||||
: {};
|
||||
const attestationSubject = isRecord(attestation.digest)
|
||||
? attestation.digest
|
||||
: {};
|
||||
const attestationPassed =
|
||||
attestationSubject.sha256 === distDigest &&
|
||||
typeof attestationInput?.provider === "string" &&
|
||||
@@ -416,7 +440,7 @@ await writeFile(
|
||||
generatedAt: new Date().toISOString(),
|
||||
context: {
|
||||
nodeVersion: process.version,
|
||||
packageManager: packageJson.packageManager,
|
||||
packageManager: String(packageJson.packageManager ?? ""),
|
||||
runnerImage:
|
||||
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
},
|
||||
@@ -0,0 +1,96 @@
|
||||
export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([
|
||||
"VITE_BUILD_ID",
|
||||
"VITE_COMMIT_SHA",
|
||||
"RELEASE_ID",
|
||||
"CI_RUNNER_IMAGE",
|
||||
"SOURCE_DATE_EPOCH",
|
||||
]);
|
||||
|
||||
export function ciBuildEnvironmentFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter(
|
||||
(name) => !environment[name]?.trim(),
|
||||
).map((name) => `missing required CI build environment: ${name}`);
|
||||
|
||||
const commitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (commitSha && !isValidCommitSha(commitSha)) {
|
||||
failures.push(
|
||||
"VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (sourceDateEpoch && !isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
failures.push("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
|
||||
const runnerImage = environment.CI_RUNNER_IMAGE?.trim();
|
||||
if (
|
||||
runnerImage &&
|
||||
!/@sha256:[0-9a-f]{64}$/i.test(runnerImage)
|
||||
) {
|
||||
failures.push(
|
||||
"CI_RUNNER_IMAGE must end with an immutable @sha256 image digest",
|
||||
);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function assertCiBuildEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const failures = ciBuildEnvironmentFailures(environment);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join("; "));
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidCommitSha(value: string) {
|
||||
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
|
||||
}
|
||||
|
||||
export function isValidSourceDateEpoch(value: string) {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const milliseconds = Number(value) * 1_000;
|
||||
return Number.isSafeInteger(milliseconds) && Number.isFinite(
|
||||
new Date(milliseconds).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function ciCheckoutIdentityFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
checkout: { commitSha: string; sourceDateEpoch: string },
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = [];
|
||||
const configuredCommitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (
|
||||
configuredCommitSha &&
|
||||
configuredCommitSha.toLowerCase() !== checkout.commitSha.toLowerCase()
|
||||
) {
|
||||
failures.push("VITE_COMMIT_SHA does not identify the checked-out commit");
|
||||
}
|
||||
const configuredEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (configuredEpoch && configuredEpoch !== checkout.sourceDateEpoch) {
|
||||
failures.push(
|
||||
"SOURCE_DATE_EPOCH does not match the checked-out commit timestamp",
|
||||
);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function buildDate(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (!sourceDateEpoch) return new Date();
|
||||
if (!isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
throw new Error("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
return new Date(Number(sourceDateEpoch) * 1_000);
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* file: string,
|
||||
* isEntry?: boolean,
|
||||
* imports?: string[]
|
||||
* }} ViteManifestEntry
|
||||
*/
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
isEntry?: boolean;
|
||||
imports?: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Static imports of an entry are part of initial JavaScript. Every remaining
|
||||
* JavaScript output is governed by the lazy-chunk budget.
|
||||
*
|
||||
* @param {Record<string, ViteManifestEntry>} manifest
|
||||
*/
|
||||
export function classifyViteJavascript(manifest) {
|
||||
const initialFiles = new Set();
|
||||
const visitedKeys = new Set();
|
||||
export function classifyViteJavascript(
|
||||
manifest: Readonly<Record<string, ViteManifestEntry>>,
|
||||
) {
|
||||
const initialFiles = new Set<string>();
|
||||
const visitedKeys = new Set<string>();
|
||||
const pendingKeys = Object.entries(manifest)
|
||||
.filter(([, entry]) => entry.isEntry)
|
||||
.map(([key]) => key);
|
||||
const missingImports = [];
|
||||
const missingImports: string[] = [];
|
||||
|
||||
while (pendingKeys.length > 0) {
|
||||
const key = /** @type {string} */ (pendingKeys.pop());
|
||||
const key = pendingKeys.pop();
|
||||
if (key === undefined) break;
|
||||
if (visitedKeys.has(key)) continue;
|
||||
visitedKeys.add(key);
|
||||
const entry = manifest[key];
|
||||
@@ -68,15 +68,10 @@ const fieldEvidenceInputSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {string | undefined} configuredMinimum
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export function validateFieldEvidenceInput(
|
||||
input,
|
||||
configuredMinimum,
|
||||
now = new Date(),
|
||||
input: unknown,
|
||||
configuredMinimum: string | undefined,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const parsed = fieldEvidenceInputSchema.safeParse(input);
|
||||
const failures = parsed.success
|
||||
@@ -4,15 +4,21 @@ const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
|
||||
* A release gate must not promote a local preview server as live hosting
|
||||
* evidence.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {
|
||||
* | { passed: true; reason: null; url: URL; observedOrigin: string }
|
||||
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
|
||||
* }
|
||||
*/
|
||||
export function classifyLiveHostingBaseUrl(value) {
|
||||
/** @type {URL} */
|
||||
let url;
|
||||
export function classifyLiveHostingBaseUrl(value: string):
|
||||
| {
|
||||
passed: true;
|
||||
reason: null;
|
||||
url: URL;
|
||||
observedOrigin: string;
|
||||
}
|
||||
| {
|
||||
passed: false;
|
||||
reason: string;
|
||||
url: URL | null;
|
||||
observedOrigin: string | null;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
|
||||
@@ -15,19 +15,18 @@ const REVIEW_FIELDS = Object.freeze([
|
||||
"Screen reader",
|
||||
]);
|
||||
|
||||
/** @param {string} content */
|
||||
export function validateManualA11yEvidence(content) {
|
||||
export function validateManualA11yEvidence(content: string) {
|
||||
const fields = Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
|
||||
.filter(Boolean)
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => [
|
||||
/** @type {RegExpExecArray} */ (match)[1].trim(),
|
||||
/** @type {RegExpExecArray} */ (match)[2].trim(),
|
||||
match[1].trim(),
|
||||
match[2].trim(),
|
||||
]),
|
||||
);
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
if (fields.Status !== "reviewed") failures.push("Status");
|
||||
if (!fields["Route ID"]) failures.push("Route ID");
|
||||
if (!fields["Release ID"]) failures.push("Release ID");
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import {
|
||||
build,
|
||||
normalizePath,
|
||||
type Plugin,
|
||||
version as viteVersion,
|
||||
} from "vite";
|
||||
|
||||
const RECIPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
|
||||
|
||||
type EmittedOutput =
|
||||
| Readonly<{
|
||||
type: "chunk";
|
||||
fileName: string;
|
||||
code: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: "asset";
|
||||
fileName: string;
|
||||
source: string | Uint8Array;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleOutput = Readonly<{
|
||||
fileName: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleMeasurement = Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
sourceFileCount: number;
|
||||
toolchain: Readonly<{
|
||||
bundler: "vite";
|
||||
viteVersion: string;
|
||||
mode: "production";
|
||||
target: "es2022";
|
||||
format: "es";
|
||||
minifier: "esbuild";
|
||||
treeshake: false;
|
||||
compression: "node-zlib-gzip";
|
||||
}>;
|
||||
outputs: readonly OptionalRecipeBundleOutput[];
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
bundleBudgetGzipBytes: number;
|
||||
remainingGzipBytes: number;
|
||||
sha256: string;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Builds an uncomposed reference runtime as a synthetic production consumer.
|
||||
* Every catalog-owned source module is exposed as an entry namespace and
|
||||
* tree-shaking is disabled so internal fail-closed paths remain in the budget.
|
||||
*/
|
||||
export async function measureOptionalRecipeBundle(input: Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
bundleBudgetGzipBytes: number;
|
||||
workspaceRoot?: string;
|
||||
}>): Promise<OptionalRecipeBundleMeasurement> {
|
||||
const recipeId = validateRecipeId(input.recipeId);
|
||||
const bundleBudgetGzipBytes = positiveSafeInteger(
|
||||
input.bundleBudgetGzipBytes,
|
||||
"Optional recipe bundle budget",
|
||||
);
|
||||
const workspaceRoot = await realpath(
|
||||
path.resolve(input.workspaceRoot ?? process.cwd()),
|
||||
);
|
||||
const sourceRoots = validateSourceRoots(input.sourceRoots);
|
||||
const sourceFiles = await resolveSourceFiles(
|
||||
workspaceRoot,
|
||||
sourceRoots,
|
||||
);
|
||||
const virtualEntry =
|
||||
`virtual:optional-reference-runtime-entry/${recipeId}`;
|
||||
const resolvedVirtualEntry = `\0${virtualEntry}`;
|
||||
const entrySource = sourceFiles
|
||||
.map(
|
||||
(sourceFile, index) =>
|
||||
`export * as source${index} from ${JSON.stringify(
|
||||
viteSourceSpecifier(sourceFile),
|
||||
)};`,
|
||||
)
|
||||
.join("\n")
|
||||
.concat("\n");
|
||||
const preservePublicEntryPlugin = {
|
||||
name: "optional-reference-runtime-entry",
|
||||
enforce: "pre",
|
||||
resolveId(id) {
|
||||
return id === virtualEntry ? resolvedVirtualEntry : null;
|
||||
},
|
||||
load(id) {
|
||||
return id === resolvedVirtualEntry ? entrySource : null;
|
||||
},
|
||||
options(options) {
|
||||
return {
|
||||
...options,
|
||||
preserveEntrySignatures: "strict",
|
||||
};
|
||||
},
|
||||
} satisfies Plugin;
|
||||
const buildResult = await build({
|
||||
root: workspaceRoot,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
mode: "production",
|
||||
publicDir: false,
|
||||
clearScreen: false,
|
||||
logLevel: "silent",
|
||||
plugins: [preservePublicEntryPlugin],
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: "esbuild",
|
||||
sourcemap: false,
|
||||
write: false,
|
||||
emptyOutDir: false,
|
||||
copyPublicDir: false,
|
||||
cssCodeSplit: false,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
input: virtualEntry,
|
||||
// Budget the complete selected runtime, including internal fail-closed
|
||||
// guards that a synthetic consumer cannot predict it will exercise.
|
||||
treeshake: false,
|
||||
output: {
|
||||
format: "es",
|
||||
entryFileNames: `${recipeId}.js`,
|
||||
chunkFileNames: `${recipeId}-chunk-[hash].js`,
|
||||
assetFileNames: `${recipeId}-asset-[name]-[hash][extname]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const emitted = emittedOutputs(buildResult);
|
||||
if (emitted.length === 0) {
|
||||
throw new TypeError("Optional recipe bundle emitted no output.");
|
||||
}
|
||||
const outputs = emitted
|
||||
.map((output) => {
|
||||
const bytes = outputBytes(output);
|
||||
return Object.freeze({
|
||||
fileName: output.fileName,
|
||||
bytes: bytes.byteLength,
|
||||
gzipBytes: gzipSync(bytes).byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
})
|
||||
.sort((left, right) => compareText(left.fileName, right.fileName));
|
||||
const aggregateHash = createHash("sha256");
|
||||
for (const output of outputs) {
|
||||
aggregateHash.update(output.fileName);
|
||||
aggregateHash.update("\0");
|
||||
aggregateHash.update(output.sha256);
|
||||
aggregateHash.update("\0");
|
||||
}
|
||||
const bytes = outputs.reduce(
|
||||
(total, output) => total + output.bytes,
|
||||
0,
|
||||
);
|
||||
const gzipBytes = outputs.reduce(
|
||||
(total, output) => total + output.gzipBytes,
|
||||
0,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
recipeId,
|
||||
sourceRoots: Object.freeze([...sourceRoots]),
|
||||
sourceFileCount: sourceFiles.length,
|
||||
toolchain: Object.freeze({
|
||||
bundler: "vite" as const,
|
||||
viteVersion,
|
||||
mode: "production" as const,
|
||||
target: "es2022" as const,
|
||||
format: "es" as const,
|
||||
minifier: "esbuild" as const,
|
||||
treeshake: false as const,
|
||||
compression: "node-zlib-gzip" as const,
|
||||
}),
|
||||
outputs: Object.freeze(outputs),
|
||||
bytes,
|
||||
gzipBytes,
|
||||
bundleBudgetGzipBytes,
|
||||
remainingGzipBytes: bundleBudgetGzipBytes - gzipBytes,
|
||||
sha256: aggregateHash.digest("hex"),
|
||||
passed: gzipBytes <= bundleBudgetGzipBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceFiles(
|
||||
workspaceRoot: string,
|
||||
sourceRoots: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
const sourceBoundary = await realpath(path.join(workspaceRoot, "src"));
|
||||
const discovered: string[] = [];
|
||||
for (const sourceRoot of sourceRoots) {
|
||||
const target = path.resolve(workspaceRoot, sourceRoot);
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
const rootMetadata = await lstat(target);
|
||||
if (rootMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source root cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(await realpath(target), sourceBoundary);
|
||||
if (
|
||||
rootMetadata.isFile() &&
|
||||
(!SOURCE_EXTENSION.test(target) || DECLARATION_FILE.test(target))
|
||||
) {
|
||||
throw new TypeError("Optional recipe source root is not executable source.");
|
||||
}
|
||||
discovered.push(
|
||||
...(await collectExecutableSources(target, sourceBoundary)),
|
||||
);
|
||||
}
|
||||
const unique = [...new Set(discovered)].sort((left, right) =>
|
||||
compareText(
|
||||
normalizePath(path.relative(workspaceRoot, left)),
|
||||
normalizePath(path.relative(workspaceRoot, right)),
|
||||
),
|
||||
);
|
||||
if (unique.length === 0) {
|
||||
throw new TypeError("Optional recipe source roots contain no executable source.");
|
||||
}
|
||||
return Object.freeze(unique);
|
||||
}
|
||||
|
||||
async function collectExecutableSources(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): Promise<string[]> {
|
||||
const metadata = await lstat(target);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
if (metadata.isFile()) {
|
||||
return SOURCE_EXTENSION.test(target) && !DECLARATION_FILE.test(target)
|
||||
? [target]
|
||||
: [];
|
||||
}
|
||||
if (!metadata.isDirectory()) return [];
|
||||
const entries = (await readdir(target, { withFileTypes: true })).sort(
|
||||
(left, right) => compareText(left.name, right.name),
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) =>
|
||||
collectExecutableSources(
|
||||
path.join(target, entry.name),
|
||||
sourceBoundary,
|
||||
),
|
||||
),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function emittedOutputs(value: unknown): readonly EmittedOutput[] {
|
||||
const buildOutputs = Array.isArray(value) ? value : [value];
|
||||
const emitted: EmittedOutput[] = [];
|
||||
for (const buildOutput of buildOutputs) {
|
||||
if (!isRecord(buildOutput) || !Array.isArray(buildOutput.output)) {
|
||||
throw new TypeError("Optional recipe bundle output is invalid.");
|
||||
}
|
||||
for (const output of buildOutput.output) {
|
||||
if (!isRecord(output)) {
|
||||
throw new TypeError("Optional recipe emitted output is invalid.");
|
||||
}
|
||||
if (
|
||||
output.type === "chunk" &&
|
||||
typeof output.fileName === "string" &&
|
||||
typeof output.code === "string"
|
||||
) {
|
||||
emitted.push({
|
||||
type: "chunk",
|
||||
fileName: output.fileName,
|
||||
code: output.code,
|
||||
});
|
||||
} else if (
|
||||
output.type === "asset" &&
|
||||
typeof output.fileName === "string" &&
|
||||
(typeof output.source === "string" ||
|
||||
output.source instanceof Uint8Array)
|
||||
) {
|
||||
emitted.push({
|
||||
type: "asset",
|
||||
fileName: output.fileName,
|
||||
source: output.source,
|
||||
});
|
||||
} else {
|
||||
throw new TypeError("Optional recipe emitted output shape is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
function outputBytes(output: EmittedOutput): Buffer {
|
||||
if (output.type === "chunk") {
|
||||
return Buffer.from(output.code, "utf8");
|
||||
}
|
||||
return Buffer.from(output.source);
|
||||
}
|
||||
|
||||
function validateRecipeId(value: unknown): string {
|
||||
if (typeof value !== "string" || !RECIPE_ID.test(value)) {
|
||||
throw new TypeError("Optional recipe ID is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSourceRoots(value: unknown): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.length > 32 ||
|
||||
value.some(
|
||||
(sourceRoot) =>
|
||||
typeof sourceRoot !== "string" ||
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot
|
||||
.split("/")
|
||||
.some(
|
||||
(segment) =>
|
||||
segment.length === 0 || segment === "." || segment === "..",
|
||||
),
|
||||
) ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
throw new TypeError("Optional recipe source roots are invalid.");
|
||||
}
|
||||
return Object.freeze([...value].sort(compareText));
|
||||
}
|
||||
|
||||
function assertInsideSourceBoundary(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): void {
|
||||
const relative = path.relative(sourceBoundary, target);
|
||||
if (
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError("Optional recipe source escaped the source boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
function viteSourceSpecifier(sourceFile: string): string {
|
||||
return pathToFileURL(sourceFile).href;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
throw new TypeError(`${name} is invalid.`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const REQUIRED_RECIPE_IDS = Object.freeze([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"feature-flag",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"large-data-ui",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
const lifecycleRecipes = new Set([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
/** @param {unknown} value */
|
||||
function nonEmptyStrings(value) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((entry) => typeof entry === "string" && entry.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {Readonly<Record<string, unknown>>} packageDocument
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function validateRecipeCatalog(input, packageDocument) {
|
||||
const document =
|
||||
/** @type {Record<string, any>} */ (
|
||||
input && typeof input === "object" ? input : {}
|
||||
);
|
||||
/** @type {string[]} */
|
||||
const violations = [];
|
||||
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
|
||||
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
|
||||
if (document.defaultStatus !== "NOT_INSTALLED") {
|
||||
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(document.productionRuntimeDependencies) ||
|
||||
document.productionRuntimeDependencies.length > 0
|
||||
) {
|
||||
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
|
||||
}
|
||||
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
|
||||
violations.push("VENDOR_PATTERN_CATALOG");
|
||||
}
|
||||
if (!Array.isArray(document.recipes)) {
|
||||
return [...violations, "RECIPE_CATALOG_MISSING"];
|
||||
}
|
||||
|
||||
const actualIds = document.recipes
|
||||
.map(/** @param {Record<string, unknown>} recipe */ (recipe) => recipe.id)
|
||||
.sort();
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
|
||||
violations.push("RECIPE_ID_SET");
|
||||
}
|
||||
if (new Set(actualIds).size !== actualIds.length) {
|
||||
violations.push("RECIPE_ID_DUPLICATE");
|
||||
}
|
||||
|
||||
for (const recipe of document.recipes) {
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (recipe.status !== "RECIPE_AVAILABLE") {
|
||||
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
|
||||
}
|
||||
for (const field of [
|
||||
"trigger",
|
||||
"boundary",
|
||||
"port",
|
||||
"fake",
|
||||
"owner",
|
||||
"fallback",
|
||||
"serverStatePolicy",
|
||||
]) {
|
||||
if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"forbiddenWhen",
|
||||
"failureKinds",
|
||||
"securityPrivacy",
|
||||
"removal",
|
||||
]) {
|
||||
if (!nonEmptyStrings(recipe[field])) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
|
||||
recipe.bundleBudgetGzipBytes < 1
|
||||
) {
|
||||
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
|
||||
}
|
||||
if (recipe.owner === "frontend-platform") {
|
||||
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
|
||||
}
|
||||
if (
|
||||
lifecycleRecipes.has(id) &&
|
||||
!nonEmptyStrings(recipe.lifecycleMethods)
|
||||
) {
|
||||
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
|
||||
}
|
||||
if (
|
||||
id === "client-workflow" &&
|
||||
recipe.serverStatePolicy !== "reference-only"
|
||||
) {
|
||||
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
.../** @type {Record<string, string>} */ (packageDocument.dependencies ?? {}),
|
||||
.../** @type {Record<string, string>} */ (
|
||||
packageDocument.devDependencies ?? {}
|
||||
),
|
||||
};
|
||||
for (const pattern of document.vendorPackagePatterns ?? []) {
|
||||
const wildcard = String(pattern).endsWith("*");
|
||||
const prefix = String(pattern).replace(/\/?\*$/, "");
|
||||
if (
|
||||
Object.keys(dependencies).some(
|
||||
(dependency) =>
|
||||
dependency === prefix ||
|
||||
dependency.startsWith(`${prefix}/`) ||
|
||||
(wildcard && dependency.startsWith(prefix)),
|
||||
)
|
||||
) {
|
||||
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
export async function sourceFiles(directory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(target)
|
||||
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root
|
||||
* @param {{scanProductionBoundary?: boolean}} [options]
|
||||
*/
|
||||
export async function scanOptionalRecipeSources(
|
||||
root,
|
||||
{ scanProductionBoundary = true } = {},
|
||||
) {
|
||||
/** @type {Array<{ruleId: string; path: string}>} */
|
||||
const violations = [];
|
||||
for (const file of await sourceFiles(root)) {
|
||||
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
|
||||
const content = await readFile(file, "utf8");
|
||||
const imports = [
|
||||
...content.matchAll(
|
||||
/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
(relativeToRoot.startsWith("src/") ||
|
||||
(path.basename(path.resolve(root)) === "src" &&
|
||||
!relativeToRoot.startsWith(".."))) &&
|
||||
imports.some((specifier) =>
|
||||
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
|
||||
}
|
||||
|
||||
const localVendorAdapter =
|
||||
relative.includes("recipes/") && relative.includes("/adapters/");
|
||||
if (
|
||||
!localVendorAdapter &&
|
||||
imports.some((specifier) =>
|
||||
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
|
||||
specifier,
|
||||
),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", path: relative });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** @param {string} distRoot */
|
||||
export async function scanProductionBundle(distRoot) {
|
||||
/** @type {string[]} */
|
||||
const violations = [];
|
||||
for (const file of await sourceFiles(distRoot)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
if (content.includes("frontend-optional-recipe-must-not-reach-production")) {
|
||||
violations.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const REQUIRED_RECIPE_IDS = Object.freeze([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"feature-flag",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"large-data-ui",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
] as const);
|
||||
|
||||
const lifecycleRecipes: ReadonlySet<string> = new Set([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
export type OptionalRecipeSourceViolation = Readonly<{
|
||||
ruleId: string;
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function nonEmptyStrings(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function packageVersions(value: unknown): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(recordValue(value)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateRecipeCatalog(
|
||||
input: unknown,
|
||||
packageDocument: Document,
|
||||
): string[] {
|
||||
const document = recordValue(input);
|
||||
const violations: string[] = [];
|
||||
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
|
||||
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
|
||||
if (document.defaultStatus !== "NOT_INSTALLED") {
|
||||
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(document.productionRuntimeDependencies) ||
|
||||
document.productionRuntimeDependencies.length > 0
|
||||
) {
|
||||
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
|
||||
}
|
||||
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
|
||||
violations.push("VENDOR_PATTERN_CATALOG");
|
||||
}
|
||||
if (!Array.isArray(document.recipes)) {
|
||||
return [...violations, "RECIPE_CATALOG_MISSING"];
|
||||
}
|
||||
|
||||
const recipes = recordRows(document.recipes);
|
||||
const actualIds = recipes.map((recipe) => String(recipe.id ?? "")).sort();
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
|
||||
violations.push("RECIPE_ID_SET");
|
||||
}
|
||||
if (new Set(actualIds).size !== actualIds.length) {
|
||||
violations.push("RECIPE_ID_DUPLICATE");
|
||||
}
|
||||
|
||||
for (const recipe of recipes) {
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (recipe.status !== "RECIPE_AVAILABLE") {
|
||||
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
|
||||
}
|
||||
for (const field of [
|
||||
"trigger",
|
||||
"boundary",
|
||||
"port",
|
||||
"fake",
|
||||
"owner",
|
||||
"fallback",
|
||||
"serverStatePolicy",
|
||||
] as const) {
|
||||
const value = recipe[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"forbiddenWhen",
|
||||
"failureKinds",
|
||||
"securityPrivacy",
|
||||
"removal",
|
||||
] as const) {
|
||||
if (!nonEmptyStrings(recipe[field])) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof recipe.bundleBudgetGzipBytes !== "number" ||
|
||||
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
|
||||
recipe.bundleBudgetGzipBytes < 1
|
||||
) {
|
||||
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
|
||||
}
|
||||
if (recipe.owner === "frontend-platform") {
|
||||
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
|
||||
}
|
||||
if (lifecycleRecipes.has(id) && !nonEmptyStrings(recipe.lifecycleMethods)) {
|
||||
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
|
||||
}
|
||||
if (
|
||||
id === "client-workflow" &&
|
||||
recipe.serverStatePolicy !== "reference-only"
|
||||
) {
|
||||
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
...packageVersions(packageDocument.dependencies),
|
||||
...packageVersions(packageDocument.devDependencies),
|
||||
};
|
||||
const packageScripts = packageVersions(packageDocument.scripts);
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.referenceRuntime === undefined) continue;
|
||||
const runtime = recordValue(recipe.referenceRuntime);
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (
|
||||
runtime.status !== "AVAILABLE_NOT_COMPOSED" ||
|
||||
runtime.productionComposition !== false
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_COMPOSITION`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.sourceRoots)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_ROOTS`);
|
||||
} else if (
|
||||
runtime.sourceRoots.some(
|
||||
(sourceRoot) =>
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot.split("/").includes(".."),
|
||||
)
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_BOUNDARY`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.coveredCapabilities)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CAPABILITIES`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.conformanceScripts)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CONFORMANCE`);
|
||||
} else {
|
||||
for (const script of runtime.conformanceScripts) {
|
||||
if (!(script in packageScripts)) {
|
||||
violations.push(`${id}:UNKNOWN_CONFORMANCE_SCRIPT:${script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const vendorPatterns = Array.isArray(document.vendorPackagePatterns)
|
||||
? document.vendorPackagePatterns.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: [];
|
||||
for (const pattern of vendorPatterns) {
|
||||
const wildcard = pattern.endsWith("*");
|
||||
const prefix = pattern.replace(/\/?\*$/, "");
|
||||
if (
|
||||
Object.keys(dependencies).some(
|
||||
(dependency) =>
|
||||
dependency === prefix ||
|
||||
dependency.startsWith(`${prefix}/`) ||
|
||||
(wildcard && dependency.startsWith(prefix)),
|
||||
)
|
||||
) {
|
||||
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export async function sourceFiles(directory: string): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isRecord(error) &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const groups: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(target)
|
||||
: /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
export async function scanOptionalRecipeSources(
|
||||
root: string,
|
||||
{ scanProductionBoundary = true }: Readonly<{
|
||||
scanProductionBoundary?: boolean;
|
||||
}> = {},
|
||||
): Promise<OptionalRecipeSourceViolation[]> {
|
||||
const violations: OptionalRecipeSourceViolation[] = [];
|
||||
for (const file of await sourceFiles(root)) {
|
||||
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
||||
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
|
||||
const content = await readFile(file, "utf8");
|
||||
const imports = [
|
||||
...content.matchAll(/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier): specifier is string => specifier !== undefined);
|
||||
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
(relativeToRoot.startsWith("src/") ||
|
||||
(path.basename(path.resolve(root)) === "src" &&
|
||||
!relativeToRoot.startsWith(".."))) &&
|
||||
imports.some((specifier) =>
|
||||
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
|
||||
}
|
||||
|
||||
const productionRelative =
|
||||
path.basename(path.resolve(root)) === "src"
|
||||
? `src/${relativeToRoot}`
|
||||
: relativeToRoot;
|
||||
const isCompositionSource =
|
||||
/(?:^|\/)src\/bootstrap\//.test(productionRelative) ||
|
||||
/(?:^|\/)src\/features\/installed-feature-(?:adapters|runtimes)\./.test(
|
||||
productionRelative,
|
||||
);
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
isCompositionSource &&
|
||||
(imports.some((specifier) =>
|
||||
/(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|$)/.test(
|
||||
specifier,
|
||||
),
|
||||
) ||
|
||||
/["'][^"'\r\n]*(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|["'])/u.test(
|
||||
content,
|
||||
))
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION",
|
||||
path: relative,
|
||||
});
|
||||
}
|
||||
|
||||
const localVendorAdapter =
|
||||
relative.includes("recipes/") && relative.includes("/adapters/");
|
||||
if (
|
||||
!localVendorAdapter &&
|
||||
imports.some((specifier) =>
|
||||
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
|
||||
specifier,
|
||||
),
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
|
||||
content,
|
||||
) ||
|
||||
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
|
||||
content,
|
||||
)
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE",
|
||||
path: relative,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export async function scanProductionBundle(
|
||||
distRoot: string,
|
||||
): Promise<string[]> {
|
||||
const violations: string[] = [];
|
||||
const forbiddenRuntimeMarkers = [
|
||||
"frontend-optional-recipe-must-not-reach-production",
|
||||
"Browser file runtime hard limits are invalid.",
|
||||
"Object URL allocation failed",
|
||||
"Storage pressure policy is invalid.",
|
||||
"IndexedDB runtime configuration is invalid.",
|
||||
"Invalid IndexedDB schema migration.",
|
||||
"OPFS runtime policy is invalid.",
|
||||
"OPFS operation failed.",
|
||||
"Public Cache Storage policy is invalid.",
|
||||
"Public cache validation failed.",
|
||||
"Presigned capability vault limit is invalid.",
|
||||
"Resumable upload policy is invalid.",
|
||||
"Image CDN policy registry is invalid.",
|
||||
] as const;
|
||||
for (const file of await sourceFiles(distRoot)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
if (forbiddenRuntimeMarkers.some((marker) => content.includes(marker))) {
|
||||
violations.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
|
||||
const viteManifestPath = path.join(distRoot, ".vite/manifest.json");
|
||||
const emittedModuleInventoryPath = path.join(
|
||||
distRoot,
|
||||
".vite/module-inventory.json",
|
||||
);
|
||||
const moduleInventoryCandidates = [
|
||||
emittedModuleInventoryPath,
|
||||
...(path.resolve(distRoot) === path.resolve("dist")
|
||||
? ["artifacts/quality/vite-module-inventory.json"]
|
||||
: []),
|
||||
];
|
||||
const viteManifestExists = await readFile(viteManifestPath, "utf8")
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
if (isRecord(error) && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
});
|
||||
if (!viteManifestExists) return [...new Set(violations)];
|
||||
|
||||
let inventory: unknown;
|
||||
let moduleInventoryPath = emittedModuleInventoryPath;
|
||||
for (const candidate of moduleInventoryCandidates) {
|
||||
try {
|
||||
inventory = JSON.parse(await readFile(candidate, "utf8"));
|
||||
moduleInventoryPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// A generated build may move the inventory out of the deploy directory.
|
||||
}
|
||||
}
|
||||
if (inventory === undefined) {
|
||||
violations.push(
|
||||
path.relative(process.cwd(), moduleInventoryPath),
|
||||
);
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
const inventoryDocument = recordValue(inventory);
|
||||
const chunks = recordRows(inventoryDocument.chunks);
|
||||
if (
|
||||
inventoryDocument.schemaVersion !== 1 ||
|
||||
!Array.isArray(inventoryDocument.chunks) ||
|
||||
chunks.length !== inventoryDocument.chunks.length
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
|
||||
const forbiddenSourcePrefixes = [
|
||||
"src/application/ports/browser-file-storage/",
|
||||
"src/application/ports/browser-transfer/",
|
||||
"src/adapters/browser-file-storage/",
|
||||
"src/adapters/browser-files/",
|
||||
"src/adapters/browser-transfer/",
|
||||
"src/adapters/cache-storage/",
|
||||
"src/adapters/storage/indexeddb/",
|
||||
"src/adapters/storage/opfs/",
|
||||
] as const;
|
||||
for (const chunk of chunks) {
|
||||
if (
|
||||
typeof chunk.fileName !== "string" ||
|
||||
!Array.isArray(chunk.modules) ||
|
||||
chunk.modules.some((moduleId) => typeof moduleId !== "string")
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
continue;
|
||||
}
|
||||
for (const moduleId of chunk.modules as string[]) {
|
||||
if (
|
||||
forbiddenSourcePrefixes.some((prefix) =>
|
||||
moduleId.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
violations.push(`${chunk.fileName}:${moduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type RealtimeBoundaryRuleId =
|
||||
| "NATIVE_REALTIME_API_OUTSIDE_ADAPTER"
|
||||
| "PRESENTATION_INTERVAL_OWNER"
|
||||
| "UNSELECTED_REALTIME_RUNTIME_COMPOSED";
|
||||
|
||||
export type RealtimeBoundaryViolation = Readonly<{
|
||||
ruleId: RealtimeBoundaryRuleId;
|
||||
file: string;
|
||||
line: number;
|
||||
}>;
|
||||
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?ts|tsx)$/u;
|
||||
const OWNED_NATIVE_ROOTS = [
|
||||
"src/adapters/realtime/",
|
||||
"src/adapters/web-push/",
|
||||
] as const;
|
||||
const REALTIME_ADAPTER_IMPORT =
|
||||
/(?:from\s*|import\s*\()\s*["'][^"']*\/adapters\/(?:realtime|web-push)(?:\/[^"']*)?["']/gu;
|
||||
const NATIVE_REALTIME_PATTERNS = [
|
||||
/\bnew\s+(?:WebSocket|EventSource|Notification)\s*\(/gu,
|
||||
/\bNotification\s*\.\s*requestPermission\s*\(/gu,
|
||||
/\.\s*showNotification\s*\(/gu,
|
||||
/\.\s*pushManager\s*\.\s*(?:subscribe|getSubscription)\s*\(/gu,
|
||||
/\bReflect\s*\.\s*get\s*\([^,]+,\s*["'](?:WebSocket|EventSource|Notification|pushManager)["']/gu,
|
||||
] as const;
|
||||
const PRESENTATION_INTERVAL = /\bsetInterval\s*\(/gu;
|
||||
|
||||
export async function scanRealtimeBoundaries(
|
||||
sourceRoot: string,
|
||||
): Promise<readonly RealtimeBoundaryViolation[]> {
|
||||
const absoluteRoot = path.resolve(sourceRoot);
|
||||
const files = await collectSourceFiles(absoluteRoot);
|
||||
const violations: RealtimeBoundaryViolation[] = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const logicalFile = logicalSourcePath(absoluteRoot, file);
|
||||
inspectFile(source, logicalFile, violations);
|
||||
}
|
||||
return Object.freeze(
|
||||
violations
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.ruleId.localeCompare(right.ruleId),
|
||||
)
|
||||
.map((violation) => Object.freeze(violation)),
|
||||
);
|
||||
}
|
||||
|
||||
function inspectFile(
|
||||
source: string,
|
||||
logicalFile: string,
|
||||
violations: RealtimeBoundaryViolation[],
|
||||
): void {
|
||||
const nativeOwned = OWNED_NATIVE_ROOTS.some((root) =>
|
||||
logicalFile.startsWith(root),
|
||||
);
|
||||
const presentationOwned =
|
||||
logicalFile.startsWith("src/presentation/") ||
|
||||
/^src\/features\/[^/]+\/presentation\//u.test(logicalFile);
|
||||
const compositionBoundary =
|
||||
logicalFile.startsWith("src/bootstrap/") ||
|
||||
/^src\/features\/installed-feature-/u.test(logicalFile);
|
||||
|
||||
const report = (
|
||||
ruleId: RealtimeBoundaryRuleId,
|
||||
index: number,
|
||||
): void => {
|
||||
violations.push({
|
||||
ruleId,
|
||||
file: logicalFile,
|
||||
line: lineAt(source, index),
|
||||
});
|
||||
};
|
||||
|
||||
if (!nativeOwned) {
|
||||
for (const pattern of NATIVE_REALTIME_PATTERNS) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
report(
|
||||
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
|
||||
match.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (presentationOwned) {
|
||||
for (const match of source.matchAll(PRESENTATION_INTERVAL)) {
|
||||
report("PRESENTATION_INTERVAL_OWNER", match.index);
|
||||
}
|
||||
}
|
||||
if (compositionBoundary) {
|
||||
for (const match of source.matchAll(REALTIME_ADAPTER_IMPORT)) {
|
||||
report("UNSELECTED_REALTIME_RUNTIME_COMPOSED", match.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSourceFiles(
|
||||
directory: string,
|
||||
): Promise<readonly string[]> {
|
||||
const output: string[] = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
!["node_modules", "dist", "artifacts", ".tmp"].includes(
|
||||
entry.name,
|
||||
)
|
||||
) {
|
||||
output.push(...(await collectSourceFiles(resolved)));
|
||||
} else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) {
|
||||
output.push(resolved);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function logicalSourcePath(root: string, file: string): string {
|
||||
const workspaceRelative = path
|
||||
.relative(process.cwd(), file)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
if (root === path.resolve("src")) return workspaceRelative;
|
||||
return path.relative(root, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1;
|
||||
for (let offset = 0; offset < index; offset += 1) {
|
||||
if (source.charCodeAt(offset) === 10) line += 1;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -5,17 +5,36 @@ export const COMPATIBILITY_IMPACTS = Object.freeze([
|
||||
"additive",
|
||||
"behavior-change",
|
||||
"breaking",
|
||||
]);
|
||||
] as const);
|
||||
|
||||
type CompatibilityImpact = (typeof COMPATIBILITY_IMPACTS)[number];
|
||||
type RegistryRecord = Record<string, unknown> & {
|
||||
registryId?: unknown;
|
||||
contract?: unknown;
|
||||
rows?: unknown;
|
||||
};
|
||||
type RegistryChange = {
|
||||
changeId: string;
|
||||
registryId: string;
|
||||
rowName: string;
|
||||
field: string;
|
||||
kind: string;
|
||||
impact: CompatibilityImpact;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
type RegistryDiff = Readonly<{
|
||||
impact: CompatibilityImpact;
|
||||
changes: readonly RegistryChange[];
|
||||
}>;
|
||||
|
||||
const impactRank = new Map(
|
||||
COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]),
|
||||
);
|
||||
|
||||
/** @param {unknown} value @returns {unknown} */
|
||||
export function canonicalizeRegistryValue(value) {
|
||||
export function canonicalizeRegistryValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const projected =
|
||||
/** @type {unknown[]} */ (value.map(canonicalizeRegistryValue));
|
||||
const projected: unknown[] = value.map(canonicalizeRegistryValue);
|
||||
return projected.every(
|
||||
(item) =>
|
||||
item === null ||
|
||||
@@ -36,64 +55,64 @@ export function canonicalizeRegistryValue(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value @returns {string} */
|
||||
export function canonicalRegistryJson(value) {
|
||||
export function canonicalRegistryJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined";
|
||||
}
|
||||
|
||||
/** @param {unknown} snapshot */
|
||||
export function registrySnapshotDigest(snapshot) {
|
||||
export function registrySnapshotDigest(snapshot: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(canonicalRegistryJson(snapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string} current @param {string} candidate */
|
||||
function strongestImpact(current, candidate) {
|
||||
function strongestImpact(
|
||||
current: CompatibilityImpact,
|
||||
candidate: CompatibilityImpact,
|
||||
): CompatibilityImpact {
|
||||
return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0)
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function valueType(value) {
|
||||
function valueType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registryId
|
||||
* @param {string} rowName
|
||||
* @param {string} field
|
||||
* @param {string} kind
|
||||
*/
|
||||
function changeId(registryId, rowName, field, kind) {
|
||||
function changeId(
|
||||
registryId: string,
|
||||
rowName: string,
|
||||
field: string,
|
||||
kind: string,
|
||||
): string {
|
||||
return `${registryId}:${rowName}:${field}:${kind}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a semantic diff. Object key and primitive-array ordering is
|
||||
* canonicalized before comparison and therefore cannot create a false change.
|
||||
*
|
||||
* @param {Readonly<Record<string, unknown>>} before
|
||||
* @param {Readonly<Record<string, unknown>>} after
|
||||
*/
|
||||
export function diffRegistrySnapshots(before, after) {
|
||||
const changes = /** @type {Array<Record<string, unknown>>} */ ([]);
|
||||
let impact = "none";
|
||||
const beforeRegistries =
|
||||
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (before.registries ?? []).map(
|
||||
(registry) => [String(registry.registryId), registry],
|
||||
),
|
||||
));
|
||||
const afterRegistries =
|
||||
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (after.registries ?? []).map(
|
||||
(registry) => [String(registry.registryId), registry],
|
||||
),
|
||||
));
|
||||
export function diffRegistrySnapshots(
|
||||
before: Readonly<Record<string, unknown>>,
|
||||
after: Readonly<Record<string, unknown>>,
|
||||
): RegistryDiff {
|
||||
const changes: RegistryChange[] = [];
|
||||
let impact: CompatibilityImpact = "none";
|
||||
const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[];
|
||||
const beforeRegistries = new Map<string, RegistryRecord>(
|
||||
beforeRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const afterRegistryRows = (after.registries ?? []) as RegistryRecord[];
|
||||
const afterRegistries = new Map<string, RegistryRecord>(
|
||||
afterRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const registryIds = new Set([
|
||||
...beforeRegistries.keys(),
|
||||
...afterRegistries.keys(),
|
||||
@@ -116,10 +135,8 @@ export function diffRegistrySnapshots(before, after) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousContract =
|
||||
/** @type {Record<string, unknown>} */ (previous.contract ?? {});
|
||||
const currentContract =
|
||||
/** @type {Record<string, unknown>} */ (current.contract ?? {});
|
||||
const previousContract = (previous.contract ?? {}) as Record<string, unknown>;
|
||||
const currentContract = (current.contract ?? {}) as Record<string, unknown>;
|
||||
const contractFields = new Set([
|
||||
...Object.keys(previousContract),
|
||||
...Object.keys(currentContract),
|
||||
@@ -156,16 +173,16 @@ export function diffRegistrySnapshots(before, after) {
|
||||
}
|
||||
|
||||
const breakingFields = new Set(
|
||||
/** @type {string[]} */ (
|
||||
currentContract.breakingFields ?? []
|
||||
),
|
||||
(currentContract.breakingFields ?? []) as string[],
|
||||
);
|
||||
const beforeRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (
|
||||
previous.rows ?? {}
|
||||
);
|
||||
const afterRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (current.rows ?? {});
|
||||
const beforeRows = (previous.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const afterRows = (current.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const rowNames = new Set([
|
||||
...Object.keys(beforeRows),
|
||||
...Object.keys(afterRows),
|
||||
@@ -209,8 +226,8 @@ export function diffRegistrySnapshots(before, after) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let kind;
|
||||
let changeImpact;
|
||||
let kind: string;
|
||||
let changeImpact: CompatibilityImpact;
|
||||
if (!beforeHas) {
|
||||
kind = "field-added";
|
||||
changeImpact = "additive";
|
||||
@@ -261,11 +278,10 @@ export function diffRegistrySnapshots(before, after) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} snapshot
|
||||
* @param {Readonly<Record<string, unknown>>} approval
|
||||
*/
|
||||
export function verifyRegistryBaselineApproval(snapshot, approval) {
|
||||
export function verifyRegistryBaselineApproval(
|
||||
snapshot: Readonly<Record<string, unknown>>,
|
||||
approval: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const actualDigest = registrySnapshotDigest(snapshot);
|
||||
const approvedDigest = approval.snapshotDigest;
|
||||
return Object.freeze({
|
||||
@@ -281,17 +297,15 @@ export function verifyRegistryBaselineApproval(snapshot, approval) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof diffRegistrySnapshots>} diff
|
||||
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
||||
*/
|
||||
export function validateBreakingEvidence(diff, evidenceFile) {
|
||||
const evidence = new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
evidenceFile.changes ?? []
|
||||
).map((entry) => [entry.changeId, entry]),
|
||||
export function validateBreakingEvidence(
|
||||
diff: RegistryDiff,
|
||||
evidenceFile: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const entries = (evidenceFile.changes ?? []) as Array<Record<string, unknown>>;
|
||||
const evidence = new Map<string, Record<string, unknown>>(
|
||||
entries.map((entry) => [String(entry.changeId), entry]),
|
||||
);
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
for (const change of diff.changes.filter(
|
||||
(entry) => entry.impact === "breaking",
|
||||
)) {
|
||||
@@ -307,7 +321,8 @@ export function validateBreakingEvidence(diff, evidenceFile) {
|
||||
"rollback",
|
||||
"owner",
|
||||
]) {
|
||||
if (typeof entry[field] !== "string" || entry[field].trim().length === 0) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
failures.push(
|
||||
`breaking change ${change.changeId} missing non-empty ${field}`,
|
||||
);
|
||||
@@ -1,547 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
/** @param {unknown} value @returns {unknown} */
|
||||
export function canonicalizeSupplyChainValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(canonicalizeSupplyChainValue)
|
||||
.sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function supplyChainDigest(value) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string} lockfile */
|
||||
export function parsePnpmLockfilePackages(lockfile) {
|
||||
const entries =
|
||||
/** @type {Array<{name: string, version: string, integrity: string}>} */ (
|
||||
[]
|
||||
);
|
||||
let inPackages = false;
|
||||
/** @type {{name: string, version: string, integrity: string} | null} */
|
||||
let current = null;
|
||||
|
||||
for (const line of lockfile.split(/\r?\n/)) {
|
||||
if (line === "packages:") {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "snapshots:") {
|
||||
if (current) entries.push(current);
|
||||
break;
|
||||
}
|
||||
if (!inPackages) continue;
|
||||
const packageMatch = line.match(/^ {2}(\S.*):$/);
|
||||
if (packageMatch) {
|
||||
if (current) entries.push(current);
|
||||
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
|
||||
const separator = key.lastIndexOf("@");
|
||||
current = {
|
||||
name: key.slice(0, separator),
|
||||
version: key.slice(separator + 1),
|
||||
integrity: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
|
||||
if (current && integrityMatch) {
|
||||
current.integrity = integrityMatch[1];
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} integrity */
|
||||
export function isValidSha512Integrity(integrity) {
|
||||
if (!integrity.startsWith("sha512-")) return false;
|
||||
try {
|
||||
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeLicense(raw) {
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === "object" &&
|
||||
"type" in raw &&
|
||||
typeof raw.type === "string"
|
||||
) {
|
||||
return raw.type;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
const licenses = raw.map(normalizeLicense).filter(
|
||||
(license) => license !== "NOASSERTION",
|
||||
);
|
||||
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
|
||||
}
|
||||
return "NOASSERTION";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} root
|
||||
* @param {Readonly<Record<string, string>>} directProduction
|
||||
* @param {Readonly<Record<string, string>>} directDevelopment
|
||||
*/
|
||||
export async function flattenPnpmDependencyTree(
|
||||
root,
|
||||
directProduction,
|
||||
directDevelopment,
|
||||
) {
|
||||
const records =
|
||||
/** @type {Map<string, {
|
||||
* name: string,
|
||||
* version: string,
|
||||
* direct: boolean,
|
||||
* scope: "production" | "development",
|
||||
* optional: boolean,
|
||||
* packagePath: string,
|
||||
* dependencies: Set<string>
|
||||
* }>} */ (new Map());
|
||||
const directIds = new Set();
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(directProduction, name) &&
|
||||
rawDependency &&
|
||||
typeof rawDependency === "object" &&
|
||||
!Array.isArray(rawDependency)
|
||||
) {
|
||||
directIds.add(
|
||||
`${name}@${String(
|
||||
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(directDevelopment, name) &&
|
||||
rawDependency &&
|
||||
typeof rawDependency === "object" &&
|
||||
!Array.isArray(rawDependency)
|
||||
) {
|
||||
directIds.add(
|
||||
`${name}@${String(
|
||||
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} node
|
||||
* @param {"production" | "development"} scope
|
||||
* @param {boolean} optionalPath
|
||||
*/
|
||||
function visit(node, scope, optionalPath) {
|
||||
for (const [groupName, group] of Object.entries({
|
||||
dependencies: node.dependencies,
|
||||
devDependencies: node.devDependencies,
|
||||
optionalDependencies: node.optionalDependencies,
|
||||
})) {
|
||||
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
|
||||
for (const [name, rawDependency] of Object.entries(group)) {
|
||||
if (
|
||||
!rawDependency ||
|
||||
typeof rawDependency !== "object" ||
|
||||
Array.isArray(rawDependency)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dependency =
|
||||
/** @type {Record<string, unknown>} */ (rawDependency);
|
||||
const version = String(dependency.version ?? "");
|
||||
const packagePath = String(dependency.path ?? "");
|
||||
const identity = `${name}@${version}`;
|
||||
const childScope =
|
||||
scope === "production" && groupName !== "devDependencies"
|
||||
? "production"
|
||||
: "development";
|
||||
const childOptional =
|
||||
optionalPath || groupName === "optionalDependencies";
|
||||
const previous = records.get(identity);
|
||||
const dependencies = previous?.dependencies ?? new Set();
|
||||
for (const childGroup of [
|
||||
dependency.dependencies,
|
||||
dependency.optionalDependencies,
|
||||
]) {
|
||||
if (
|
||||
!childGroup ||
|
||||
typeof childGroup !== "object" ||
|
||||
Array.isArray(childGroup)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const [childName, rawChild] of Object.entries(childGroup)) {
|
||||
if (
|
||||
rawChild &&
|
||||
typeof rawChild === "object" &&
|
||||
!Array.isArray(rawChild)
|
||||
) {
|
||||
dependencies.add(
|
||||
`${childName}@${String(rawChild.version ?? "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.set(identity, {
|
||||
name,
|
||||
version,
|
||||
direct: directIds.has(identity),
|
||||
scope:
|
||||
previous?.scope === "production" || childScope === "production"
|
||||
? "production"
|
||||
: "development",
|
||||
optional: previous ? previous.optional && childOptional : childOptional,
|
||||
packagePath: previous?.packagePath || packagePath,
|
||||
dependencies,
|
||||
});
|
||||
visit(dependency, childScope, childOptional);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productionRoot = {
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
||||
).filter(([name]) => Object.hasOwn(directProduction, name)),
|
||||
),
|
||||
};
|
||||
const developmentRoot = {
|
||||
devDependencies: Object.fromEntries(
|
||||
Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
||||
).filter(([name]) => Object.hasOwn(directDevelopment, name)),
|
||||
),
|
||||
};
|
||||
visit(productionRoot, "production", false);
|
||||
visit(developmentRoot, "development", false);
|
||||
|
||||
const result = [];
|
||||
for (const record of records.values()) {
|
||||
let license = "NOASSERTION";
|
||||
let optional = record.optional;
|
||||
if (record.packagePath) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
||||
);
|
||||
license = normalizeLicense(manifest.license ?? manifest.licenses);
|
||||
} catch {
|
||||
// Platform-specific optional packages may not be materialized locally.
|
||||
optional = true;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
direct: record.direct,
|
||||
scope: record.scope,
|
||||
optional,
|
||||
license,
|
||||
dependencies: [...record.dependencies].sort(),
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} before
|
||||
* @param {Readonly<Record<string, unknown>>} after
|
||||
*/
|
||||
export function diffDependencyInventories(before, after) {
|
||||
const beforeRows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (before.dependencies ?? []);
|
||||
const afterRows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (after.dependencies ?? []);
|
||||
const beforeMap = new Map(
|
||||
beforeRows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const afterMap = new Map(
|
||||
afterRows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
||||
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
||||
const changed = [];
|
||||
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
|
||||
if (
|
||||
supplyChainDigest(beforeMap.get(key)) !==
|
||||
supplyChainDigest(afterMap.get(key))
|
||||
) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
const upgrades = [];
|
||||
for (const removedKey of removed) {
|
||||
const previous = beforeMap.get(removedKey);
|
||||
const replacement = added.find(
|
||||
(addedKey) => afterMap.get(addedKey)?.name === previous?.name,
|
||||
);
|
||||
if (replacement) {
|
||||
upgrades.push({
|
||||
name: previous?.name,
|
||||
from: previous?.version,
|
||||
to: afterMap.get(replacement)?.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
added: Object.freeze(added.sort()),
|
||||
removed: Object.freeze(removed.sort()),
|
||||
changed: Object.freeze(changed.sort()),
|
||||
upgrades: Object.freeze(
|
||||
upgrades.sort((left, right) =>
|
||||
String(left.name).localeCompare(String(right.name)),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} policy
|
||||
*/
|
||||
export function validateLicensePolicy(inventory, policy) {
|
||||
const allowed = new Set(
|
||||
/** @type {string[]} */ (policy.allowedLicenses ?? []),
|
||||
);
|
||||
const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []);
|
||||
const failures = [];
|
||||
const results = [];
|
||||
for (const dependency of /** @type {Array<Record<string, unknown>>} */ (
|
||||
inventory.dependencies ?? []
|
||||
)) {
|
||||
const license = String(dependency.license ?? "NOASSERTION");
|
||||
const explicitlyDenied = denied.some((pattern) =>
|
||||
new RegExp(pattern, "i").test(license),
|
||||
);
|
||||
const unknownAccepted =
|
||||
license === "NOASSERTION" && dependency.optional === true;
|
||||
const passed =
|
||||
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
|
||||
results.push({
|
||||
package: `${dependency.name}@${dependency.version}`,
|
||||
license,
|
||||
passed,
|
||||
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
||||
});
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${dependency.name}@${dependency.version} has disallowed license ${license}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
results: Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof diffDependencyInventories>} diff
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
||||
*/
|
||||
export function validateDependencyReview(diff, inventory, evidenceFile) {
|
||||
const rows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (inventory.dependencies ?? []);
|
||||
const byIdentity = new Map(
|
||||
rows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const evidence = new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
evidenceFile.changes ?? []
|
||||
).map((entry) => [entry.changeId, entry]),
|
||||
);
|
||||
const highRisk = diff.added.filter((identity) => {
|
||||
const row = byIdentity.get(identity);
|
||||
return row?.direct === true && row.scope === "production";
|
||||
});
|
||||
const failures = [];
|
||||
for (const identity of highRisk) {
|
||||
const changeId = `add:${identity}`;
|
||||
const entry = evidence.get(changeId);
|
||||
if (!entry) {
|
||||
failures.push(`high-risk dependency missing review: ${changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of ["owner", "reviewer", "reason", "rollback"]) {
|
||||
if (typeof entry[field] !== "string" || !entry[field].trim()) {
|
||||
failures.push(`${changeId} missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.owner === entry.reviewer) {
|
||||
failures.push(`${changeId} may not be self-approved`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
highRisk: Object.freeze(highRisk),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank = new Map([
|
||||
["unknown", 0],
|
||||
["low", 1],
|
||||
["moderate", 2],
|
||||
["high", 3],
|
||||
["critical", 4],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} report
|
||||
* @param {Readonly<Record<string, unknown>>} policy
|
||||
* @param {Readonly<Record<string, unknown>>} exceptionFile
|
||||
* @param {string} lockfileSha256
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export function validateVulnerabilityReport(
|
||||
report,
|
||||
policy,
|
||||
exceptionFile,
|
||||
lockfileSha256,
|
||||
now = new Date(),
|
||||
) {
|
||||
const failures = [];
|
||||
if (report.scannedLockfileSha256 !== lockfileSha256) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (typeof report.provider !== "string" || !report.provider.trim()) {
|
||||
failures.push("vulnerability report provider missing");
|
||||
}
|
||||
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
|
||||
const exceptions =
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
exceptionFile.exceptions ?? []
|
||||
);
|
||||
const blocking = [];
|
||||
for (const finding of /** @type {Array<Record<string, unknown>>} */ (
|
||||
report.findings ?? []
|
||||
)) {
|
||||
const severity = String(finding.severity ?? "unknown").toLowerCase();
|
||||
if ((severityRank.get(severity) ?? 0) < threshold) continue;
|
||||
const exception = exceptions.find(
|
||||
(entry) =>
|
||||
entry.vulnerabilityId === finding.id &&
|
||||
entry.packageName === finding.packageName,
|
||||
);
|
||||
const expiry =
|
||||
typeof exception?.expiresAt === "string"
|
||||
? Date.parse(exception.expiresAt)
|
||||
: Number.NaN;
|
||||
const validException =
|
||||
exception &&
|
||||
typeof exception.owner === "string" &&
|
||||
exception.owner.trim() &&
|
||||
typeof exception.reviewer === "string" &&
|
||||
exception.reviewer.trim() &&
|
||||
exception.owner !== exception.reviewer &&
|
||||
typeof exception.reason === "string" &&
|
||||
exception.reason.trim() &&
|
||||
Number.isFinite(expiry) &&
|
||||
expiry > now.getTime();
|
||||
if (!validException) {
|
||||
blocking.push(
|
||||
`${finding.id}:${finding.packageName}@${finding.version}:${severity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0 && blocking.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
blocking: Object.freeze(blocking),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} sbom
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} provenance
|
||||
* @param {string} distDigest
|
||||
*/
|
||||
export function verifySupplyChainCoherence(
|
||||
sbom,
|
||||
inventory,
|
||||
provenance,
|
||||
distDigest,
|
||||
) {
|
||||
const failures = [];
|
||||
const componentCount = Array.isArray(sbom.components)
|
||||
? sbom.components.length
|
||||
: -1;
|
||||
const dependencyCount = Array.isArray(inventory.dependencies)
|
||||
? inventory.dependencies.length
|
||||
: -2;
|
||||
if (componentCount !== dependencyCount) {
|
||||
failures.push("SBOM component count does not match inventory");
|
||||
}
|
||||
const metadata =
|
||||
/** @type {Record<string, unknown>} */ (sbom.metadata ?? {});
|
||||
const properties =
|
||||
/** @type {Array<{name?: string, value?: string}>} */ (
|
||||
metadata.properties ?? []
|
||||
);
|
||||
if (properties.find(
|
||||
/** @param {{name?: string, value?: string}} property */
|
||||
(property) =>
|
||||
property.name === "ca:lockfileSha256" &&
|
||||
property.value === inventory.lockfileSha256,
|
||||
) === undefined) {
|
||||
failures.push("SBOM lockfile digest does not match inventory");
|
||||
}
|
||||
const subject =
|
||||
/** @type {Array<Record<string, unknown>>} */ (provenance.subject ?? [])[0];
|
||||
const subjectDigest =
|
||||
/** @type {Record<string, unknown>} */ (subject?.digest ?? {});
|
||||
if (subjectDigest.sha256 !== distDigest) {
|
||||
failures.push("provenance subject does not match built dist digest");
|
||||
}
|
||||
const predicate =
|
||||
/** @type {Record<string, unknown>} */ (provenance.predicate ?? {});
|
||||
const materials =
|
||||
/** @type {Record<string, unknown>} */ (predicate.materials ?? {});
|
||||
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
|
||||
failures.push("provenance lockfile material does not match inventory");
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export type DependencyScope = "production" | "development";
|
||||
export type LockfilePackage = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
integrity: string;
|
||||
}>;
|
||||
export type DependencyInventoryRow = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
license: string;
|
||||
dependencies: readonly string[];
|
||||
}>;
|
||||
export type DependencyUpgrade = Readonly<{
|
||||
name: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}>;
|
||||
export type DependencyInventoryDiff = Readonly<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
upgrades: readonly DependencyUpgrade[];
|
||||
}>;
|
||||
|
||||
type MutableDependencyRecord = {
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
packagePath: string;
|
||||
dependencies: Set<string>;
|
||||
};
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function stringRows(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function dependencyIdentity(row: Readonly<Record<string, unknown>>): string {
|
||||
return `${String(row.name ?? "")}@${String(row.version ?? "")}`;
|
||||
}
|
||||
|
||||
export function canonicalizeSupplyChainValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(canonicalizeSupplyChainValue)
|
||||
.sort((left, right) =>
|
||||
String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))),
|
||||
);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function supplyChainDigest(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function parsePnpmLockfilePackages(
|
||||
lockfile: string,
|
||||
): LockfilePackage[] {
|
||||
const entries: LockfilePackage[] = [];
|
||||
let inPackages = false;
|
||||
let current: { name: string; version: string; integrity: string } | null = null;
|
||||
|
||||
for (const line of lockfile.split(/\r?\n/)) {
|
||||
if (line === "packages:") {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "snapshots:") {
|
||||
if (current) entries.push(current);
|
||||
break;
|
||||
}
|
||||
if (!inPackages) continue;
|
||||
const packageMatch = line.match(/^ {2}(\S.*):$/);
|
||||
if (packageMatch?.[1]) {
|
||||
if (current) entries.push(current);
|
||||
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
|
||||
const separator = key.lastIndexOf("@");
|
||||
current = {
|
||||
name: key.slice(0, separator),
|
||||
version: key.slice(separator + 1),
|
||||
integrity: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
|
||||
if (current && integrityMatch?.[1]) {
|
||||
current.integrity = integrityMatch[1];
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidSha512Integrity(integrity: string): boolean {
|
||||
if (!integrity.startsWith("sha512-")) return false;
|
||||
try {
|
||||
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLicense(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (isRecord(raw) && typeof raw.type === "string") return raw.type;
|
||||
if (Array.isArray(raw)) {
|
||||
const licenses = raw
|
||||
.map(normalizeLicense)
|
||||
.filter((license) => license !== "NOASSERTION");
|
||||
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
|
||||
}
|
||||
return "NOASSERTION";
|
||||
}
|
||||
|
||||
export async function flattenPnpmDependencyTree(
|
||||
root: Record<string, unknown>,
|
||||
directProduction: Readonly<Record<string, string>>,
|
||||
directDevelopment: Readonly<Record<string, string>>,
|
||||
): Promise<DependencyInventoryRow[]> {
|
||||
const records = new Map<string, MutableDependencyRecord>();
|
||||
const directIds = new Set<string>();
|
||||
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.dependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directProduction, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.devDependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directDevelopment, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(
|
||||
node: Record<string, unknown>,
|
||||
scope: DependencyScope,
|
||||
optionalPath: boolean,
|
||||
): void {
|
||||
const groups = {
|
||||
dependencies: node.dependencies,
|
||||
devDependencies: node.devDependencies,
|
||||
optionalDependencies: node.optionalDependencies,
|
||||
};
|
||||
for (const [groupName, group] of Object.entries(groups)) {
|
||||
for (const [name, rawDependency] of Object.entries(recordValue(group))) {
|
||||
if (!isRecord(rawDependency)) continue;
|
||||
const version = String(rawDependency.version ?? "");
|
||||
const packagePath = String(rawDependency.path ?? "");
|
||||
const identity = `${name}@${version}`;
|
||||
const childScope: DependencyScope =
|
||||
scope === "production" && groupName !== "devDependencies"
|
||||
? "production"
|
||||
: "development";
|
||||
const childOptional =
|
||||
optionalPath || groupName === "optionalDependencies";
|
||||
const previous = records.get(identity);
|
||||
const dependencies = previous?.dependencies ?? new Set<string>();
|
||||
for (const childGroup of [
|
||||
rawDependency.dependencies,
|
||||
rawDependency.optionalDependencies,
|
||||
]) {
|
||||
for (const [childName, rawChild] of Object.entries(
|
||||
recordValue(childGroup),
|
||||
)) {
|
||||
if (isRecord(rawChild)) {
|
||||
dependencies.add(
|
||||
`${childName}@${String(rawChild.version ?? "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.set(identity, {
|
||||
name,
|
||||
version,
|
||||
direct: directIds.has(identity),
|
||||
scope:
|
||||
previous?.scope === "production" || childScope === "production"
|
||||
? "production"
|
||||
: "development",
|
||||
optional: previous
|
||||
? previous.optional && childOptional
|
||||
: childOptional,
|
||||
packagePath: previous?.packagePath || packagePath,
|
||||
dependencies,
|
||||
});
|
||||
visit(rawDependency, childScope, childOptional);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productionRoot: Record<string, unknown> = {
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.dependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directProduction, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
const developmentRoot: Record<string, unknown> = {
|
||||
devDependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.devDependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directDevelopment, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
visit(productionRoot, "production", false);
|
||||
visit(developmentRoot, "development", false);
|
||||
|
||||
const result: DependencyInventoryRow[] = [];
|
||||
for (const record of records.values()) {
|
||||
let license = "NOASSERTION";
|
||||
let optional = record.optional;
|
||||
if (record.packagePath) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
||||
);
|
||||
const manifest = recordValue(parsed);
|
||||
license = normalizeLicense(manifest.license ?? manifest.licenses);
|
||||
} catch {
|
||||
// Platform-specific optional packages may not be materialized locally.
|
||||
optional = true;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
direct: record.direct,
|
||||
scope: record.scope,
|
||||
optional,
|
||||
license,
|
||||
dependencies: [...record.dependencies].sort(),
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function diffDependencyInventories(
|
||||
before: Document,
|
||||
after: Document,
|
||||
): DependencyInventoryDiff {
|
||||
const beforeRows = recordRows(before.dependencies);
|
||||
const afterRows = recordRows(after.dependencies);
|
||||
const beforeMap = new Map(
|
||||
beforeRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const afterMap = new Map(
|
||||
afterRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
||||
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
||||
const changed: string[] = [];
|
||||
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
|
||||
if (supplyChainDigest(beforeMap.get(key)) !== supplyChainDigest(afterMap.get(key))) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
const upgrades: DependencyUpgrade[] = [];
|
||||
for (const removedKey of removed) {
|
||||
const previous = beforeMap.get(removedKey);
|
||||
if (!previous) continue;
|
||||
const replacement = added.find(
|
||||
(addedKey) => afterMap.get(addedKey)?.name === previous.name,
|
||||
);
|
||||
const next = replacement ? afterMap.get(replacement) : undefined;
|
||||
if (next) {
|
||||
upgrades.push({
|
||||
name: String(previous.name ?? ""),
|
||||
from: String(previous.version ?? ""),
|
||||
to: String(next.version ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
added: Object.freeze(added.sort()),
|
||||
removed: Object.freeze(removed.sort()),
|
||||
changed: Object.freeze(changed.sort()),
|
||||
upgrades: Object.freeze(
|
||||
upgrades.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateLicensePolicy(
|
||||
inventory: Document,
|
||||
policy: Document,
|
||||
) {
|
||||
const allowed = new Set(stringRows(policy.allowedLicenses));
|
||||
const denied = stringRows(policy.deniedLicensePatterns);
|
||||
const failures: string[] = [];
|
||||
const results: Array<Readonly<{
|
||||
package: string;
|
||||
license: string;
|
||||
passed: boolean;
|
||||
reason: string | null;
|
||||
}>> = [];
|
||||
for (const dependency of recordRows(inventory.dependencies)) {
|
||||
const license = String(dependency.license ?? "NOASSERTION");
|
||||
const explicitlyDenied = denied.some((pattern) =>
|
||||
new RegExp(pattern, "i").test(license),
|
||||
);
|
||||
const unknownAccepted =
|
||||
license === "NOASSERTION" && dependency.optional === true;
|
||||
const passed =
|
||||
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
|
||||
results.push({
|
||||
package: dependencyIdentity(dependency),
|
||||
license,
|
||||
passed,
|
||||
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
||||
});
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${dependencyIdentity(dependency)} has disallowed license ${license}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
results: Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDependencyReview(
|
||||
diff: DependencyInventoryDiff,
|
||||
inventory: Document,
|
||||
evidenceFile: Document,
|
||||
) {
|
||||
const byIdentity = new Map(
|
||||
recordRows(inventory.dependencies).map(
|
||||
(row) => [dependencyIdentity(row), row] as const,
|
||||
),
|
||||
);
|
||||
const evidence = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of recordRows(evidenceFile.changes)) {
|
||||
if (typeof entry.changeId === "string") evidence.set(entry.changeId, entry);
|
||||
}
|
||||
const highRisk = diff.added.filter((identity) => {
|
||||
const row = byIdentity.get(identity);
|
||||
return row?.direct === true && row.scope === "production";
|
||||
});
|
||||
const failures: string[] = [];
|
||||
for (const identity of highRisk) {
|
||||
const changeId = `add:${identity}`;
|
||||
const entry = evidence.get(changeId);
|
||||
if (!entry) {
|
||||
failures.push(`high-risk dependency missing review: ${changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of ["owner", "reviewer", "reason", "rollback"] as const) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
failures.push(`${changeId} missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.owner === entry.reviewer) {
|
||||
failures.push(`${changeId} may not be self-approved`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
highRisk: Object.freeze(highRisk),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank: ReadonlyMap<string, number> = new Map([
|
||||
["unknown", 0],
|
||||
["low", 1],
|
||||
["moderate", 2],
|
||||
["high", 3],
|
||||
["critical", 4],
|
||||
]);
|
||||
|
||||
export function validateVulnerabilityReport(
|
||||
report: Document,
|
||||
policy: Document,
|
||||
exceptionFile: Document,
|
||||
lockfileSha256: string,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
if (report.scannedLockfileSha256 !== lockfileSha256) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (typeof report.provider !== "string" || !report.provider.trim()) {
|
||||
failures.push("vulnerability report provider missing");
|
||||
}
|
||||
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
|
||||
const exceptions = recordRows(exceptionFile.exceptions);
|
||||
const blocking: string[] = [];
|
||||
for (const finding of recordRows(report.findings)) {
|
||||
const severity = String(finding.severity ?? "unknown").toLowerCase();
|
||||
if ((severityRank.get(severity) ?? 0) < threshold) continue;
|
||||
const exception = exceptions.find(
|
||||
(entry) =>
|
||||
entry.vulnerabilityId === finding.id &&
|
||||
entry.packageName === finding.packageName,
|
||||
);
|
||||
const expiry =
|
||||
typeof exception?.expiresAt === "string"
|
||||
? Date.parse(exception.expiresAt)
|
||||
: Number.NaN;
|
||||
const validException = Boolean(
|
||||
exception &&
|
||||
typeof exception.owner === "string" &&
|
||||
exception.owner.trim() &&
|
||||
typeof exception.reviewer === "string" &&
|
||||
exception.reviewer.trim() &&
|
||||
exception.owner !== exception.reviewer &&
|
||||
typeof exception.reason === "string" &&
|
||||
exception.reason.trim() &&
|
||||
Number.isFinite(expiry) &&
|
||||
expiry > now.getTime(),
|
||||
);
|
||||
if (!validException) {
|
||||
blocking.push(
|
||||
`${String(finding.id)}:${String(finding.packageName)}@${String(finding.version)}:${severity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0 && blocking.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
blocking: Object.freeze(blocking),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifySupplyChainCoherence(
|
||||
sbom: Document,
|
||||
inventory: Document,
|
||||
provenance: Document,
|
||||
distDigest: string,
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
const componentCount = Array.isArray(sbom.components)
|
||||
? sbom.components.length
|
||||
: -1;
|
||||
const dependencyCount = Array.isArray(inventory.dependencies)
|
||||
? inventory.dependencies.length
|
||||
: -2;
|
||||
if (componentCount !== dependencyCount) {
|
||||
failures.push("SBOM component count does not match inventory");
|
||||
}
|
||||
const metadata = recordValue(sbom.metadata);
|
||||
const properties = recordRows(metadata.properties);
|
||||
if (
|
||||
properties.find(
|
||||
(property) =>
|
||||
property.name === "ca:lockfileSha256" &&
|
||||
property.value === inventory.lockfileSha256,
|
||||
) === undefined
|
||||
) {
|
||||
failures.push("SBOM lockfile digest does not match inventory");
|
||||
}
|
||||
const subject = recordRows(provenance.subject)[0];
|
||||
const subjectDigest = recordValue(subject?.digest);
|
||||
if (subjectDigest.sha256 !== distDigest) {
|
||||
failures.push("provenance subject does not match built dist digest");
|
||||
}
|
||||
const predicate = recordValue(provenance.predicate);
|
||||
const materials = recordValue(predicate.materials);
|
||||
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
|
||||
failures.push("provenance lockfile material does not match inventory");
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
type ModuleInventoryChunk = Readonly<{
|
||||
fileName: string;
|
||||
modules: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Rollup knows the exact source-module set for every emitted chunk. Persisting
|
||||
* that graph makes optional-runtime exclusion verifiable without relying on
|
||||
* minified names, error strings, or source maps.
|
||||
*/
|
||||
export function viteModuleInventoryPlugin(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Plugin {
|
||||
return {
|
||||
name: "frontend-module-inventory",
|
||||
generateBundle(_options, bundle) {
|
||||
const chunks: ModuleInventoryChunk[] = Object.values(bundle)
|
||||
.filter((output) => output.type === "chunk")
|
||||
.map((chunk) => ({
|
||||
fileName: chunk.fileName,
|
||||
modules: Object.freeze(
|
||||
[...new Set(
|
||||
Object.keys(chunk.modules).map((moduleId) =>
|
||||
normalizeModuleId(moduleId, repositoryRoot),
|
||||
),
|
||||
)].sort(),
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
||||
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: ".vite/module-inventory.json",
|
||||
source: `${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
chunks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModuleId(
|
||||
moduleId: string,
|
||||
repositoryRoot: string,
|
||||
): string {
|
||||
const withoutQuery = moduleId.replace(/^\0/u, "").split("?", 1)[0] ?? "";
|
||||
if (!path.isAbsolute(withoutQuery)) {
|
||||
return withoutQuery.replaceAll("\\", "/");
|
||||
}
|
||||
const relative = path.relative(repositoryRoot, withoutQuery);
|
||||
return relative.startsWith("..")
|
||||
? `external:${path.basename(withoutQuery)}`
|
||||
: relative.replaceAll("\\", "/");
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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,208 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ciCheckoutIdentityFailures,
|
||||
ciBuildEnvironmentFailures,
|
||||
isValidCommitSha,
|
||||
isValidSourceDateEpoch,
|
||||
} from "./lib/build-environment.ts";
|
||||
|
||||
type GateStep = Readonly<{
|
||||
script: string;
|
||||
args?: readonly string[];
|
||||
expect: "pass" | "fail";
|
||||
}>;
|
||||
type GateDefinition = Readonly<{
|
||||
name: string;
|
||||
steps: readonly GateStep[];
|
||||
logPath: string;
|
||||
evidence: readonly string[];
|
||||
retentionClass: string;
|
||||
requiresEnvironment?: readonly string[];
|
||||
}>;
|
||||
type GateDocument = Readonly<{
|
||||
gates: Readonly<Record<string, GateDefinition>>;
|
||||
}>;
|
||||
|
||||
const gateId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
|
||||
const document = parseGateDocument(
|
||||
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: string[] = [];
|
||||
let passed = true;
|
||||
|
||||
const gateEnvironment = { ...process.env };
|
||||
if (gateEnvironment.CI === "true") {
|
||||
const commitMetadata = spawnSync(
|
||||
"git",
|
||||
["show", "-s", "--format=%H%n%ct", "HEAD"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const [commitSha = "", sourceDateEpoch = ""] =
|
||||
commitMetadata.stdout.trim().split(/\r?\n/);
|
||||
if (
|
||||
commitMetadata.status === 0 &&
|
||||
isValidCommitSha(commitSha) &&
|
||||
isValidSourceDateEpoch(sourceDateEpoch)
|
||||
) {
|
||||
if (!gateEnvironment.SOURCE_DATE_EPOCH?.trim()) {
|
||||
gateEnvironment.SOURCE_DATE_EPOCH = sourceDateEpoch;
|
||||
output.push(`derived SOURCE_DATE_EPOCH=${sourceDateEpoch} from HEAD`);
|
||||
}
|
||||
for (const failure of ciCheckoutIdentityFailures(gateEnvironment, {
|
||||
commitSha,
|
||||
sourceDateEpoch,
|
||||
})) {
|
||||
output.push(failure);
|
||||
passed = false;
|
||||
}
|
||||
} else {
|
||||
output.push(
|
||||
"unable to resolve the checked-out commit identity and timestamp",
|
||||
commitMetadata.stderr,
|
||||
);
|
||||
passed = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of ciBuildEnvironmentFailures(gateEnvironment)) {
|
||||
output.push(failure);
|
||||
passed = false;
|
||||
}
|
||||
|
||||
for (const variable of gate.requiresEnvironment ?? []) {
|
||||
if (!gateEnvironment[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: gateEnvironment },
|
||||
);
|
||||
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`,
|
||||
);
|
||||
|
||||
function parseGateDocument(value: unknown): GateDocument {
|
||||
if (!isRecord(value) || !isRecord(value.gates)) {
|
||||
throw new TypeError("CI gate registry must be an object");
|
||||
}
|
||||
const gates: Record<string, GateDefinition> = {};
|
||||
for (const [gateId, candidate] of Object.entries(value.gates)) {
|
||||
if (!isRecord(candidate)) throw new TypeError(`Invalid CI gate: ${gateId}`);
|
||||
const steps = parseGateSteps(candidate.steps, gateId);
|
||||
const evidence = parseStringArray(candidate.evidence, `${gateId}.evidence`);
|
||||
const requiresEnvironment =
|
||||
candidate.requiresEnvironment === undefined
|
||||
? undefined
|
||||
: parseStringArray(
|
||||
candidate.requiresEnvironment,
|
||||
`${gateId}.requiresEnvironment`,
|
||||
);
|
||||
if (
|
||||
typeof candidate.name !== "string" ||
|
||||
typeof candidate.logPath !== "string" ||
|
||||
typeof candidate.retentionClass !== "string"
|
||||
) {
|
||||
throw new TypeError(`CI gate metadata is invalid: ${gateId}`);
|
||||
}
|
||||
gates[gateId] = {
|
||||
name: candidate.name,
|
||||
steps,
|
||||
logPath: candidate.logPath,
|
||||
evidence,
|
||||
retentionClass: candidate.retentionClass,
|
||||
...(requiresEnvironment ? { requiresEnvironment } : {}),
|
||||
};
|
||||
}
|
||||
return { gates };
|
||||
}
|
||||
|
||||
function parseGateSteps(value: unknown, gateId: string): GateStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError(`CI gate steps are invalid: ${gateId}`);
|
||||
}
|
||||
return value.map((candidate, index) => {
|
||||
if (
|
||||
!isRecord(candidate) ||
|
||||
typeof candidate.script !== "string" ||
|
||||
(candidate.expect !== "pass" && candidate.expect !== "fail")
|
||||
) {
|
||||
throw new TypeError(`Invalid CI gate step: ${gateId}[${index}]`);
|
||||
}
|
||||
const args =
|
||||
candidate.args === undefined
|
||||
? undefined
|
||||
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
|
||||
return {
|
||||
script: candidate.script,
|
||||
expect: candidate.expect,
|
||||
...(args ? { args } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseStringArray(value: unknown, label: string): string[] {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||
throw new TypeError(`${label} must be a string array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -2,14 +2,66 @@ import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** @param {string} name @param {string} fallback */
|
||||
function argumentValue(name, fallback) {
|
||||
type SecretFinding = Readonly<{
|
||||
ruleId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
type AllowlistEntry = Readonly<{
|
||||
path: string;
|
||||
ruleId: string;
|
||||
owner: string;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
type SecretPolicy = Readonly<{
|
||||
excludedPaths: readonly string[];
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
allowlist: readonly AllowlistEntry[];
|
||||
}>;
|
||||
|
||||
function argumentValue(name: string, fallback: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function parsePolicy(value: unknown): SecretPolicy {
|
||||
const document = isRecord(value) ? value : {};
|
||||
const allowlist = Array.isArray(document.allowlist)
|
||||
? document.allowlist.map((rawEntry) => {
|
||||
const entry = isRecord(rawEntry) ? rawEntry : {};
|
||||
return {
|
||||
path: typeof entry.path === "string" ? entry.path : "",
|
||||
ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "",
|
||||
owner: typeof entry.owner === "string" ? entry.owner : "",
|
||||
reason: typeof entry.reason === "string" ? entry.reason : "",
|
||||
expiresAt:
|
||||
typeof entry.expiresAt === "string" ? entry.expiresAt : "",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return Object.freeze({
|
||||
excludedPaths: Object.freeze(strings(document.excludedPaths)),
|
||||
trackedRoots: Object.freeze(strings(document.trackedRoots)),
|
||||
generatedRoots: Object.freeze(strings(document.generatedRoots)),
|
||||
allowlist: Object.freeze(allowlist),
|
||||
});
|
||||
}
|
||||
|
||||
const policyPath = argumentValue(
|
||||
"--policy",
|
||||
"config/security/secret-scan-policy.json",
|
||||
@@ -18,16 +70,11 @@ const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/security/scan.sarif",
|
||||
);
|
||||
const policy = JSON.parse(await readFile(policyPath, "utf8"));
|
||||
const findings =
|
||||
/** @type {Array<{
|
||||
* ruleId: string,
|
||||
* file: string,
|
||||
* line: number,
|
||||
* fingerprint: string
|
||||
* }>} */ ([]);
|
||||
const policyFailures = [];
|
||||
const patterns = [
|
||||
const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
|
||||
const policy = parsePolicy(rawPolicy);
|
||||
const findings: SecretFinding[] = [];
|
||||
const policyFailures: string[] = [];
|
||||
const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [
|
||||
{
|
||||
id: "private-key",
|
||||
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
|
||||
@@ -41,18 +88,17 @@ const patterns = [
|
||||
},
|
||||
];
|
||||
|
||||
/** @param {string} target @returns {Promise<string[]>} */
|
||||
async function filesWithin(target) {
|
||||
async function filesWithin(target: string): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
if (metadata.isFile()) return [target];
|
||||
const entries = await readdir(target, { withFileTypes: true });
|
||||
const nested = /** @type {string[][]} */ (await Promise.all(
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const child = path.join(target, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(child) : [child];
|
||||
}),
|
||||
));
|
||||
);
|
||||
return nested.flat();
|
||||
} catch {
|
||||
return [];
|
||||
@@ -60,24 +106,15 @@ async function filesWithin(target) {
|
||||
}
|
||||
|
||||
const excluded = new Set(
|
||||
/** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) =>
|
||||
entry.replaceAll("\\", "/"),
|
||||
),
|
||||
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
|
||||
);
|
||||
const allowlist =
|
||||
/** @type {Array<{
|
||||
* path: string,
|
||||
* ruleId: string,
|
||||
* owner: string,
|
||||
* reason: string,
|
||||
* expiresAt: string
|
||||
* }>} */ (policy.allowlist ?? []);
|
||||
const allowlist = policy.allowlist;
|
||||
for (const entry of allowlist) {
|
||||
const expiry = Date.parse(entry.expiresAt);
|
||||
if (
|
||||
!entry.path.startsWith("tests/") ||
|
||||
!entry.owner?.trim() ||
|
||||
!entry.reason?.trim() ||
|
||||
!entry.owner.trim() ||
|
||||
!entry.reason.trim() ||
|
||||
!Number.isFinite(expiry) ||
|
||||
expiry <= Date.now()
|
||||
) {
|
||||
@@ -87,10 +124,7 @@ for (const entry of allowlist) {
|
||||
}
|
||||
}
|
||||
|
||||
const roots = [
|
||||
...(/** @type {string[]} */ (policy.trackedRoots ?? [])),
|
||||
...(/** @type {string[]} */ (policy.generatedRoots ?? [])),
|
||||
];
|
||||
const roots = [...policy.trackedRoots, ...policy.generatedRoots];
|
||||
const scanFiles = (
|
||||
await Promise.all(roots.map((root) => filesWithin(root)))
|
||||
).flat();
|
||||
@@ -104,7 +138,7 @@ for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let content;
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(scanFile, "utf8");
|
||||
} catch {
|
||||
@@ -120,13 +154,14 @@ for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
Date.parse(entry.expiresAt) > Date.now(),
|
||||
);
|
||||
if (isAllowed) continue;
|
||||
const prefix = content.slice(0, match.index);
|
||||
const matchIndex = match.index ?? 0;
|
||||
const prefix = content.slice(0, matchIndex);
|
||||
findings.push({
|
||||
ruleId: pattern.id,
|
||||
file: normalized,
|
||||
line: prefix.split(/\r?\n/).length,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${pattern.id}:${normalized}:${String(match.index)}`)
|
||||
.update(`${pattern.id}:${normalized}:${String(matchIndex)}`)
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
@@ -150,9 +185,7 @@ const sarif = {
|
||||
results: [
|
||||
...findings.map((finding) => ({
|
||||
ruleId: finding.ruleId,
|
||||
message: {
|
||||
text: "Potential secret material must be removed.",
|
||||
},
|
||||
message: { text: "Potential secret material must be removed." },
|
||||
partialFingerprints: {
|
||||
primaryLocationLineHash: finding.fingerprint,
|
||||
},
|
||||
@@ -5,14 +5,14 @@ import path from "node:path";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? "artifacts/storybook/static");
|
||||
const port = Number(process.argv[3] ?? 6006);
|
||||
const contentTypes = /** @type {Readonly<Record<string, string>>} */ ({
|
||||
const contentTypes: Readonly<Record<string, string>> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
});
|
||||
};
|
||||
|
||||
await access(root);
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -0,0 +1,379 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const fixtureRoot = path.resolve(
|
||||
".tmp/browser-file-storage-runtime-removal",
|
||||
);
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const runtimePaths = [
|
||||
"src/application/ports/browser-file-storage",
|
||||
"src/application/ports/browser-transfer",
|
||||
"src/adapters/browser-file-storage",
|
||||
"src/adapters/browser-files",
|
||||
"src/adapters/browser-transfer",
|
||||
"src/adapters/cache-storage",
|
||||
"src/adapters/storage/indexeddb",
|
||||
"src/adapters/storage/opfs",
|
||||
"tests/browser-capabilities",
|
||||
"tests/fixtures/browser-file-storage-boundaries",
|
||||
] as const;
|
||||
const runtimeSourceRoots = runtimePaths.filter((entry) =>
|
||||
entry.startsWith("src/"),
|
||||
);
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
"recipes",
|
||||
"scripts",
|
||||
"config",
|
||||
"schemas",
|
||||
"public",
|
||||
".gitea",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.capabilities.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
] as const;
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required for runtime removal verification`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function runPnpm(script: string): boolean {
|
||||
return (
|
||||
spawnSync(process.execPath, [pnpmCli, script], {
|
||||
cwd: fixtureRoot,
|
||||
stdio: "inherit",
|
||||
}).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
async function sourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry): Promise<string[]> => {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await sourceFiles(target);
|
||||
}
|
||||
return /\.(?:[cm]?ts|tsx)$/u.test(entry.name)
|
||||
? [path.resolve(target)]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
|
||||
function staticImportSpecifiers(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(
|
||||
/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier): specifier is string =>
|
||||
typeof specifier === "string",
|
||||
);
|
||||
}
|
||||
|
||||
function isWithin(target: string, root: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedImport(
|
||||
importer: string,
|
||||
specifier: string,
|
||||
sourceSet: ReadonlySet<string>,
|
||||
): string | null {
|
||||
if (!specifier.startsWith(".")) return null;
|
||||
const base = path.resolve(path.dirname(importer), specifier);
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
`${base}.mts`,
|
||||
`${base}.cts`,
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.tsx"),
|
||||
];
|
||||
return candidates.find((candidate) => sourceSet.has(candidate)) ?? base;
|
||||
}
|
||||
|
||||
async function runtimeImportGraph(root: string): Promise<Readonly<{
|
||||
dependentTests: readonly string[];
|
||||
importingFiles: readonly string[];
|
||||
}>> {
|
||||
const files = await sourceFiles(root);
|
||||
const sourceSet = new Set(files);
|
||||
const runtimeRoots = runtimeSourceRoots.map((entry) =>
|
||||
path.resolve(root, entry),
|
||||
);
|
||||
const imports = new Map<string, readonly string[]>();
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
imports.set(
|
||||
file,
|
||||
staticImportSpecifiers(source)
|
||||
.map((specifier) =>
|
||||
resolvedImport(file, specifier, sourceSet),
|
||||
)
|
||||
.filter((target): target is string => target !== null),
|
||||
);
|
||||
}
|
||||
|
||||
const memo = new Map<string, boolean>();
|
||||
const reachesRuntime = (
|
||||
file: string,
|
||||
visiting = new Set<string>(),
|
||||
): boolean => {
|
||||
if (runtimeRoots.some((root) => isWithin(file, root))) return true;
|
||||
const known = memo.get(file);
|
||||
if (known !== undefined) return known;
|
||||
if (visiting.has(file)) return false;
|
||||
visiting.add(file);
|
||||
const reaches = (imports.get(file) ?? []).some(
|
||||
(dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(dependency, runtimeRoot),
|
||||
) ||
|
||||
(sourceSet.has(dependency) &&
|
||||
reachesRuntime(dependency, visiting)),
|
||||
);
|
||||
visiting.delete(file);
|
||||
memo.set(file, reaches);
|
||||
return reaches;
|
||||
};
|
||||
|
||||
const testsRoot = path.resolve(root, "tests");
|
||||
const dependentTests = files.filter(
|
||||
(file) => isWithin(file, testsRoot) && reachesRuntime(file),
|
||||
);
|
||||
const importingFiles = files.filter(
|
||||
(file) =>
|
||||
!runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(file, runtimeRoot),
|
||||
) &&
|
||||
(imports.get(file) ?? []).some((dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(dependency, runtimeRoot),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Object.freeze({
|
||||
dependentTests: Object.freeze(dependentTests),
|
||||
importingFiles: Object.freeze(importingFiles),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertNoRuntimeImports(root: string): Promise<void> {
|
||||
const graph = await runtimeImportGraph(root);
|
||||
if (graph.importingFiles.length > 0) {
|
||||
throw new Error(
|
||||
`Removed browser file/storage runtime is still imported by: ${graph.importingFiles
|
||||
.map((file) => path.relative(root, file))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRuntimeDependentTests(
|
||||
root: string,
|
||||
): Promise<number> {
|
||||
const graph = await runtimeImportGraph(root);
|
||||
await Promise.all(
|
||||
graph.dependentTests.map(async (file) => {
|
||||
if (isWithin(file, path.resolve(root, "tests"))) {
|
||||
await rm(file, { force: true });
|
||||
}
|
||||
}),
|
||||
);
|
||||
return graph.dependentTests.length;
|
||||
}
|
||||
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
await mkdir(fixtureRoot, { recursive: true });
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(fixtureRoot, target), { recursive: true });
|
||||
}
|
||||
await symlink(
|
||||
path.resolve("node_modules"),
|
||||
path.join(fixtureRoot, "node_modules"),
|
||||
"dir",
|
||||
);
|
||||
|
||||
const removedRuntimeTests =
|
||||
await removeRuntimeDependentTests(fixtureRoot);
|
||||
for (const runtimePath of runtimePaths) {
|
||||
await rm(path.join(fixtureRoot, runtimePath), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const catalogPath = path.join(
|
||||
fixtureRoot,
|
||||
"config/recipes/frontend-capability-recipes.json",
|
||||
);
|
||||
const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as {
|
||||
recipes: Array<Record<string, unknown>>;
|
||||
};
|
||||
const browserFileRuntimeRecipeIds = new Set([
|
||||
"offline-indexeddb",
|
||||
"service-worker-pwa",
|
||||
"file-transfer",
|
||||
]);
|
||||
let removedRuntimeEntries = 0;
|
||||
for (const recipe of catalog.recipes) {
|
||||
if (
|
||||
typeof recipe.id !== "string" ||
|
||||
!browserFileRuntimeRecipeIds.has(recipe.id) ||
|
||||
!Object.hasOwn(recipe, "referenceRuntime")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
delete recipe.referenceRuntime;
|
||||
removedRuntimeEntries += 1;
|
||||
}
|
||||
if (removedRuntimeEntries !== 3) {
|
||||
throw new Error(
|
||||
`Expected three reference runtime catalog entries, removed ${removedRuntimeEntries}`,
|
||||
);
|
||||
}
|
||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
|
||||
const packagePath = path.join(fixtureRoot, "package.json");
|
||||
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
for (const script of [
|
||||
"test:browser-capabilities",
|
||||
"verify:browser-capability-evidence",
|
||||
"check:browser-file-storage-boundaries",
|
||||
"test:browser-file-storage-removal",
|
||||
]) {
|
||||
delete packageDocument.scripts[script];
|
||||
}
|
||||
await writeFile(
|
||||
packagePath,
|
||||
`${JSON.stringify(packageDocument, null, 2)}\n`,
|
||||
);
|
||||
await rm(path.join(fixtureRoot, "playwright.capabilities.config.ts"), {
|
||||
force: true,
|
||||
});
|
||||
await rm(
|
||||
path.join(fixtureRoot, "scripts/check-browser-file-storage-boundaries.ts"),
|
||||
{ force: true },
|
||||
);
|
||||
await rm(
|
||||
path.join(fixtureRoot, "scripts/verify-browser-capability-evidence.ts"),
|
||||
{ force: true },
|
||||
);
|
||||
await rm(
|
||||
path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
const gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
|
||||
const gatesDocument = JSON.parse(
|
||||
await readFile(gatesPath, "utf8"),
|
||||
) as {
|
||||
gates: Record<
|
||||
string,
|
||||
{
|
||||
steps: Array<{ script: string }>;
|
||||
evidence: string[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
for (const gate of Object.values(gatesDocument.gates)) {
|
||||
gate.steps = gate.steps.filter(
|
||||
({ script }) =>
|
||||
![
|
||||
"test:browser-capabilities",
|
||||
"verify:browser-capability-evidence",
|
||||
"check:browser-file-storage-boundaries",
|
||||
"test:browser-file-storage-removal",
|
||||
].includes(script),
|
||||
);
|
||||
gate.evidence = gate.evidence.filter(
|
||||
(evidence) =>
|
||||
!evidence.includes("browser-capabilities") &&
|
||||
!evidence.includes("browser-file-storage-runtime-removal"),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
gatesPath,
|
||||
`${JSON.stringify(gatesDocument, null, 2)}\n`,
|
||||
);
|
||||
await assertNoRuntimeImports(fixtureRoot);
|
||||
|
||||
const checks: Array<readonly [string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["lint", runPnpm("lint")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["test", runPnpm("test:all")],
|
||||
["build", runPnpm("build")],
|
||||
["optional-catalog", runPnpm("check:optional-recipes:source")],
|
||||
["ci-contract", runPnpm("check:ci")],
|
||||
];
|
||||
const passed = checks.every(([, result]) => result);
|
||||
await mkdir("artifacts/tests", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/tests/browser-file-storage-runtime-removal.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="browser-file-storage-runtime-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
|
||||
checks
|
||||
.map(
|
||||
([name, result]) =>
|
||||
`<testcase name="${name}">${result ? "" : "<failure />"}</testcase>`,
|
||||
)
|
||||
.join("") +
|
||||
`</testsuite>\n`,
|
||||
);
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Browser file/storage runtime removal failed: ${checks
|
||||
.filter(([, result]) => !result)
|
||||
.map(([name]) => name)
|
||||
.join(", ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Browser file/storage runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`,
|
||||
);
|
||||
+22
-13
@@ -11,7 +11,7 @@ import {
|
||||
import path from "node:path";
|
||||
|
||||
const fixtureRoot = path.resolve(".tmp/optional-recipe-removal");
|
||||
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
@@ -19,6 +19,7 @@ const copyTargets = [
|
||||
"scripts",
|
||||
"config",
|
||||
"public",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
@@ -27,15 +28,25 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"vite.config.js",
|
||||
"vitest.config.js",
|
||||
"playwright.config.js",
|
||||
"eslint.config.js",
|
||||
".dependency-cruiser.cjs",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
];
|
||||
|
||||
/** @param {string} script */
|
||||
function runPnpm(script) {
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to run removal verification`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function runPnpm(script: string): boolean {
|
||||
return (
|
||||
spawnSync(process.execPath, [pnpmCli, script], {
|
||||
cwd: fixtureRoot,
|
||||
@@ -44,8 +55,7 @@ function runPnpm(script) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesBelow(directory) {
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
@@ -68,14 +78,13 @@ await rm(path.join(fixtureRoot, "tests/recipes"), {
|
||||
force: true,
|
||||
});
|
||||
|
||||
const checks = [
|
||||
const checks: Array<[string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["test", runPnpm("test:all")],
|
||||
["build", runPnpm("build")],
|
||||
];
|
||||
/** @type {string[]} */
|
||||
const residue = [];
|
||||
const residue: string[] = [];
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
@@ -5,8 +5,22 @@ import process from "node:process";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js";
|
||||
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.js";
|
||||
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.ts";
|
||||
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts";
|
||||
|
||||
type ContractPerformanceEvidence = {
|
||||
lcpMs: number;
|
||||
cls: number;
|
||||
};
|
||||
|
||||
type ContractPerformanceWindow = Window & {
|
||||
__contractPerformance?: ContractPerformanceEvidence;
|
||||
};
|
||||
|
||||
type LayoutShiftEntry = PerformanceEntry & {
|
||||
hadRecentInput: boolean;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const server = spawn(
|
||||
"corepack",
|
||||
@@ -54,20 +68,25 @@ try {
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||
await page.addInitScript(() => {
|
||||
const evidence = { lcpMs: 0, cls: 0 };
|
||||
/** @type {any} */ (window).__contractPerformance = evidence;
|
||||
(window as ContractPerformanceWindow).__contractPerformance = evidence;
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime;
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!(/** @type {any} */ (entry)).hadRecentInput) {
|
||||
evidence.cls += /** @type {any} */ (entry).value;
|
||||
const layoutShift = entry as LayoutShiftEntry;
|
||||
if (!layoutShift.hadRecentInput) {
|
||||
evidence.cls += layoutShift.value;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
});
|
||||
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
||||
const targetLabel = Object.values(ROUTE_REGISTRY).find(
|
||||
const performanceRoutes: ReadonlyArray<{
|
||||
access: string;
|
||||
navigationLabel: string | null;
|
||||
}> = Object.values(ROUTE_REGISTRY);
|
||||
const targetLabel = performanceRoutes.find(
|
||||
(definition) => definition.access === "integration-defined",
|
||||
)?.navigationLabel;
|
||||
if (!targetLabel) {
|
||||
@@ -78,8 +97,11 @@ try {
|
||||
await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor();
|
||||
const namedInteractionMs = performance.now() - interactionStarted;
|
||||
const paint = await page.evaluate(
|
||||
() => /** @type {any} */ (window).__contractPerformance,
|
||||
() => (window as ContractPerformanceWindow).__contractPerformance,
|
||||
);
|
||||
if (!paint) {
|
||||
throw new Error("Browser performance evidence was not initialized.");
|
||||
}
|
||||
const contextMetadata = {
|
||||
runner: {
|
||||
platform: process.platform,
|
||||
@@ -0,0 +1,356 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal");
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const runtimePaths = [
|
||||
"src/application/ports/realtime",
|
||||
"src/application/ports/out/web-push-control.ts",
|
||||
"src/application/policies/bounded-polling.ts",
|
||||
"src/contracts/realtime-events.ts",
|
||||
"src/contracts/realtime-streams.ts",
|
||||
"src/contracts/web-push.ts",
|
||||
"src/adapters/realtime",
|
||||
"src/adapters/web-push",
|
||||
"tests/fixtures/realtime-boundaries",
|
||||
] as const;
|
||||
const runtimeSourceRoots = runtimePaths.filter((entry) =>
|
||||
entry.startsWith("src/"),
|
||||
);
|
||||
const runtimeScripts = [
|
||||
"check:realtime-boundaries",
|
||||
"check:realtime-boundaries:fixture",
|
||||
"test:realtime-removal",
|
||||
] as const;
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
"recipes",
|
||||
"scripts",
|
||||
"config",
|
||||
"schemas",
|
||||
"public",
|
||||
".gitea",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.capabilities.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
".nvmrc",
|
||||
] as const;
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required for runtime removal verification`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function runPnpm(script: string): boolean {
|
||||
return (
|
||||
spawnSync(process.execPath, [pnpmCli, script], {
|
||||
cwd: fixtureRoot,
|
||||
stdio: "inherit",
|
||||
}).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
async function sourceFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry): Promise<string[]> => {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return await sourceFiles(target);
|
||||
return /\.(?:[cm]?ts|tsx)$/u.test(entry.name)
|
||||
? [path.resolve(target)]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
|
||||
function staticImportSpecifiers(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(
|
||||
/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu,
|
||||
),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((specifier): specifier is string =>
|
||||
typeof specifier === "string",
|
||||
);
|
||||
}
|
||||
|
||||
function isWithin(target: string, root: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedImport(
|
||||
importer: string,
|
||||
specifier: string,
|
||||
sourceSet: ReadonlySet<string>,
|
||||
): string | null {
|
||||
if (!specifier.startsWith(".")) return null;
|
||||
const base = path.resolve(path.dirname(importer), specifier);
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
`${base}.mts`,
|
||||
`${base}.cts`,
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.tsx"),
|
||||
];
|
||||
return candidates.find((candidate) => sourceSet.has(candidate)) ?? base;
|
||||
}
|
||||
|
||||
async function runtimeImportGraph(root: string): Promise<Readonly<{
|
||||
dependentTests: readonly string[];
|
||||
importingFiles: readonly string[];
|
||||
}>> {
|
||||
const files = await sourceFiles(root);
|
||||
const sourceSet = new Set(files);
|
||||
const runtimeRoots = runtimeSourceRoots.map((entry) =>
|
||||
path.resolve(root, entry),
|
||||
);
|
||||
const imports = new Map<string, readonly string[]>();
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
imports.set(
|
||||
file,
|
||||
staticImportSpecifiers(source)
|
||||
.map((specifier) => resolvedImport(file, specifier, sourceSet))
|
||||
.filter((target): target is string => target !== null),
|
||||
);
|
||||
}
|
||||
|
||||
const memo = new Map<string, boolean>();
|
||||
const reachesRuntime = (
|
||||
file: string,
|
||||
visiting = new Set<string>(),
|
||||
): boolean => {
|
||||
if (runtimeRoots.some((root) => isWithin(file, root))) return true;
|
||||
const known = memo.get(file);
|
||||
if (known !== undefined) return known;
|
||||
if (visiting.has(file)) return false;
|
||||
visiting.add(file);
|
||||
const reaches = (imports.get(file) ?? []).some(
|
||||
(dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(dependency, runtimeRoot),
|
||||
) ||
|
||||
(sourceSet.has(dependency) &&
|
||||
reachesRuntime(dependency, visiting)),
|
||||
);
|
||||
visiting.delete(file);
|
||||
memo.set(file, reaches);
|
||||
return reaches;
|
||||
};
|
||||
|
||||
const testsRoot = path.resolve(root, "tests");
|
||||
return Object.freeze({
|
||||
dependentTests: Object.freeze(
|
||||
files.filter(
|
||||
(file) => isWithin(file, testsRoot) && reachesRuntime(file),
|
||||
),
|
||||
),
|
||||
importingFiles: Object.freeze(
|
||||
files.filter(
|
||||
(file) =>
|
||||
!runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(file, runtimeRoot),
|
||||
) &&
|
||||
(imports.get(file) ?? []).some((dependency) =>
|
||||
runtimeRoots.some((runtimeRoot) =>
|
||||
isWithin(dependency, runtimeRoot),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function removeRuntimeDependentTests(root: string): Promise<number> {
|
||||
const graph = await runtimeImportGraph(root);
|
||||
await Promise.all(
|
||||
graph.dependentTests.map(async (file) => {
|
||||
if (isWithin(file, path.resolve(root, "tests"))) {
|
||||
await rm(file, { force: true });
|
||||
}
|
||||
}),
|
||||
);
|
||||
return graph.dependentTests.length;
|
||||
}
|
||||
|
||||
async function assertNoRuntimeImports(root: string): Promise<void> {
|
||||
const graph = await runtimeImportGraph(root);
|
||||
if (graph.importingFiles.length > 0) {
|
||||
throw new Error(
|
||||
`Removed realtime runtime is still imported by: ${graph.importingFiles
|
||||
.map((file) => path.relative(root, file))
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
await mkdir(fixtureRoot, { recursive: true });
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(fixtureRoot, target), { recursive: true });
|
||||
}
|
||||
await symlink(
|
||||
path.resolve("node_modules"),
|
||||
path.join(fixtureRoot, "node_modules"),
|
||||
"dir",
|
||||
);
|
||||
|
||||
const removedRuntimeTests =
|
||||
await removeRuntimeDependentTests(fixtureRoot);
|
||||
for (const runtimePath of runtimePaths) {
|
||||
await rm(path.join(fixtureRoot, runtimePath), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const outputPortsIndexPath = path.join(
|
||||
fixtureRoot,
|
||||
"src/application/ports/out/index.ts",
|
||||
);
|
||||
const outputPortsIndex = await readFile(outputPortsIndexPath, "utf8");
|
||||
await writeFile(
|
||||
outputPortsIndexPath,
|
||||
outputPortsIndex.replace(
|
||||
'export type { WebPushControlPort } from "./web-push-control.ts";\n',
|
||||
"",
|
||||
),
|
||||
);
|
||||
|
||||
const catalogPath = path.join(
|
||||
fixtureRoot,
|
||||
"config/recipes/frontend-capability-recipes.json",
|
||||
);
|
||||
const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as {
|
||||
recipes: Array<Record<string, unknown>>;
|
||||
};
|
||||
const realtimeRecipe = catalog.recipes.find(
|
||||
(recipe) => recipe.id === "realtime",
|
||||
);
|
||||
if (!realtimeRecipe || !Object.hasOwn(realtimeRecipe, "referenceRuntime")) {
|
||||
throw new Error("Expected realtime reference runtime catalog entry");
|
||||
}
|
||||
delete realtimeRecipe.referenceRuntime;
|
||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
|
||||
const packagePath = path.join(fixtureRoot, "package.json");
|
||||
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
for (const script of runtimeScripts) {
|
||||
delete packageDocument.scripts[script];
|
||||
}
|
||||
await writeFile(
|
||||
packagePath,
|
||||
`${JSON.stringify(packageDocument, null, 2)}\n`,
|
||||
);
|
||||
for (const scriptPath of [
|
||||
"scripts/check-realtime-boundaries.ts",
|
||||
"scripts/check-realtime-boundary-fixtures.ts",
|
||||
"scripts/lib/realtime-boundaries.ts",
|
||||
"scripts/test-realtime-runtime-removal.ts",
|
||||
]) {
|
||||
await rm(path.join(fixtureRoot, scriptPath), { force: true });
|
||||
}
|
||||
|
||||
const gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
|
||||
const gatesDocument = JSON.parse(await readFile(gatesPath, "utf8")) as {
|
||||
gates: Record<
|
||||
string,
|
||||
{
|
||||
steps: Array<{ script: string }>;
|
||||
evidence: string[];
|
||||
}
|
||||
>;
|
||||
};
|
||||
for (const gate of Object.values(gatesDocument.gates)) {
|
||||
gate.steps = gate.steps.filter(
|
||||
({ script }) =>
|
||||
!runtimeScripts.some((runtimeScript) => runtimeScript === script),
|
||||
);
|
||||
gate.evidence = gate.evidence.filter(
|
||||
(evidence) =>
|
||||
!evidence.includes("realtime-boundaries") &&
|
||||
!evidence.includes("realtime-runtime-removal"),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
gatesPath,
|
||||
`${JSON.stringify(gatesDocument, null, 2)}\n`,
|
||||
);
|
||||
await assertNoRuntimeImports(fixtureRoot);
|
||||
|
||||
const checks: Array<readonly [string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["lint", runPnpm("lint")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["test", runPnpm("test:all")],
|
||||
["build", runPnpm("build")],
|
||||
["optional-catalog", runPnpm("check:optional-recipes:source")],
|
||||
["ci-contract", runPnpm("check:ci")],
|
||||
];
|
||||
const passed = checks.every(([, result]) => result);
|
||||
await mkdir("artifacts/tests", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/tests/realtime-runtime-removal.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<testsuite name="realtime-runtime-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
|
||||
checks
|
||||
.map(
|
||||
([name, result]) =>
|
||||
`<testcase name="${name}">${result ? "" : "<failure />"}</testcase>`,
|
||||
)
|
||||
.join("") +
|
||||
`</testsuite>\n`,
|
||||
);
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Realtime runtime removal failed: ${checks
|
||||
.filter(([, result]) => !result)
|
||||
.map(([name]) => name)
|
||||
.join(", ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Realtime runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`,
|
||||
);
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
import path from "node:path";
|
||||
|
||||
const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
|
||||
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const featureSource = "src/features/reference-feature";
|
||||
const featureTests = "tests/features/reference-feature";
|
||||
const featureOwnedPaths = [
|
||||
featureSource,
|
||||
featureTests,
|
||||
"tests/e2e/reference-form.spec.js",
|
||||
"tests/e2e/reference-route.spec.js",
|
||||
"tests/e2e/reference-form.spec.ts",
|
||||
"tests/e2e/reference-route.spec.ts",
|
||||
"tests/mocks",
|
||||
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||
"tests/fixtures/typecheck/invalid-reference-operation.ts",
|
||||
];
|
||||
const copyTargets = [
|
||||
"src",
|
||||
@@ -28,6 +30,7 @@ const copyTargets = [
|
||||
"scripts",
|
||||
"config",
|
||||
"public",
|
||||
".storybook",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"tsconfig.base.json",
|
||||
@@ -36,19 +39,21 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"vite.config.js",
|
||||
"vitest.config.js",
|
||||
"playwright.config.js",
|
||||
"eslint.config.js",
|
||||
".dependency-cruiser.cjs",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.dev.config.ts",
|
||||
"playwright.storybook.config.ts",
|
||||
"playwright.visual.config.ts",
|
||||
"eslint.config.ts",
|
||||
".dependency-cruiser.json",
|
||||
];
|
||||
|
||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.js";
|
||||
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.js";
|
||||
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.js";
|
||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
||||
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
|
||||
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
||||
|
||||
export const INSTALLED_FEATURE_CONTRACTS =
|
||||
/** @type {readonly unknown[]} */ (Object.freeze([]));
|
||||
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
|
||||
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
||||
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
||||
export const API_OPERATIONS = Object.freeze({});
|
||||
@@ -59,28 +64,22 @@ export const NAVIGATION_ROUTES = Object.freeze(
|
||||
.filter((definition) => definition.navigationOrder !== null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
/** @type {number} */ (left.navigationOrder) -
|
||||
/** @type {number} */ (right.navigationOrder),
|
||||
left.navigationOrder! - right.navigationOrder!,
|
||||
),
|
||||
);
|
||||
/** @param {string} routeId */
|
||||
export function getRoute(routeId) {
|
||||
const registry =
|
||||
/** @type {Readonly<Record<string, import("../contracts/routes.js").RouteDefinition>>} */ (
|
||||
ROUTE_REGISTRY
|
||||
);
|
||||
export function getRoute(routeId: string): RouteDefinition {
|
||||
const registry = ROUTE_REGISTRY as Readonly<Record<string, RouteDefinition>>;
|
||||
const selected = registry[routeId];
|
||||
if (!selected) throw new Error(\`Unregistered route: \${routeId}\`);
|
||||
return selected;
|
||||
}
|
||||
/** @param {string} routeId */
|
||||
export function routePath(routeId) {
|
||||
export function routePath(routeId: string): string {
|
||||
return getRoute(routeId).path;
|
||||
}
|
||||
`;
|
||||
|
||||
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.js";
|
||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.js";
|
||||
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
||||
|
||||
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
|
||||
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
||||
@@ -101,8 +100,29 @@ const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
||||
});
|
||||
`;
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesBelow(directory) {
|
||||
type CoveragePolicy = {
|
||||
criticalModules: Array<{ path?: string }>;
|
||||
};
|
||||
|
||||
type EvidenceContribution = Readonly<{ owner?: string }>;
|
||||
type EvidencePolicy = Record<
|
||||
"scenarioCatalogs" | "sourceContracts",
|
||||
unknown
|
||||
>;
|
||||
type GovernanceConsumer = Readonly<{ path?: string }>;
|
||||
type GovernanceRegistry = Record<string, unknown> & {
|
||||
consumers?: unknown;
|
||||
consumerDirectories?: unknown;
|
||||
};
|
||||
type RemovalGovernance = { registries: GovernanceRegistry[] };
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required for sample removal`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
@@ -113,8 +133,7 @@ async function filesBelow(directory) {
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
/** @param {string} script @param {string[]} [extra] */
|
||||
function runPnpm(script, extra = []) {
|
||||
function runPnpm(script: string, extra: string[] = []): boolean {
|
||||
const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], {
|
||||
cwd: fixtureRoot,
|
||||
stdio: "inherit",
|
||||
@@ -136,7 +155,7 @@ for (const ownedPath of featureOwnedPaths) {
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
|
||||
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
|
||||
emptyContracts,
|
||||
);
|
||||
await writeFile(
|
||||
@@ -148,32 +167,96 @@ await writeFile(
|
||||
emptyAdapters,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-messages.js"),
|
||||
path.join(fixtureRoot, "src/features/installed-feature-messages.ts"),
|
||||
emptyMessages,
|
||||
);
|
||||
|
||||
const vitestConfigFile = path.join(fixtureRoot, "vitest.config.ts");
|
||||
const vitestConfig = await readFile(vitestConfigFile, "utf8");
|
||||
const featureCoverageInclude =
|
||||
` "${featureSource}/adapters/reference-http-gateway.ts",\n`;
|
||||
if (!vitestConfig.includes(featureCoverageInclude)) {
|
||||
throw new Error("Reference feature coverage include is not registered");
|
||||
}
|
||||
await writeFile(
|
||||
vitestConfigFile,
|
||||
vitestConfig.replace(featureCoverageInclude, ""),
|
||||
);
|
||||
|
||||
const coveragePolicyFile = path.join(
|
||||
fixtureRoot,
|
||||
"config/testing/risk-coverage.json",
|
||||
);
|
||||
const coveragePolicy = JSON.parse(
|
||||
await readFile(coveragePolicyFile, "utf8"),
|
||||
) as CoveragePolicy;
|
||||
const retainedCriticalModules = coveragePolicy.criticalModules.filter(
|
||||
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
|
||||
);
|
||||
if (
|
||||
retainedCriticalModules.length === coveragePolicy.criticalModules.length
|
||||
) {
|
||||
throw new Error("Reference feature coverage policy is not registered");
|
||||
}
|
||||
coveragePolicy.criticalModules = retainedCriticalModules;
|
||||
await writeFile(
|
||||
coveragePolicyFile,
|
||||
`${JSON.stringify(coveragePolicy, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const evidencePolicyFile = path.join(
|
||||
fixtureRoot,
|
||||
"config/testing/test-evidence.json",
|
||||
);
|
||||
const evidencePolicy = JSON.parse(
|
||||
await readFile(evidencePolicyFile, "utf8"),
|
||||
) as EvidencePolicy;
|
||||
let removedEvidenceContributions = 0;
|
||||
for (const policyKey of ["scenarioCatalogs", "sourceContracts"] as const) {
|
||||
const contributions = evidencePolicy[policyKey];
|
||||
if (!Array.isArray(contributions)) {
|
||||
throw new Error(`Test evidence policy is missing ${policyKey}`);
|
||||
}
|
||||
evidencePolicy[policyKey] = contributions.filter((candidate: unknown) => {
|
||||
const contribution = candidate as EvidenceContribution;
|
||||
const retained = contribution.owner !== "reference-feature";
|
||||
if (!retained) removedEvidenceContributions += 1;
|
||||
return retained;
|
||||
});
|
||||
}
|
||||
if (removedEvidenceContributions === 0) {
|
||||
throw new Error("Reference feature test evidence policy is not registered");
|
||||
}
|
||||
await writeFile(
|
||||
evidencePolicyFile,
|
||||
`${JSON.stringify(evidencePolicy, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const governanceFile = path.join(
|
||||
fixtureRoot,
|
||||
"config/contracts/registry-governance.json",
|
||||
);
|
||||
const removalGovernance = JSON.parse(await readFile(governanceFile, "utf8"));
|
||||
const removalGovernance = JSON.parse(
|
||||
await readFile(governanceFile, "utf8"),
|
||||
) as RemovalGovernance;
|
||||
removalGovernance.registries = removalGovernance.registries.map(
|
||||
/** @param {Record<string, unknown>} registry */
|
||||
(registry) => ({
|
||||
...registry,
|
||||
...(Array.isArray(registry.consumers)
|
||||
? {
|
||||
consumers: registry.consumers.filter(
|
||||
/** @param {{path?: string}} consumer */
|
||||
(consumer) =>
|
||||
!consumer.path?.includes("features/reference-feature"),
|
||||
(candidate: unknown) => {
|
||||
const consumer = candidate as GovernanceConsumer;
|
||||
return !consumer.path?.includes("features/reference-feature");
|
||||
},
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(registry.consumerDirectories)
|
||||
? {
|
||||
consumerDirectories: registry.consumerDirectories.filter(
|
||||
/** @param {string} directory */
|
||||
(directory) =>
|
||||
(directory: unknown) =>
|
||||
typeof directory !== "string" ||
|
||||
!directory.includes("features/reference-feature"),
|
||||
),
|
||||
}
|
||||
@@ -185,8 +268,7 @@ await writeFile(
|
||||
`${JSON.stringify(removalGovernance, null, 2)}\n`,
|
||||
);
|
||||
|
||||
/** @type {string[]} */
|
||||
const residue = [];
|
||||
const residue: string[] = [];
|
||||
for (const root of ["src", "tests"]) {
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, root))) {
|
||||
const relative = path.relative(fixtureRoot, file);
|
||||
@@ -201,24 +283,25 @@ for (const root of ["src", "tests"]) {
|
||||
}
|
||||
}
|
||||
|
||||
const checks = [
|
||||
const checks: Array<[string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["registry-structure", runPnpm("check:registries:structure")],
|
||||
["unit-integration", runPnpm("test:all")],
|
||||
["coverage", runPnpm("test:coverage")],
|
||||
["test-evidence-source", runPnpm("check:test-evidence:source")],
|
||||
[
|
||||
"home-smoke",
|
||||
runPnpm("exec", [
|
||||
"vitest",
|
||||
"run",
|
||||
"tests/component/router.test.jsx",
|
||||
"tests/component/router.test.tsx",
|
||||
"--reporter=default",
|
||||
]),
|
||||
],
|
||||
["build", runPnpm("build")],
|
||||
];
|
||||
/** @type {string[]} */
|
||||
const builtResidue = [];
|
||||
const builtResidue: string[] = [];
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
@@ -230,10 +313,10 @@ for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
}
|
||||
const routeCatalog = await import(
|
||||
`${new URL(
|
||||
"../src/features/installed-feature-contracts.js",
|
||||
"../src/features/installed-feature-contracts.ts",
|
||||
`file://${fixtureRoot}/scripts/`,
|
||||
).href}?removed=${Date.now()}`
|
||||
);
|
||||
) as { ROUTE_REGISTRY: Readonly<Record<string, unknown>> };
|
||||
const routeIds = Object.keys(routeCatalog.ROUTE_REGISTRY);
|
||||
const routeAbsent = routeIds.every((routeId) => !routeId.startsWith("REFERENCE_"));
|
||||
checks.push(["route-absent", routeAbsent]);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { supplyChainDigest } from "./lib/supply-chain.mjs";
|
||||
import { supplyChainDigest } from "./lib/supply-chain.ts";
|
||||
|
||||
const owner = process.env.DEPENDENCY_BASELINE_OWNER;
|
||||
const reason = process.env.DEPENDENCY_BASELINE_REASON;
|
||||
@@ -12,10 +12,10 @@ if (!owner?.trim() || !reason?.trim()) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const commands = /** @type {Array<[string, string[]]>} */ ([
|
||||
const commands: Array<[string, string[]]> = [
|
||||
["corepack", ["pnpm", "build"]],
|
||||
["node", ["scripts/generate-supply-chain.mjs", "--no-baseline"]],
|
||||
]);
|
||||
["node", ["scripts/generate-supply-chain.ts", "--no-baseline"]],
|
||||
];
|
||||
for (const [command, args] of commands) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" });
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { registrySnapshotDigest } from "./lib/registry-compatibility.mjs";
|
||||
import { registrySnapshotDigest } from "./lib/registry-compatibility.ts";
|
||||
|
||||
const inputPath =
|
||||
process.argv[2] ?? "artifacts/quality/registry-current-snapshot.json";
|
||||
@@ -3,18 +3,19 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
MANUAL_A11Y_ROUTE_IDS,
|
||||
validateManualA11yEvidence,
|
||||
} from "./lib/manual-a11y-evidence.mjs";
|
||||
} from "./lib/manual-a11y-evidence.ts";
|
||||
|
||||
/** @type {Array<{
|
||||
* routeId: string;
|
||||
* path: string;
|
||||
* reviewer: string | null;
|
||||
* reviewedAt: string | null;
|
||||
* releaseId: string | null;
|
||||
* failures: readonly string[];
|
||||
* passed: boolean;
|
||||
* }>} */
|
||||
const results = [];
|
||||
type ManualA11yResult = Readonly<{
|
||||
routeId: string;
|
||||
path: string;
|
||||
reviewer: string | null;
|
||||
reviewedAt: string | null;
|
||||
releaseId: string | null;
|
||||
failures: readonly string[];
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
const results: ManualA11yResult[] = [];
|
||||
for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
|
||||
const path = `artifacts/tests/a11y-manual/${routeId}.md`;
|
||||
const evidence = await readFile(path, "utf8");
|
||||
@@ -0,0 +1,89 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const evidencePath =
|
||||
"artifacts/tests/browser-capabilities/results.xml";
|
||||
const xml = await readFile(evidencePath, "utf8");
|
||||
const failures: string[] = [];
|
||||
const rootAttributes =
|
||||
/<testsuites\b([^>]*)>/u.exec(xml)?.[1] ?? "";
|
||||
const root = attributes(rootAttributes);
|
||||
|
||||
for (const field of ["failures", "errors", "skipped"] as const) {
|
||||
if (root[field] !== "0") {
|
||||
failures.push(`root ${field} must be zero`);
|
||||
}
|
||||
}
|
||||
if (!positiveInteger(root.tests)) {
|
||||
failures.push("root tests must be positive");
|
||||
}
|
||||
|
||||
const casesByEngine = new Map<string, Set<string>>();
|
||||
for (const match of xml.matchAll(
|
||||
/<testsuite\b([^>]*)>([\s\S]*?)<\/testsuite>/gu,
|
||||
)) {
|
||||
const suite = attributes(match[1] ?? "");
|
||||
const engine = suite.hostname;
|
||||
if (!engine) continue;
|
||||
const cases =
|
||||
casesByEngine.get(engine) ?? new Set<string>();
|
||||
for (const testCase of (match[2] ?? "").matchAll(
|
||||
/<testcase\b([^>]*)>/gu,
|
||||
)) {
|
||||
const data = attributes(testCase[1] ?? "");
|
||||
if (data.classname && data.name) {
|
||||
cases.add(`${data.classname}::${data.name}`);
|
||||
}
|
||||
}
|
||||
casesByEngine.set(engine, cases);
|
||||
}
|
||||
|
||||
const expectedEngines = ["chromium", "firefox", "webkit"] as const;
|
||||
const baseline = casesByEngine.get(expectedEngines[0]);
|
||||
for (const engine of expectedEngines) {
|
||||
const cases = casesByEngine.get(engine);
|
||||
if (!cases || cases.size === 0) {
|
||||
failures.push(`${engine} has no executed browser-capability cases`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
baseline &&
|
||||
(cases.size !== baseline.size ||
|
||||
[...baseline].some((testCase) => !cases.has(testCase)))
|
||||
) {
|
||||
failures.push(`${engine} case set differs from chromium`);
|
||||
}
|
||||
}
|
||||
for (const engine of casesByEngine.keys()) {
|
||||
if (!expectedEngines.includes(engine as (typeof expectedEngines)[number])) {
|
||||
failures.push(`unexpected browser project ${engine}`);
|
||||
}
|
||||
}
|
||||
if (/<skipped\b/u.test(xml) || /<failure\b/u.test(xml)) {
|
||||
failures.push("browser-capability evidence contains skipped/failure nodes");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(
|
||||
`Browser capability evidence failed:\n- ${failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Browser capability evidence: PASS (${baseline?.size ?? 0} cases x ${expectedEngines.length} engines, zero skipped)\n`,
|
||||
);
|
||||
|
||||
function attributes(source: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
[...source.matchAll(/([A-Za-z][A-Za-z0-9_-]*)="([^"]*)"/gu)].map(
|
||||
(match) => [match[1] ?? "", match[2] ?? ""],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined): boolean {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
/^\d+$/u.test(value) &&
|
||||
Number(value) > 0
|
||||
);
|
||||
}
|
||||
+20
-1
@@ -1,8 +1,27 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
type DocumentationReview = Readonly<{
|
||||
sourcePath: string;
|
||||
sha256: string;
|
||||
thresholdSatisfied: boolean;
|
||||
verdict: string;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
type ReviewLedger = Readonly<{
|
||||
evidenceReport: Readonly<{
|
||||
repoPath: string;
|
||||
canonicalSha256: string;
|
||||
}>;
|
||||
reviews: Record<string, DocumentationReview>;
|
||||
reviewer: string;
|
||||
standard: string;
|
||||
status: string;
|
||||
}>;
|
||||
|
||||
const ledger = JSON.parse(
|
||||
await readFile("docs/architecture/review-ledger.json", "utf8"),
|
||||
);
|
||||
) as ReviewLedger;
|
||||
const evidence = await readFile(ledger.evidenceReport.repoPath, "utf8");
|
||||
const results = [];
|
||||
for (const [diagram, review] of Object.entries(ledger.reviews)) {
|
||||
@@ -1,13 +1,62 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
|
||||
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.mjs";
|
||||
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.ts";
|
||||
|
||||
const cachePolicy = JSON.parse(
|
||||
await readFile("config/hosting/cache-policy.json", "utf8"),
|
||||
);
|
||||
const securityPolicy = JSON.parse(
|
||||
await readFile("config/hosting/security-headers.json", "utf8"),
|
||||
type Document = Record<string, unknown>;
|
||||
type ResponseHeaders = Record<string, Record<string, string>>;
|
||||
type HostingMode = "live" | "invalid-live" | "fixture";
|
||||
type ProbeResult = Readonly<{
|
||||
surface: string;
|
||||
header: string;
|
||||
expected: unknown;
|
||||
observed: unknown;
|
||||
reason?: string;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Document {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Document {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(recordValue(value)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function responseHeaders(value: unknown): ResponseHeaders {
|
||||
return Object.fromEntries(
|
||||
Object.entries(recordValue(value)).map(([surface, headers]) => [
|
||||
surface,
|
||||
stringRecord(headers),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function readDocument(file: string): Promise<Document> {
|
||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const cachePolicy = await readDocument("config/hosting/cache-policy.json");
|
||||
const cacheSurfaces = recordValue(cachePolicy.surfaces);
|
||||
const securityPolicy = await readDocument(
|
||||
"config/hosting/security-headers.json",
|
||||
);
|
||||
const securityHeaders = stringRecord(securityPolicy.headers);
|
||||
const baseUrl = process.env.HOSTING_BASE_URL;
|
||||
const liveTarget = baseUrl ? classifyLiveHostingBaseUrl(baseUrl) : null;
|
||||
const distFiles = (await readdir("dist", { recursive: true })).map(String);
|
||||
@@ -16,18 +65,9 @@ const publicServiceWorkers = distFiles.filter((file) =>
|
||||
/(?:^|\/)(?:service-worker|sw)(?:[.-][^/]*)?\.js$/i.test(file),
|
||||
);
|
||||
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
let responses = {};
|
||||
let mode;
|
||||
/** @type {Array<{
|
||||
* surface: string;
|
||||
* header: string;
|
||||
* expected: unknown;
|
||||
* observed: unknown;
|
||||
* reason?: string;
|
||||
* passed: boolean;
|
||||
* }>} */
|
||||
const probeResults = [];
|
||||
let responses: ResponseHeaders = {};
|
||||
let mode: HostingMode;
|
||||
const probeResults: ProbeResult[] = [];
|
||||
|
||||
if (liveTarget?.passed) {
|
||||
mode = "live";
|
||||
@@ -40,7 +80,6 @@ if (liveTarget?.passed) {
|
||||
releaseManifest: "/release-manifest.json",
|
||||
hashedAsset: `/assets/${hashedJavaScript}`,
|
||||
};
|
||||
responses = {};
|
||||
for (const [surface, pathname] of Object.entries(paths)) {
|
||||
const requestedUrl = new URL(pathname, liveTarget.url);
|
||||
try {
|
||||
@@ -68,7 +107,7 @@ if (liveTarget?.passed) {
|
||||
value,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
probeResults.push({
|
||||
surface,
|
||||
header: "transport",
|
||||
@@ -90,14 +129,17 @@ if (liveTarget?.passed) {
|
||||
});
|
||||
} else {
|
||||
mode = "fixture";
|
||||
responses = JSON.parse(
|
||||
await readFile("config/hosting/response-headers.fixture.json", "utf8"),
|
||||
).responses;
|
||||
const fixture = await readDocument(
|
||||
"config/hosting/response-headers.fixture.json",
|
||||
);
|
||||
responses = responseHeaders(fixture.responses);
|
||||
}
|
||||
|
||||
const results = [...probeResults];
|
||||
for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
|
||||
if (!("cacheControl" in policy)) continue;
|
||||
const results: ProbeResult[] = [...probeResults];
|
||||
for (const [surface, rawPolicy] of Object.entries(cacheSurfaces)) {
|
||||
const policy = recordValue(rawPolicy);
|
||||
if (typeof policy.cacheControl !== "string") continue;
|
||||
const contentTypes = strings(policy.contentTypes);
|
||||
const observed = responses[surface]?.["cache-control"];
|
||||
results.push({
|
||||
surface,
|
||||
@@ -109,17 +151,17 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
|
||||
const observedContentType = responses[surface]?.["content-type"];
|
||||
const observedMime = observedContentType
|
||||
?.split(";", 1)[0]
|
||||
.trim()
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
results.push({
|
||||
surface,
|
||||
header: "content-type",
|
||||
expected: policy.contentTypes,
|
||||
expected: contentTypes,
|
||||
observed: observedContentType,
|
||||
passed: policy.contentTypes.includes(observedMime),
|
||||
passed: observedMime !== undefined && contentTypes.includes(observedMime),
|
||||
});
|
||||
if (policy.securityHeaders) {
|
||||
for (const [header, expected] of Object.entries(securityPolicy.headers)) {
|
||||
if (policy.securityHeaders === true) {
|
||||
for (const [header, expected] of Object.entries(securityHeaders)) {
|
||||
const observedSecurity = responses[surface]?.[header.toLowerCase()];
|
||||
results.push({
|
||||
surface,
|
||||
@@ -132,23 +174,22 @@ for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) {
|
||||
}
|
||||
}
|
||||
|
||||
const sourceMapPolicy = recordValue(cacheSurfaces.sourceMap);
|
||||
results.push({
|
||||
surface: "sourceMap",
|
||||
header: "public",
|
||||
expected: false,
|
||||
observed: publicSourceMaps.length > 0,
|
||||
passed:
|
||||
cachePolicy.surfaces.sourceMap.public === false &&
|
||||
publicSourceMaps.length === 0,
|
||||
passed: sourceMapPolicy.public === false && publicSourceMaps.length === 0,
|
||||
});
|
||||
const serviceWorkerPolicy = recordValue(cacheSurfaces.serviceWorker);
|
||||
results.push({
|
||||
surface: "serviceWorker",
|
||||
header: "enabled",
|
||||
expected: false,
|
||||
observed: publicServiceWorkers.length > 0,
|
||||
passed:
|
||||
cachePolicy.surfaces.serviceWorker.enabled === false &&
|
||||
publicServiceWorkers.length === 0,
|
||||
serviceWorkerPolicy.enabled === false && publicServiceWorkers.length === 0,
|
||||
});
|
||||
|
||||
const passed = results.every((result) => result.passed);
|
||||
@@ -1,164 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
|
||||
import {
|
||||
compareReleaseToRuntime,
|
||||
RELEASE_TOKEN_REGISTRY,
|
||||
} from "../src/contracts/release-tokens.js";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.js";
|
||||
|
||||
const fixturesDocument =
|
||||
/** @type {{
|
||||
* fixtures: Array<{
|
||||
* name: string,
|
||||
* expectedCompatible: boolean,
|
||||
* frontend: {
|
||||
* buildId: string,
|
||||
* configSchemaVersion: string,
|
||||
* apiContractVersion: string,
|
||||
* assetManifestHash: string,
|
||||
* releaseId: string
|
||||
* },
|
||||
* runtime: {
|
||||
* buildId: string,
|
||||
* configSchemaVersion: string,
|
||||
* apiContractVersion: string,
|
||||
* assetManifestHash: string,
|
||||
* releaseId: string
|
||||
* }
|
||||
* }>
|
||||
* }} */ (
|
||||
JSON.parse(
|
||||
await readFile("config/release/coherence-fixtures.json", "utf8"),
|
||||
)
|
||||
);
|
||||
const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8"));
|
||||
const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8"));
|
||||
const buildManifest = JSON.parse(
|
||||
await readFile("artifacts/release/build-manifest.json", "utf8"),
|
||||
);
|
||||
const runtimeConfigJsonSchema = JSON.parse(
|
||||
await readFile("dist/runtime-config.schema.json", "utf8"),
|
||||
);
|
||||
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
||||
const viteManifestObject =
|
||||
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
|
||||
JSON.parse(viteManifest)
|
||||
);
|
||||
const actualAssetManifestHash = createHash("sha256")
|
||||
.update(viteManifest)
|
||||
.digest("hex");
|
||||
|
||||
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
|
||||
const artifactMismatches = [...artifactComparison.mismatches];
|
||||
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
|
||||
if (typeof release[token] !== "string" || release[token].length === 0) {
|
||||
artifactMismatches.push(`releaseToken:${token}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(Date.parse(release.builtAt))) {
|
||||
artifactMismatches.push("releaseToken:builtAtFormat");
|
||||
}
|
||||
if (release.assetManifestHash !== actualAssetManifestHash) {
|
||||
artifactMismatches.push("assetManifestContent");
|
||||
}
|
||||
if (
|
||||
runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" ||
|
||||
runtimeConfigJsonSchema.type !== "object" ||
|
||||
!runtimeConfigJsonSchema.properties
|
||||
) {
|
||||
artifactMismatches.push("runtimeConfigSchema");
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.runtimeConfigSchema !==
|
||||
"dist/runtime-config.schema.json"
|
||||
) {
|
||||
artifactMismatches.push("buildManifest:runtimeConfigSchema");
|
||||
}
|
||||
|
||||
const expectedChunkIds = new Set(
|
||||
Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId),
|
||||
);
|
||||
const actualChunkIds = new Set(Object.keys(release.routeChunks ?? {}));
|
||||
for (const chunkId of expectedChunkIds) {
|
||||
if (!actualChunkIds.has(chunkId)) {
|
||||
artifactMismatches.push(`routeChunk:missing:${chunkId}`);
|
||||
}
|
||||
}
|
||||
for (const chunkId of actualChunkIds) {
|
||||
if (!expectedChunkIds.has(chunkId)) {
|
||||
artifactMismatches.push(`routeChunk:orphan:${chunkId}`);
|
||||
}
|
||||
}
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
const runtime =
|
||||
/** @type {Record<string, {moduleId: string}>} */ (
|
||||
ROUTE_RUNTIME_CONTRACT
|
||||
)[definition.routeId];
|
||||
const viteEntry = Object.values(viteManifestObject).find(
|
||||
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
|
||||
);
|
||||
const routeAsset = release.routeChunks?.[definition.chunkId];
|
||||
if (!runtime || !viteEntry || routeAsset !== viteEntry.file) {
|
||||
artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
|
||||
) {
|
||||
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
|
||||
}
|
||||
try {
|
||||
await readFile(`dist/${routeAsset}`);
|
||||
} catch {
|
||||
artifactMismatches.push(`routeChunk:file:${definition.chunkId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fixtures = fixturesDocument.fixtures.map((fixture) => {
|
||||
const result = verifyCompatibilityTuple({
|
||||
frontend: fixture.frontend,
|
||||
runtime: fixture.runtime,
|
||||
});
|
||||
return {
|
||||
name: fixture.name,
|
||||
expectedCompatible: fixture.expectedCompatible,
|
||||
actualCompatible: result.compatible,
|
||||
mismatches: result.mismatches,
|
||||
passed: result.compatible === fixture.expectedCompatible,
|
||||
};
|
||||
});
|
||||
const artifact = {
|
||||
checked: true,
|
||||
compatible: artifactComparison.compatible && artifactMismatches.length === 0,
|
||||
mismatches: artifactMismatches,
|
||||
releaseId: release.releaseId,
|
||||
};
|
||||
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
artifact,
|
||||
fixtures,
|
||||
passed,
|
||||
};
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/release/verification.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
|
||||
);
|
||||
@@ -0,0 +1,320 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
verifyCompatibilityTuple,
|
||||
type CompatibilityTuple,
|
||||
} from "../src/application/policies/compatibility.ts";
|
||||
import {
|
||||
compareReleaseToRuntime,
|
||||
RELEASE_TOKEN_REGISTRY,
|
||||
} from "../src/contracts/release-tokens.ts";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
|
||||
type CoherenceFixture = Readonly<{
|
||||
name: string;
|
||||
expectedCompatible: boolean;
|
||||
frontend: CompatibilityTuple;
|
||||
runtime: CompatibilityTuple;
|
||||
}>;
|
||||
type ReleaseDocument = CompatibilityTuple &
|
||||
Record<string, unknown> &
|
||||
Readonly<{ releaseId: string; routeChunks: Readonly<Record<string, unknown>> }>;
|
||||
type RuntimeConfigDocument = Readonly<{
|
||||
BUILD_ID: string;
|
||||
CONFIG_SCHEMA_VERSION: string;
|
||||
API_CONTRACT_VERSION: string;
|
||||
RELEASE_ID: string;
|
||||
}>;
|
||||
type BuildManifestDocument = Readonly<
|
||||
Record<string, unknown> & {
|
||||
outputs?: Readonly<{
|
||||
runtimeConfigSchema?: unknown;
|
||||
routeChunks?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
name?: string;
|
||||
isDynamicEntry?: boolean;
|
||||
}>;
|
||||
|
||||
const fixturesDocument = parseFixturesDocument(
|
||||
JSON.parse(
|
||||
await readFile("config/release/coherence-fixtures.json", "utf8"),
|
||||
),
|
||||
);
|
||||
const release = parseReleaseDocument(
|
||||
JSON.parse(await readFile("dist/release-manifest.json", "utf8")),
|
||||
);
|
||||
const runtimeConfig = parseRuntimeConfigDocument(
|
||||
JSON.parse(await readFile("dist/config.json", "utf8")),
|
||||
);
|
||||
const buildManifest = parseBuildManifestDocument(
|
||||
JSON.parse(await readFile("artifacts/release/build-manifest.json", "utf8")),
|
||||
);
|
||||
const runtimeConfigJsonSchema = requireRecord(
|
||||
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
|
||||
"runtime config JSON schema",
|
||||
);
|
||||
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
||||
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
|
||||
const actualAssetManifestHash = createHash("sha256")
|
||||
.update(viteManifest)
|
||||
.digest("hex");
|
||||
|
||||
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
|
||||
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
||||
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
|
||||
if (typeof release[token] !== "string" || release[token].length === 0) {
|
||||
artifactMismatches.push(`releaseToken:${token}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof release.builtAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(release.builtAt))
|
||||
) {
|
||||
artifactMismatches.push("releaseToken:builtAtFormat");
|
||||
}
|
||||
if (release.assetManifestHash !== actualAssetManifestHash) {
|
||||
artifactMismatches.push("assetManifestContent");
|
||||
}
|
||||
if (
|
||||
runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" ||
|
||||
runtimeConfigJsonSchema.type !== "object" ||
|
||||
!runtimeConfigJsonSchema.properties
|
||||
) {
|
||||
artifactMismatches.push("runtimeConfigSchema");
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.runtimeConfigSchema !==
|
||||
"dist/runtime-config.schema.json"
|
||||
) {
|
||||
artifactMismatches.push("buildManifest:runtimeConfigSchema");
|
||||
}
|
||||
for (const [buildToken, releaseToken] of [
|
||||
["buildId", "buildId"],
|
||||
["commitSha", "commitSha"],
|
||||
["releaseId", "releaseId"],
|
||||
["generatedAt", "builtAt"],
|
||||
]) {
|
||||
if (buildManifest[buildToken] !== release[releaseToken]) {
|
||||
artifactMismatches.push(`buildManifest:${buildToken}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedChunkIds = new Set(
|
||||
Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId),
|
||||
);
|
||||
const actualChunkIds = new Set(Object.keys(release.routeChunks));
|
||||
for (const chunkId of expectedChunkIds) {
|
||||
if (!actualChunkIds.has(chunkId)) {
|
||||
artifactMismatches.push(`routeChunk:missing:${chunkId}`);
|
||||
}
|
||||
}
|
||||
for (const chunkId of actualChunkIds) {
|
||||
if (!expectedChunkIds.has(chunkId)) {
|
||||
artifactMismatches.push(`routeChunk:orphan:${chunkId}`);
|
||||
}
|
||||
}
|
||||
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
|
||||
ROUTE_RUNTIME_CONTRACT;
|
||||
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
const runtime = runtimeContracts[definition.routeId];
|
||||
const viteEntry = Object.values(viteManifestObject).find(
|
||||
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
|
||||
);
|
||||
const routeAsset = release.routeChunks[definition.chunkId];
|
||||
if (!runtime || !viteEntry || routeAsset !== viteEntry.file) {
|
||||
artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
|
||||
) {
|
||||
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
|
||||
}
|
||||
try {
|
||||
await readFile(`dist/${routeAsset}`);
|
||||
} catch {
|
||||
artifactMismatches.push(`routeChunk:file:${definition.chunkId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fixtures = fixturesDocument.fixtures.map((fixture) => {
|
||||
const result = verifyCompatibilityTuple({
|
||||
frontend: fixture.frontend,
|
||||
runtime: fixture.runtime,
|
||||
});
|
||||
return {
|
||||
name: fixture.name,
|
||||
expectedCompatible: fixture.expectedCompatible,
|
||||
actualCompatible: result.compatible,
|
||||
mismatches: result.mismatches,
|
||||
passed: result.compatible === fixture.expectedCompatible,
|
||||
};
|
||||
});
|
||||
const artifact = {
|
||||
checked: true,
|
||||
compatible: artifactComparison.compatible && artifactMismatches.length === 0,
|
||||
mismatches: artifactMismatches,
|
||||
releaseId: release.releaseId,
|
||||
};
|
||||
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
artifact,
|
||||
fixtures,
|
||||
passed,
|
||||
};
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/release/verification.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`,
|
||||
);
|
||||
|
||||
function parseFixturesDocument(value: unknown): Readonly<{
|
||||
fixtures: readonly CoherenceFixture[];
|
||||
}> {
|
||||
const document = requireRecord(value, "release coherence fixtures");
|
||||
if (!Array.isArray(document.fixtures)) {
|
||||
throw new TypeError("release coherence fixtures must be an array");
|
||||
}
|
||||
return {
|
||||
fixtures: document.fixtures.map((candidate, index) => {
|
||||
const fixture = requireRecord(candidate, `release fixture ${index}`);
|
||||
if (
|
||||
typeof fixture.name !== "string" ||
|
||||
typeof fixture.expectedCompatible !== "boolean"
|
||||
) {
|
||||
throw new TypeError(`Invalid release fixture metadata: ${index}`);
|
||||
}
|
||||
return {
|
||||
name: fixture.name,
|
||||
expectedCompatible: fixture.expectedCompatible,
|
||||
frontend: parseCompatibilityTuple(
|
||||
fixture.frontend,
|
||||
`release fixture ${index}.frontend`,
|
||||
),
|
||||
runtime: parseCompatibilityTuple(
|
||||
fixture.runtime,
|
||||
`release fixture ${index}.runtime`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReleaseDocument(value: unknown): ReleaseDocument {
|
||||
const document = requireRecord(value, "release manifest");
|
||||
const tuple = parseCompatibilityTuple(document, "release manifest");
|
||||
const routeChunks = isRecord(document.routeChunks)
|
||||
? document.routeChunks
|
||||
: {};
|
||||
return { ...document, ...tuple, routeChunks };
|
||||
}
|
||||
|
||||
function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument {
|
||||
const document = requireRecord(value, "runtime config");
|
||||
return {
|
||||
BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"),
|
||||
CONFIG_SCHEMA_VERSION: requireString(
|
||||
document.CONFIG_SCHEMA_VERSION,
|
||||
"runtime config CONFIG_SCHEMA_VERSION",
|
||||
),
|
||||
API_CONTRACT_VERSION: requireString(
|
||||
document.API_CONTRACT_VERSION,
|
||||
"runtime config API_CONTRACT_VERSION",
|
||||
),
|
||||
RELEASE_ID: requireString(
|
||||
document.RELEASE_ID,
|
||||
"runtime config RELEASE_ID",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBuildManifestDocument(value: unknown): BuildManifestDocument {
|
||||
const document = requireRecord(value, "build manifest");
|
||||
const outputs = isRecord(document.outputs)
|
||||
? {
|
||||
runtimeConfigSchema: document.outputs.runtimeConfigSchema,
|
||||
routeChunks: isRecord(document.outputs.routeChunks)
|
||||
? document.outputs.routeChunks
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
return { ...document, ...(outputs ? { outputs } : {}) };
|
||||
}
|
||||
|
||||
function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple {
|
||||
const document = requireRecord(value, label);
|
||||
return {
|
||||
buildId: requireString(document.buildId, `${label}.buildId`),
|
||||
configSchemaVersion: requireString(
|
||||
document.configSchemaVersion,
|
||||
`${label}.configSchemaVersion`,
|
||||
),
|
||||
apiContractVersion: requireString(
|
||||
document.apiContractVersion,
|
||||
`${label}.apiContractVersion`,
|
||||
),
|
||||
assetManifestHash: requireString(
|
||||
document.assetManifestHash,
|
||||
`${label}.assetManifestHash`,
|
||||
),
|
||||
releaseId: requireString(document.releaseId, `${label}.releaseId`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseViteManifest(
|
||||
value: unknown,
|
||||
): Readonly<Record<string, ViteManifestEntry>> {
|
||||
const document = requireRecord(value, "Vite manifest");
|
||||
const entries: Record<string, ViteManifestEntry> = {};
|
||||
for (const [key, candidate] of Object.entries(document)) {
|
||||
const entry = requireRecord(candidate, `Vite manifest entry ${key}`);
|
||||
entries[key] = {
|
||||
file: requireString(entry.file, `Vite manifest entry ${key}.file`),
|
||||
...(typeof entry.name === "string" ? { name: entry.name } : {}),
|
||||
...(typeof entry.isDynamicEntry === "boolean"
|
||||
? { isDynamicEntry: entry.isDynamicEntry }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new TypeError(`${label} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -2,21 +2,23 @@ import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { supplyChainDigest } from "./lib/supply-chain.mjs";
|
||||
import { assertCiBuildEnvironment } from "./lib/build-environment.ts";
|
||||
import { supplyChainDigest } from "./lib/supply-chain.ts";
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesWithin(directory) {
|
||||
assertCiBuildEnvironment(process.env);
|
||||
|
||||
async function filesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = /** @type {string[][]} */ (await Promise.all(
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
||||
}),
|
||||
));
|
||||
);
|
||||
return nested.flat().sort();
|
||||
}
|
||||
|
||||
async function distDigest() {
|
||||
async function distDigest(): Promise<string> {
|
||||
const rows = await Promise.all(
|
||||
(await filesWithin("dist")).map(async (file) => ({
|
||||
path: path.relative("dist", file).replaceAll("\\", "/"),
|
||||
@@ -27,7 +29,7 @@ async function distDigest() {
|
||||
return supplyChainDigest(rows);
|
||||
}
|
||||
|
||||
function build(environment = process.env) {
|
||||
function build(environment: NodeJS.ProcessEnv = process.env) {
|
||||
return spawnSync("corepack", ["pnpm", "build"], {
|
||||
env: environment,
|
||||
encoding: "utf8",
|
||||
@@ -58,6 +60,11 @@ await writeFile(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
|
||||
buildId: process.env.VITE_BUILD_ID ?? "local-build",
|
||||
commitSha: process.env.VITE_COMMIT_SHA ?? "local",
|
||||
releaseId: process.env.RELEASE_ID ?? "local-release",
|
||||
runnerImage:
|
||||
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
firstDigest,
|
||||
secondDigest,
|
||||
restored: restoreBuild.status === 0,
|
||||
+39
-28
@@ -7,34 +7,46 @@ import {
|
||||
parsePnpmLockfilePackages,
|
||||
supplyChainDigest,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.mjs";
|
||||
} from "./lib/supply-chain.ts";
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesWithin(directory) {
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Document {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseDocument(text: string, label: string): Document {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Document[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
async function readDocument(file: string): Promise<Document> {
|
||||
return parseDocument(await readFile(file, "utf8"), file);
|
||||
}
|
||||
|
||||
async function filesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = /** @type {string[][]} */ (await Promise.all(
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
||||
}),
|
||||
));
|
||||
);
|
||||
return nested.flat().sort();
|
||||
}
|
||||
|
||||
const inventory = JSON.parse(
|
||||
await readFile("artifacts/release/dependency-inventory.json", "utf8"),
|
||||
const inventory = await readDocument(
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
);
|
||||
const sbom = JSON.parse(
|
||||
await readFile("artifacts/release/sbom.cdx.json", "utf8"),
|
||||
);
|
||||
const provenance = JSON.parse(
|
||||
await readFile("artifacts/release/provenance.json", "utf8"),
|
||||
);
|
||||
const verification = JSON.parse(
|
||||
await readFile(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"utf8",
|
||||
),
|
||||
const sbom = await readDocument("artifacts/release/sbom.cdx.json");
|
||||
const provenance = await readDocument("artifacts/release/provenance.json");
|
||||
const verification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
@@ -57,7 +69,7 @@ const coherence = verifySupplyChainCoherence(
|
||||
provenance,
|
||||
distDigest,
|
||||
);
|
||||
const failures = [...coherence.failures];
|
||||
const failures: string[] = [...coherence.failures];
|
||||
if (
|
||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||
verification.lockfileSha256 !== lockfileSha256
|
||||
@@ -71,15 +83,14 @@ if (
|
||||
failures.push("verification digest set is incoherent");
|
||||
}
|
||||
const lockRows = parsePnpmLockfilePackages(lockfileText);
|
||||
const inventoryRows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
inventory.dependencies ?? []
|
||||
);
|
||||
const inventoryByIdentity = new Map(
|
||||
inventoryRows.map((entry) => [
|
||||
`${entry.name}@${entry.version}`,
|
||||
entry,
|
||||
]),
|
||||
const inventoryRows = recordRows(inventory.dependencies);
|
||||
const inventoryByIdentity = new Map<string, Document>(
|
||||
inventoryRows.map(
|
||||
(entry) => [
|
||||
`${String(entry.name ?? "")}@${String(entry.version ?? "")}`,
|
||||
entry,
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
if (lockRows.length !== inventoryRows.length) {
|
||||
failures.push("transitive dependency count differs from lockfile");
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.mjs";
|
||||
import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.ts";
|
||||
|
||||
await mkdir("artifacts/tests", { recursive: true });
|
||||
await writeFile(
|
||||
Reference in New Issue
Block a user