diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json new file mode 100644 index 0000000..a7a0c8c --- /dev/null +++ b/config/contracts/registry-governance.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "registries": [ + { + "registryId": "FE-REG-ROUTE", + "path": "src/contracts/routes.js", + "exportName": "ROUTE_REGISTRY", + "owner": "feature-routing-navigation-guard-contract", + "requiredFields": [ + "routeId", + "path", + "paramsSchema", + "searchSchema", + "access", + "loadingSurface", + "errorSurface", + "chunkId" + ] + }, + { + "registryId": "FE-REG-API", + "path": "src/contracts/api-operations.js", + "exportName": "API_OPERATIONS", + "owner": "feature-api-client-response-envelope-contract", + "requiredFields": [ + "method", + "path", + "operationId", + "auth", + "timeoutMs", + "idempotency", + "requestSchema", + "responseSchema", + "owner" + ] + }, + { + "registryId": "FE-REG-ENV", + "path": "src/contracts/env.js", + "exportName": "ENV_REGISTRY", + "owner": "feature-frontend-env-runtime-config-contract", + "requiredFields": ["phase", "classification", "required", "defaultValue"] + }, + { + "registryId": "FE-REG-STORAGE", + "path": "src/contracts/storage-keys.js", + "exportName": "STORAGE_REGISTRY", + "owner": "feature-frontend-storage-registry-contract", + "requiredFields": [ + "logicalName", + "physicalKey", + "backend", + "classification", + "schemaVersion", + "ttl", + "migration", + "quotaFallback" + ] + }, + { + "registryId": "FE-REG-ERROR", + "path": "src/contracts/errors.js", + "exportName": "ERROR_REGISTRY", + "owner": "feature-frontend-error-classification-boundary-contract", + "requiredFields": [ + "kind", + "defaultRetryable", + "severity", + "userMessageKey", + "action", + "telemetryEvent", + "redaction" + ] + }, + { + "registryId": "FE-REG-QUERY", + "path": "src/contracts/query-keys.js", + "exportName": "QUERY_REGISTRY", + "owner": "feature-server-state-caching-contract", + "requiredFields": [ + "namespace", + "serialization", + "identity", + "invalidation", + "version", + "persistence" + ] + }, + { + "registryId": "FE-REG-TELEMETRY", + "path": "src/contracts/telemetry.js", + "exportName": "TELEMETRY_REGISTRY", + "owner": "feature-frontend-observability-logging-trace-contract", + "requiredFields": [ + "eventName", + "trigger", + "requiredAttributes", + "optionalAttributes", + "forbiddenAttributes", + "sampling", + "delivery" + ] + }, + { + "registryId": "FE-REG-RELEASE", + "path": "src/contracts/release-tokens.js", + "exportName": "RELEASE_TOKEN_REGISTRY", + "owner": "feature-frontend-release-cache-rollback-contract", + "requiredFields": ["token", "source", "compatibilityRole"], + "declaredRows": { + "appVersion": { + "token": "appVersion", + "source": "manifest", + "compatibilityRole": "human release label" + }, + "buildId": { + "token": "buildId", + "source": "CI build", + "compatibilityRole": "asset and HTML coherence" + }, + "commitSha": { + "token": "commitSha", + "source": "VCS", + "compatibilityRole": "source traceability" + }, + "configSchemaVersion": { + "token": "configSchemaVersion", + "source": "runtime config schema", + "compatibilityRole": "boot compatibility" + }, + "apiContractVersion": { + "token": "apiContractVersion", + "source": "frontend/backend agreement", + "compatibilityRole": "schema compatibility" + }, + "assetManifestHash": { + "token": "assetManifestHash", + "source": "build output", + "compatibilityRole": "chunk integrity" + }, + "releaseId": { + "token": "releaseId", + "source": "deploy system", + "compatibilityRole": "rollback target" + }, + "builtAt": { + "token": "builtAt", + "source": "CI", + "compatibilityRole": "diagnostics only" + } + } + } + ], + "compatibilityImpact": { + "allowed": ["none", "additive", "behavior-change", "breaking"], + "current": "additive" + } +} diff --git a/package.json b/package.json index f27c05c..8aecda8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "verify:lockfile": "corepack pnpm install --frozen-lockfile", "generate:supply-chain": "node scripts/generate-supply-chain.mjs", "scan:security": "node scripts/security-scan.mjs", - "check:browser-security": "node scripts/check-browser-security.mjs" + "check:browser-security": "node scripts/check-browser-security.mjs", + "check:registries": "node scripts/check-registries.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json new file mode 100644 index 0000000..8a948f8 --- /dev/null +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "compatibilityImpact", + "failures", + "registries" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "compatibilityImpact": { + "enum": ["none", "additive", "behavior-change", "breaking"] + }, + "failures": { "type": "array", "maxItems": 0 }, + "registries": { + "type": "array", + "minItems": 8, + "maxItems": 8, + "items": { + "type": "object", + "required": ["registryId", "owner", "source", "rowCount", "rows"] + } + } + }, + "additionalProperties": false +} diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs new file mode 100644 index 0000000..d32e03c --- /dev/null +++ b/scripts/check-registries.mjs @@ -0,0 +1,112 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const governance = JSON.parse( + await readFile("config/contracts/registry-governance.json", "utf8"), +); +const failures = []; +const owners = new Map(); +const snapshots = []; + +for (const specification of governance.registries) { + if (owners.has(specification.registryId)) { + failures.push(`duplicate owner for ${specification.registryId}`); + } + owners.set(specification.registryId, specification.owner); + + let rows = specification.declaredRows; + try { + await access(specification.path); + const module = await import( + `${pathToFileURL(path.resolve(specification.path)).href}?registry-check=${Date.now()}` + ); + rows = module[specification.exportName]; + } catch { + if (!rows) failures.push(`missing registry source ${specification.path}`); + } + + if (!rows || typeof rows !== "object" || Array.isArray(rows)) { + failures.push(`${specification.registryId} is not an object registry`); + continue; + } + + for (const [rowName, row] of Object.entries(rows)) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + failures.push(`${specification.registryId}.${rowName} is not an object`); + continue; + } + for (const field of specification.requiredFields) { + if (!(field in row)) { + failures.push(`${specification.registryId}.${rowName} missing ${field}`); + } + } + } + + snapshots.push({ + registryId: specification.registryId, + owner: specification.owner, + source: specification.path, + rowCount: Object.keys(rows).length, + rows, + }); +} + +const sourceFiles = [ + "src/application", + "src/presentation", + "src/domain", +]; +const adHocPatterns = [ + { name: "direct fetch", expression: /\bfetch\s*\(/ }, + { 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) { + 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)$/.test(entry.name)) continue; + const content = await readFile(target, "utf8"); + for (const pattern of adHocPatterns) { + if (pattern.expression.test(content)) { + failures.push(`ad-hoc ${pattern.name} in ${target}`); + } + } + } +} + +for (const sourceDirectory of sourceFiles) { + await scanDirectory(sourceDirectory); +} + +await mkdir("artifacts/quality", { recursive: true }); +await writeFile( + "artifacts/quality/registries.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + compatibilityImpact: governance.compatibilityImpact.current, + failures, + registries: snapshots, + }, + 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`); diff --git a/tests/unit/registry-governance.test.js b/tests/unit/registry-governance.test.js new file mode 100644 index 0000000..ad4688f --- /dev/null +++ b/tests/unit/registry-governance.test.js @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +describe("registry governance manifest", () => { + it("declares exactly eight single-owner registries and impact labels", async () => { + const governance = JSON.parse( + await readFile("config/contracts/registry-governance.json", "utf8"), + ); + expect(governance.registries).toHaveLength(8); + expect(new Set(governance.registries.map((entry) => entry.registryId)).size).toBe( + 8, + ); + expect(governance.registries.every((entry) => entry.owner)).toBe(true); + expect(governance.compatibilityImpact.allowed).toEqual([ + "none", + "additive", + "behavior-change", + "breaking", + ]); + }); +});