feat: harden test and registry evidence
This commit is contained in:
+243
-56
@@ -1,28 +1,65 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/** @param {string} name @param {string} fallback */
|
||||
import {
|
||||
canonicalizeRegistryValue,
|
||||
diffRegistrySnapshots,
|
||||
registrySnapshotDigest,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.mjs";
|
||||
|
||||
/** @param {string} name @param {string | undefined} fallback */
|
||||
function argumentValue(name, fallback) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
const governancePath = argumentValue(
|
||||
"--governance",
|
||||
"config/contracts/registry-governance.json",
|
||||
const defaultGovernancePath = "config/contracts/registry-governance.json";
|
||||
const governancePath =
|
||||
/** @type {string} */ (
|
||||
argumentValue("--governance", defaultGovernancePath)
|
||||
);
|
||||
const artifactPath =
|
||||
/** @type {string} */ (
|
||||
argumentValue("--artifact", "artifacts/quality/registries.json")
|
||||
);
|
||||
const usesRepositoryBaseline =
|
||||
governancePath === defaultGovernancePath &&
|
||||
!process.argv.includes("--no-baseline");
|
||||
const baselinePath = argumentValue(
|
||||
"--baseline",
|
||||
usesRepositoryBaseline
|
||||
? "config/contracts/registry-baseline.json"
|
||||
: undefined,
|
||||
);
|
||||
const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/quality/registries.json",
|
||||
const approvalPath = argumentValue(
|
||||
"--approval",
|
||||
usesRepositoryBaseline
|
||||
? "config/contracts/registry-baseline.approval.json"
|
||||
: undefined,
|
||||
);
|
||||
const governance = JSON.parse(
|
||||
await readFile(governancePath, "utf8"),
|
||||
const evidencePath = argumentValue(
|
||||
"--compatibility-evidence",
|
||||
usesRepositoryBaseline
|
||||
? "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"];
|
||||
|
||||
/** @param {string} declaredPath */
|
||||
@@ -38,7 +75,7 @@ async function resolveRegistrySource(declaredPath) {
|
||||
await access(candidate);
|
||||
candidates.push(candidate);
|
||||
} catch {
|
||||
// A migration may legitimately replace the declared extension.
|
||||
// A TypeScript migration may replace the declared extension.
|
||||
}
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
@@ -50,6 +87,44 @@ async function resolveRegistrySource(declaredPath) {
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function runtimeType(value) {
|
||||
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) {
|
||||
const actual = runtimeType(value);
|
||||
return declaration
|
||||
.split("|")
|
||||
.some(
|
||||
(candidate) =>
|
||||
candidate === actual ||
|
||||
(candidate === "number" && actual === "integer"),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} directory @returns {Promise<string[]>} */
|
||||
async function filesBelow(directory) {
|
||||
try {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(target) : [target];
|
||||
}),
|
||||
);
|
||||
return groups.flat().filter((file) =>
|
||||
/\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(file),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
for (const specification of governance.registries) {
|
||||
if (owners.has(specification.registryId)) {
|
||||
failures.push(`duplicate owner for ${specification.registryId}`);
|
||||
@@ -74,6 +149,10 @@ for (const specification of governance.registries) {
|
||||
}
|
||||
|
||||
rowsByRegistry.set(specification.registryId, rows);
|
||||
sourcesByRegistry.set(
|
||||
specification.registryId,
|
||||
sourcePath ?? specification.path,
|
||||
);
|
||||
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
||||
@@ -85,6 +164,26 @@ for (const specification of governance.registries) {
|
||||
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
||||
}
|
||||
}
|
||||
for (const [field, declaredType] of Object.entries(
|
||||
specification.fieldTypes ?? {},
|
||||
)) {
|
||||
if (
|
||||
field in row &&
|
||||
!matchesDeclaredType(row[field], String(declaredType))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
specification.keyField &&
|
||||
row[specification.keyField] !== rowName
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of specification.uniqueFields ?? []) {
|
||||
@@ -93,12 +192,13 @@ for (const specification of governance.registries) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const value = row[field];
|
||||
if (value === undefined) continue;
|
||||
if (values.has(value)) {
|
||||
const identity = JSON.stringify(canonicalizeRegistryValue(value));
|
||||
if (values.has(identity)) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(value)}`,
|
||||
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`,
|
||||
);
|
||||
} else {
|
||||
values.set(value, rowName);
|
||||
values.set(identity, rowName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,12 +221,22 @@ for (const specification of governance.registries) {
|
||||
}
|
||||
}
|
||||
|
||||
const contract = Object.freeze({
|
||||
requiredFields: specification.requiredFields,
|
||||
fieldTypes: specification.fieldTypes ?? {},
|
||||
uniqueFields: specification.uniqueFields ?? [],
|
||||
allowedValues: specification.allowedValues ?? {},
|
||||
references: specification.references ?? [],
|
||||
keyField: specification.keyField ?? null,
|
||||
breakingFields: specification.breakingFields ?? [],
|
||||
});
|
||||
snapshots.push({
|
||||
registryId: specification.registryId,
|
||||
owner: specification.owner,
|
||||
source: sourcePath ?? specification.path,
|
||||
rowCount: Object.keys(rows).length,
|
||||
rows,
|
||||
contract,
|
||||
rows: canonicalizeRegistryValue(rows),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,18 +255,74 @@ for (const specification of governance.registries) {
|
||||
Object.values(targetRows)
|
||||
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
||||
.map((row) => row[reference.targetField])
|
||||
.filter((value) => value !== undefined),
|
||||
.filter((value) => value !== undefined && value !== null),
|
||||
);
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const value = row[reference.field];
|
||||
if (value !== undefined && !targetValues.has(value)) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
!targetValues.has(value)
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const consumer of specification.consumers ?? []) {
|
||||
try {
|
||||
const source = await readFile(consumer.path, "utf8");
|
||||
if (!source.includes(consumer.token)) {
|
||||
failures.push(
|
||||
`${specification.registryId} consumer ${consumer.path} is missing ${consumer.token}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
failures.push(
|
||||
`${specification.registryId} consumer source is missing: ${consumer.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (specification.consumerIdentityField) {
|
||||
const consumerFiles = (
|
||||
await Promise.all(
|
||||
(specification.consumerDirectories ?? []).map(filesBelow),
|
||||
)
|
||||
).flat();
|
||||
const sourcePath = sourcesByRegistry.get(specification.registryId);
|
||||
const consumerText = (
|
||||
await Promise.all(
|
||||
consumerFiles
|
||||
.filter((file) => file !== sourcePath)
|
||||
.map((file) => readFile(file, "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
const exemptions = new Set(specification.orphanExemptRows ?? []);
|
||||
for (const [rowName, row] of Object.entries(rows)) {
|
||||
if (
|
||||
!row ||
|
||||
typeof row !== "object" ||
|
||||
Array.isArray(row) ||
|
||||
exemptions.has(rowName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const identity = row[specification.consumerIdentityField];
|
||||
if (
|
||||
(typeof identity !== "string" &&
|
||||
typeof identity !== "number") ||
|
||||
!consumerText.includes(String(identity))
|
||||
) {
|
||||
failures.push(
|
||||
`${specification.registryId}.${rowName} has no executable consumer for ${specification.consumerIdentityField}=${String(identity)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = governance.sourceDirectories ?? [
|
||||
@@ -166,59 +332,80 @@ const sourceFiles = governance.sourceDirectories ?? [
|
||||
];
|
||||
const adHocPatterns = [
|
||||
{ name: "direct fetch", expression: /\bfetch\s*\(/ },
|
||||
{ name: "direct localStorage", expression: /\blocalStorage\.(?:get|set|remove)Item/ },
|
||||
{
|
||||
name: "direct localStorage",
|
||||
expression: /\blocalStorage\.(?:get|set|remove)Item/,
|
||||
},
|
||||
{ name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
|
||||
{ name: "raw API path", expression: /["']\/api\// },
|
||||
];
|
||||
|
||||
/** @param {string} directory */
|
||||
async function scanDirectory(directory) {
|
||||
try {
|
||||
await access(directory);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const entries = await import("node:fs/promises").then(({ readdir }) =>
|
||||
readdir(directory, { withFileTypes: true }),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await scanDirectory(target);
|
||||
continue;
|
||||
}
|
||||
if (!/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) continue;
|
||||
const content = await readFile(target, "utf8");
|
||||
for (const sourceDirectory of sourceFiles) {
|
||||
for (const file of await filesBelow(sourceDirectory)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
for (const pattern of adHocPatterns) {
|
||||
if (pattern.expression.test(content)) {
|
||||
failures.push(`ad-hoc ${pattern.name} in ${target}`);
|
||||
failures.push(`ad-hoc ${pattern.name} in ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceDirectory of sourceFiles) {
|
||||
await scanDirectory(sourceDirectory);
|
||||
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: [],
|
||||
});
|
||||
|
||||
if (baselinePath && approvalPath && evidencePath) {
|
||||
try {
|
||||
const baseline = JSON.parse(await readFile(baselinePath, "utf8"));
|
||||
const approval = JSON.parse(await readFile(approvalPath, "utf8"));
|
||||
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
|
||||
baselineDigest = approvalResult.actualDigest;
|
||||
if (!approvalResult.passed) {
|
||||
failures.push(
|
||||
`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);
|
||||
failures.push(...evidenceResult.failures);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`registry compatibility evidence unavailable: ${
|
||||
error instanceof Error ? error.name : "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
artifactPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
compatibilityImpact: governance.compatibilityImpact.current,
|
||||
failures,
|
||||
registries: snapshots,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const report = {
|
||||
schemaVersion: 2,
|
||||
generatedAt: new Date().toISOString(),
|
||||
baselineDigest,
|
||||
currentDigest,
|
||||
compatibility,
|
||||
failures,
|
||||
registries: snapshots,
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Registry governance: ${snapshots.length} registries PASS\n`);
|
||||
process.stdout.write(
|
||||
`Registry governance: ${snapshots.length} registries PASS; compatibility=${compatibility.impact}\n`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
diffRegistrySnapshots,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.mjs";
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
await readFile(
|
||||
"tests/fixtures/registry/compatibility/semantic-diff.json",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const results = [];
|
||||
for (const fixture of fixtures.cases) {
|
||||
const actual = diffRegistrySnapshots(fixture.before, fixture.after);
|
||||
results.push({
|
||||
id: fixture.id,
|
||||
expected: fixture.expected,
|
||||
actual: actual.impact,
|
||||
passed: actual.impact === fixture.expected,
|
||||
});
|
||||
}
|
||||
|
||||
const breaking = diffRegistrySnapshots(
|
||||
fixtures.breakingEvidence.before,
|
||||
fixtures.breakingEvidence.after,
|
||||
);
|
||||
const missingEvidence = validateBreakingEvidence(breaking, {
|
||||
schemaVersion: 1,
|
||||
changes: [],
|
||||
});
|
||||
results.push({
|
||||
id: "breaking-evidence-required",
|
||||
expected: false,
|
||||
actual: missingEvidence.passed,
|
||||
passed: !missingEvidence.passed,
|
||||
});
|
||||
|
||||
const tamperedApproval = verifyRegistryBaselineApproval(
|
||||
fixtures.tamperedApproval.snapshot,
|
||||
fixtures.tamperedApproval.approval,
|
||||
);
|
||||
results.push({
|
||||
id: "tampered-baseline-digest",
|
||||
expected: false,
|
||||
actual: tamperedApproval.passed,
|
||||
passed: !tamperedApproval.passed,
|
||||
});
|
||||
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/registry-compatibility-fixtures.json",
|
||||
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
|
||||
);
|
||||
if (results.some((result) => !result.passed)) {
|
||||
process.stderr.write("Registry compatibility fixture failed.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Registry compatibility fixtures: ${results.length} PASS\n`,
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdir, readFile, 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 policyPath = argumentValue(
|
||||
"--policy",
|
||||
"config/testing/risk-coverage.json",
|
||||
);
|
||||
const summaryPath = argumentValue(
|
||||
"--summary",
|
||||
"artifacts/tests/coverage/coverage-summary.json",
|
||||
);
|
||||
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 = [];
|
||||
|
||||
/**
|
||||
* @param {string} scope
|
||||
* @param {Record<string, {pct: number}>} actual
|
||||
* @param {Record<string, number>} minimum
|
||||
*/
|
||||
function evaluate(scope, actual, minimum) {
|
||||
for (const [metric, threshold] of Object.entries(minimum)) {
|
||||
const received = actual?.[metric]?.pct;
|
||||
const passed =
|
||||
typeof received === "number" &&
|
||||
Number.isFinite(received) &&
|
||||
received >= threshold;
|
||||
results.push({ scope, metric, threshold, received, passed });
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${scope}.${metric} expected >= ${threshold}, received ${String(received)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
evaluate("total", summary.total, policy.summary);
|
||||
for (const modulePolicy of policy.criticalModules) {
|
||||
const key = Object.keys(summary).find(
|
||||
(candidate) =>
|
||||
candidate !== "total" &&
|
||||
candidate.replaceAll("\\", "/").endsWith(`/${modulePolicy.path}`),
|
||||
);
|
||||
if (!key) {
|
||||
failures.push(`critical module missing from coverage: ${modulePolicy.path}`);
|
||||
continue;
|
||||
}
|
||||
evaluate(modulePolicy.path, summary[key], modulePolicy.minimum);
|
||||
}
|
||||
|
||||
const artifact = {
|
||||
schemaVersion: 1,
|
||||
policy: policyPath,
|
||||
summary: summaryPath,
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
results,
|
||||
failures,
|
||||
};
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Risk coverage failed:\n- ${failures.join("\n- ")}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Risk coverage: PASS (${results.length} scoped thresholds)\n`,
|
||||
);
|
||||
@@ -0,0 +1,167 @@
|
||||
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,321 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const COMPATIBILITY_IMPACTS = Object.freeze([
|
||||
"none",
|
||||
"additive",
|
||||
"behavior-change",
|
||||
"breaking",
|
||||
]);
|
||||
|
||||
const impactRank = new Map(
|
||||
COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]),
|
||||
);
|
||||
|
||||
/** @param {unknown} value @returns {unknown} */
|
||||
export function canonicalizeRegistryValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const projected =
|
||||
/** @type {unknown[]} */ (value.map(canonicalizeRegistryValue));
|
||||
return projected.every(
|
||||
(item) =>
|
||||
item === null ||
|
||||
["string", "number", "boolean"].includes(typeof item),
|
||||
)
|
||||
? projected.sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
)
|
||||
: projected;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeRegistryValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value @returns {string} */
|
||||
export function canonicalRegistryJson(value) {
|
||||
return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined";
|
||||
}
|
||||
|
||||
/** @param {unknown} snapshot */
|
||||
export function registrySnapshotDigest(snapshot) {
|
||||
return createHash("sha256")
|
||||
.update(canonicalRegistryJson(snapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string} current @param {string} candidate */
|
||||
function strongestImpact(current, candidate) {
|
||||
return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0)
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function valueType(value) {
|
||||
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) {
|
||||
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],
|
||||
),
|
||||
));
|
||||
const registryIds = new Set([
|
||||
...beforeRegistries.keys(),
|
||||
...afterRegistries.keys(),
|
||||
]);
|
||||
|
||||
for (const registryId of [...registryIds].sort()) {
|
||||
const previous = beforeRegistries.get(registryId);
|
||||
const current = afterRegistries.get(registryId);
|
||||
if (!previous || !current) {
|
||||
const changeImpact = previous ? "breaking" : "additive";
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(registryId, "*", "*", previous ? "removed" : "added"),
|
||||
registryId,
|
||||
rowName: "*",
|
||||
field: "*",
|
||||
kind: previous ? "registry-removed" : "registry-added",
|
||||
impact: changeImpact,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousContract =
|
||||
/** @type {Record<string, unknown>} */ (previous.contract ?? {});
|
||||
const currentContract =
|
||||
/** @type {Record<string, unknown>} */ (current.contract ?? {});
|
||||
const contractFields = new Set([
|
||||
...Object.keys(previousContract),
|
||||
...Object.keys(currentContract),
|
||||
]);
|
||||
for (const field of [...contractFields].sort()) {
|
||||
const beforeHas = Object.hasOwn(previousContract, field);
|
||||
const afterHas = Object.hasOwn(currentContract, field);
|
||||
const beforeValue = previousContract[field];
|
||||
const afterValue = currentContract[field];
|
||||
if (
|
||||
beforeHas &&
|
||||
afterHas &&
|
||||
canonicalRegistryJson(beforeValue) ===
|
||||
canonicalRegistryJson(afterValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const kind = !beforeHas
|
||||
? "contract-field-added"
|
||||
: !afterHas
|
||||
? "contract-field-removed"
|
||||
: "contract-field-changed";
|
||||
impact = strongestImpact(impact, "breaking");
|
||||
changes.push({
|
||||
changeId: changeId(registryId, "$contract", field, kind),
|
||||
registryId,
|
||||
rowName: "$contract",
|
||||
field,
|
||||
kind,
|
||||
impact: "breaking",
|
||||
before: canonicalizeRegistryValue(beforeValue),
|
||||
after: canonicalizeRegistryValue(afterValue),
|
||||
});
|
||||
}
|
||||
|
||||
const breakingFields = new Set(
|
||||
/** @type {string[]} */ (
|
||||
currentContract.breakingFields ?? []
|
||||
),
|
||||
);
|
||||
const beforeRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (
|
||||
previous.rows ?? {}
|
||||
);
|
||||
const afterRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (current.rows ?? {});
|
||||
const rowNames = new Set([
|
||||
...Object.keys(beforeRows),
|
||||
...Object.keys(afterRows),
|
||||
]);
|
||||
for (const rowName of [...rowNames].sort()) {
|
||||
const beforeRow = beforeRows[rowName];
|
||||
const afterRow = afterRows[rowName];
|
||||
if (!beforeRow || !afterRow) {
|
||||
const changeImpact = beforeRow ? "breaking" : "additive";
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(
|
||||
registryId,
|
||||
rowName,
|
||||
"*",
|
||||
beforeRow ? "removed" : "added",
|
||||
),
|
||||
registryId,
|
||||
rowName,
|
||||
field: "*",
|
||||
kind: beforeRow ? "row-removed" : "row-added",
|
||||
impact: changeImpact,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = new Set([
|
||||
...Object.keys(beforeRow),
|
||||
...Object.keys(afterRow),
|
||||
]);
|
||||
for (const field of [...fields].sort()) {
|
||||
const beforeHas = Object.hasOwn(beforeRow, field);
|
||||
const afterHas = Object.hasOwn(afterRow, field);
|
||||
const beforeValue = beforeRow[field];
|
||||
const afterValue = afterRow[field];
|
||||
if (
|
||||
beforeHas &&
|
||||
afterHas &&
|
||||
canonicalRegistryJson(beforeValue) ===
|
||||
canonicalRegistryJson(afterValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let kind;
|
||||
let changeImpact;
|
||||
if (!beforeHas) {
|
||||
kind = "field-added";
|
||||
changeImpact = "additive";
|
||||
} else if (!afterHas) {
|
||||
kind = "field-removed";
|
||||
changeImpact = "breaking";
|
||||
} else if (valueType(beforeValue) !== valueType(afterValue)) {
|
||||
kind = "field-type-changed";
|
||||
changeImpact = "breaking";
|
||||
} else if (
|
||||
Array.isArray(beforeValue) &&
|
||||
Array.isArray(afterValue) &&
|
||||
beforeValue.some(
|
||||
(item) =>
|
||||
!afterValue.some(
|
||||
(candidate) =>
|
||||
canonicalRegistryJson(candidate) ===
|
||||
canonicalRegistryJson(item),
|
||||
),
|
||||
)
|
||||
) {
|
||||
kind = "allowed-value-removed";
|
||||
changeImpact = "breaking";
|
||||
} else {
|
||||
kind = "field-changed";
|
||||
changeImpact = breakingFields.has(field)
|
||||
? "breaking"
|
||||
: "behavior-change";
|
||||
}
|
||||
impact = strongestImpact(impact, changeImpact);
|
||||
changes.push({
|
||||
changeId: changeId(registryId, rowName, field, kind),
|
||||
registryId,
|
||||
rowName,
|
||||
field,
|
||||
kind,
|
||||
impact: changeImpact,
|
||||
before: canonicalizeRegistryValue(beforeValue),
|
||||
after: canonicalizeRegistryValue(afterValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
impact,
|
||||
changes: Object.freeze(changes),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} snapshot
|
||||
* @param {Readonly<Record<string, unknown>>} approval
|
||||
*/
|
||||
export function verifyRegistryBaselineApproval(snapshot, approval) {
|
||||
const actualDigest = registrySnapshotDigest(snapshot);
|
||||
const approvedDigest = approval.snapshotDigest;
|
||||
return Object.freeze({
|
||||
passed:
|
||||
approval.schemaVersion === 1 &&
|
||||
typeof approval.owner === "string" &&
|
||||
approval.owner.length > 0 &&
|
||||
typeof approval.approvedAt === "string" &&
|
||||
approvedDigest === actualDigest,
|
||||
actualDigest,
|
||||
approvedDigest:
|
||||
typeof approvedDigest === "string" ? approvedDigest : "missing",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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]),
|
||||
);
|
||||
const failures = [];
|
||||
for (const change of diff.changes.filter(
|
||||
(entry) => entry.impact === "breaking",
|
||||
)) {
|
||||
const entry = evidence.get(change.changeId);
|
||||
if (!entry) {
|
||||
failures.push(`breaking change missing evidence: ${change.changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of [
|
||||
"versionBump",
|
||||
"migration",
|
||||
"compatibilityWindow",
|
||||
"rollback",
|
||||
"owner",
|
||||
]) {
|
||||
if (typeof entry[field] !== "string" || entry[field].trim().length === 0) {
|
||||
failures.push(
|
||||
`breaking change ${change.changeId} missing non-empty ${field}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
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>>} */ ({
|
||||
".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) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
||||
const decoded = decodeURIComponent(url.pathname);
|
||||
const requested = path.resolve(root, `.${decoded}`);
|
||||
if (requested !== root && !requested.startsWith(`${root}${path.sep}`)) {
|
||||
response.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
const details = await stat(requested).catch(() => null);
|
||||
const file = details?.isDirectory()
|
||||
? path.join(requested, "index.html")
|
||||
: requested;
|
||||
await access(file);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
contentTypes[path.extname(file)] ?? "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
createReadStream(file).pipe(response);
|
||||
} catch {
|
||||
response.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
process.stdout.write(`Static evidence server: ${root} on ${port}\n`);
|
||||
});
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => server.close(() => process.exit(0)));
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const featureOwnedPaths = [
|
||||
featureSource,
|
||||
featureTests,
|
||||
"tests/e2e/reference-form.spec.js",
|
||||
"tests/mocks",
|
||||
];
|
||||
const copyTargets = [
|
||||
"src",
|
||||
@@ -41,6 +42,7 @@ const copyTargets = [
|
||||
|
||||
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";
|
||||
|
||||
export const INSTALLED_FEATURE_CONTRACTS =
|
||||
/** @type {readonly unknown[]} */ (Object.freeze([]));
|
||||
@@ -48,6 +50,7 @@ export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
||||
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
||||
export const API_OPERATIONS = Object.freeze({});
|
||||
export const QUERY_REGISTRY = Object.freeze({});
|
||||
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
||||
export const NAVIGATION_ROUTES = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY)
|
||||
.filter((definition) => definition.navigationOrder !== null)
|
||||
@@ -145,6 +148,39 @@ await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-messages.js"),
|
||||
emptyMessages,
|
||||
);
|
||||
const governanceFile = path.join(
|
||||
fixtureRoot,
|
||||
"config/contracts/registry-governance.json",
|
||||
);
|
||||
const removalGovernance = JSON.parse(await readFile(governanceFile, "utf8"));
|
||||
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"),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(Array.isArray(registry.consumerDirectories)
|
||||
? {
|
||||
consumerDirectories: registry.consumerDirectories.filter(
|
||||
/** @param {string} directory */
|
||||
(directory) =>
|
||||
!directory.includes("features/reference-feature"),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
governanceFile,
|
||||
`${JSON.stringify(removalGovernance, null, 2)}\n`,
|
||||
);
|
||||
|
||||
/** @type {string[]} */
|
||||
const residue = [];
|
||||
@@ -165,7 +201,7 @@ for (const root of ["src", "tests"]) {
|
||||
const checks = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["registry", runPnpm("check:registries")],
|
||||
["registry-structure", runPnpm("check:registries:structure")],
|
||||
["unit-integration", runPnpm("test:all")],
|
||||
[
|
||||
"home-smoke",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { registrySnapshotDigest } from "./lib/registry-compatibility.mjs";
|
||||
|
||||
const inputPath =
|
||||
process.argv[2] ?? "artifacts/quality/registry-current-snapshot.json";
|
||||
const outputPath =
|
||||
process.argv[3] ?? "config/contracts/registry-baseline.json";
|
||||
const owner = process.env.REGISTRY_BASELINE_OWNER;
|
||||
const reason = process.env.REGISTRY_BASELINE_REASON;
|
||||
|
||||
if (!owner || !reason) {
|
||||
process.stderr.write(
|
||||
"REGISTRY_BASELINE_OWNER and REGISTRY_BASELINE_REASON are required.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
||||
const snapshot = input.registries
|
||||
? { schemaVersion: 2, registries: input.registries }
|
||||
: input;
|
||||
const digest = registrySnapshotDigest(snapshot);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
||||
await writeFile(
|
||||
"config/contracts/registry-baseline.approval.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
snapshotDigest: digest,
|
||||
owner,
|
||||
reason,
|
||||
approvedAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
process.stdout.write(`Registry baseline updated: ${digest}\n`);
|
||||
Reference in New Issue
Block a user