diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json index 55ee8b7..ea20ba8 100644 --- a/.dependency-cruiser.json +++ b/.dependency-cruiser.json @@ -156,6 +156,41 @@ "path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)" } }, + { + "name": "contracts-do-not-know-application", + "comment": "§4. `src/contracts` is the lower of the two packages: application reads contracts, never the other way round. Before this rule the shared Result carrier and the compatibility predicate lived in application and were imported back down by contracts, so neither package owned the shared vocabulary and the coupling was invisible to every gate.", + "severity": "error", + "from": { + "path": "^src/contracts" + }, + "to": { + "path": "^src/(application|features)" + } + }, + { + "name": "generic-presentation-does-not-compose-the-product", + "comment": "§4 / §9. Which features are installed is a product decision that belongs to bootstrap. Generic presentation reads the installed registries directly today; the paths below are the exact set that does so, frozen so the coupling cannot spread while the assembly is lifted into bootstrap.", + "severity": "error", + "from": { + "path": "^src/presentation/", + "pathNot": "^src/presentation/(layouts/app-shell\\.tsx|pages/(not-found-page|home-page)\\.tsx|routes/(route-contract|route-codecs|app-router|navigation-policy)\\.(ts|tsx)|i18n/catalog\\.ts|examples/platform-overview-page\\.tsx)$" + }, + "to": { + "path": "^src/features/installed-" + } + }, + { + "name": "adapters-do-not-know-other-concrete-adapters", + "comment": "docs/architecture/layers.md §4: a concrete adapter never depends on another concrete adapter. Only the adapter kernel is shared — `src/adapters/platform` (clock, abort primitive, capacity guard) and the browser-data result helpers. `query-cache` still reads two collaborator types from `cross-context-invalidation`; that edge is named here rather than left silent, and closes when those types are lifted to a port.", + "severity": "error", + "from": { + "path": "^src/adapters/([^/]+)/" + }, + "to": { + "path": "^src/adapters/([^/]+)/", + "pathNot": "^src/adapters/($1/|platform/|browser-file-storage/result\\.ts$|cross-context-invalidation/index\\.ts$)" + } + }, { "name": "no-circular-dependencies", "severity": "error", diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index 25e012d..a718a9e 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -126,6 +126,9 @@ jobs: outputs: dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }} archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }} + env: + APP_PROFILE: "${{ vars.APP_PROFILE }}" + RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}" steps: - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: diff --git a/README.md b/README.md index a4127ce..24a106b 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,12 @@ corepack pnpm exec playwright install --with-deps chromium firefox webkit Two gates intentionally need external evidence: - `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review - for all six registered routes. + for all ten registered routes: `APP_HOME`, `EXAMPLES_PLATFORM`, + `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, `NOT_FOUND`, + `REFERENCE_RESOURCE_LIST`, `REFERENCE_RESOURCE_DETAIL`, + `REFERENCE_RESOURCE_FORM` and `REFERENCE_RESOURCE_STATUS`. + `verify:documentation` derives that list from the route registry and fails if + this paragraph falls behind it. - `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum eligible-sample threshold and 28 days of production data exist. diff --git a/config/ci/gates.json b/config/ci/gates.json index 1b36ea9..b549802 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -2275,10 +2275,20 @@ "condition": "release", "timeoutMinutes": 45, "gateIds": [ - "FE-GATE-015" + "FE-GATE-015", + "FE-GATE-027" ], "browserGateIds": [], - "environment": [], + "environment": [ + { + "name": "APP_PROFILE", + "value": "${{ vars.APP_PROFILE }}" + }, + { + "name": "RELEASE_TARGET", + "value": "${{ vars.RELEASE_TARGET }}" + } + ], "steps": [ { "kind": "checkout" diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md index 6bfac32..4aaa7aa 100644 --- a/docs/accessibility/manual-checklist.md +++ b/docs/accessibility/manual-checklist.md @@ -1,12 +1,20 @@ # Manual accessibility review checklist Automated axe checks do not establish WCAG conformance. A human reviewer must -review all six route records in `artifacts/tests/a11y-manual/` against one +review all ten route records in `artifacts/tests/a11y-manual/` against one release candidate and sign them. The required scope is derived from the route -registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`, -`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the -gate rejects blank identity/timestamp/signature fields, pending verdicts, -mismatched release IDs, or missing routes. +registry: `APP_HOME`, `EXAMPLES_PLATFORM`, `EXAMPLES_UI`, `EXAMPLES_STATES`, +`EXAMPLES_AUTH`, `NOT_FOUND`, `REFERENCE_RESOURCE_LIST`, +`REFERENCE_RESOURCE_DETAIL`, `REFERENCE_RESOURCE_FORM` and +`REFERENCE_RESOURCE_STATUS`. Copy the template fields exactly; the gate rejects +blank identity/timestamp/signature fields, pending verdicts, mismatched release +IDs, or missing routes. + +This list is not maintained by hand: `verify:documentation` compares it against +the installed route registry and fails when a registered route is absent. It +said six routes while ten were registered, which put the platform overview and +the three reference-resource screens outside the declared manual review scope +without anyone deciding they should be. Allowed item verdicts: diff --git a/docs/architecture/layers.md b/docs/architecture/layers.md index 45d1c35..56a2c3f 100644 --- a/docs/architecture/layers.md +++ b/docs/architecture/layers.md @@ -17,11 +17,33 @@ The following edges are forbidden: - domain to application, presentation, adapters, bootstrap, React, or browser globals - application to presentation, concrete adapters, bootstrap, React, or browser globals +- `contracts` to application or features: contracts is the lower package and + owns the shared vocabulary both of them read - presentation to concrete adapters, raw DTO schemas, or storage implementations +- generic presentation to the installed-feature registries: which features exist + is a product decision owned by `bootstrap` - an adapter to presentation, bootstrap internals, or another concrete adapter - feature domain/application to its presentation or outbound adapter, and feature presentation to its outbound adapter +## The adapter kernel + +"Another concrete adapter" excludes the adapter kernel, which is shared on +purpose and is the only adapter code an adapter may reach across a group for: + +- `src/adapters/platform/**` — the system clock, the shared abort primitive and + the bounded-capacity guard +- `src/adapters/browser-file-storage/result.ts` — the browser-data result and + failure constructors + +Each rule above is enforced by `check:architecture`, including the kernel +carve-out, so this table and the executable rules cannot drift apart. Two edges +are still open and are named explicitly in `.dependency-cruiser.json` rather +than left silent: the generic presentation modules that read the installed +registries today, and the two collaborator types `query-cache` reads from +`cross-context-invalidation`. Both lists are frozen — a new edge of either kind +fails the gate. + `bootstrap` contains composition only. Business rules and page-specific orchestration belong to domain/application. diff --git a/docs/architecture/review-ledger.json b/docs/architecture/review-ledger.json index d026be8..d8d1e23 100644 --- a/docs/architecture/review-ledger.json +++ b/docs/architecture/review-ledger.json @@ -5,7 +5,7 @@ "standard": "rules/diagram-standards.md v2", "evidenceReport": { "repoPath": "docs/architecture/review-evidence.md", - "canonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", + "upstreamCanonicalPath": "docs/superpowers/specs/2026-07-18-ca-skeleton-frontend-operational-contract-review/diagram-review.md", "canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29" }, "reviews": { @@ -25,5 +25,6 @@ "thresholdSatisfied": true, "scope": "immutable static assets and mutable /config.json delivery" } - } + }, + "note": "`repoPath` is this repository's copy and must resolve. `upstreamCanonicalPath` and every `reviews[*].sourcePath` name the reviewing workspace, not this tree; they are provenance labels and are deliberately not resolvable here. `canonicalSha256` is what binds the two, and the gate checks it appears in `repoPath`." } diff --git a/scripts/check-architecture.ts b/scripts/check-architecture.ts index b246e0b..6b2957a 100644 --- a/scripts/check-architecture.ts +++ b/scripts/check-architecture.ts @@ -723,9 +723,13 @@ function findArchitectureViolations( continue; } for (const dependency of dependencies) { + const sourceGroups = rule.from?.path + ? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ?? + []) + : []; if ( matchesPath(dependency.source, rule.from) && - matchesPath(dependency.target, rule.to) + matchesPath(dependency.target, rule.to, sourceGroups) ) { violations.push({ rule: rule.name, @@ -746,16 +750,52 @@ function findArchitectureViolations( function matchesPath( modulePath: string, criterion: PathRule | undefined, + sourceGroups: readonly string[] = [], ): boolean { if (!criterion) return true; - if (criterion.path && !new RegExp(criterion.path, "u").test(modulePath)) { + if ( + criterion.path && + !new RegExp(expandSourceGroups(criterion.path, sourceGroups), "u").test( + modulePath, + ) + ) { return false; } return !( - criterion.pathNot && new RegExp(criterion.pathNot, "u").test(modulePath) + criterion.pathNot && + new RegExp(expandSourceGroups(criterion.pathNot, sourceGroups), "u").test( + modulePath, + ) ); } +/** + * Substitutes `$1`..`$9` in a `to` pattern with the capture groups the `from` + * pattern matched on the importing module. + * + * Without it, "an adapter may not import a *different* adapter" cannot be + * written as one rule: the target pattern has to name the importer's own + * directory to exempt it. The alternative is one rule per adapter group, which + * silently stops covering a group the moment somebody adds one — exactly the + * gap that let `diagnostics` import `telemetry` while the documented rule said + * it could not. + */ +function expandSourceGroups( + pattern: string, + sourceGroups: readonly string[], +): string { + return pattern.replaceAll(/\$([1-9])/gu, (whole, index: string) => { + const captured = sourceGroups[Number(index) - 1]; + // A `from` pattern that did not capture leaves the token literal rather + // than quietly matching everything. + return captured === undefined ? whole : escapeRegExp(captured); + }); +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); +} + function validateArchitectureRules(rules: readonly ArchitectureRule[]): void { if (!rules.some((rule) => rule.to?.circular === true)) { throw new Error("Architecture configuration must contain a circular rule"); diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index 4e0b338..2376827 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat } const expectedGateIds = Array.from( - { length: 26 }, + { length: 27 }, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, ); const passingResults: Record = Object.fromEntries( @@ -139,4 +139,4 @@ if (failures.length > 0) { process.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`); process.exit(1); } -process.stdout.write("CI contract: 26 gates, strict v2 graph and generated workflow model PASS\n"); +process.stdout.write("CI contract: 27 gates, strict v2 graph and generated workflow model PASS\n"); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 37be0d0..fb37a52 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{ }>; const CANONICAL_GATE_SHAPE_SHA256 = - "a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4"; + "4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -474,16 +474,16 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] (total, gate) => total + gate.commandIds.length, 0, ); - if (contract.gates.length !== 26) { - failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`); + if (contract.gates.length !== 27) { + failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`); } - if (contract.commands.length !== 81 || commandReferenceCount !== 93) { + if (contract.commands.length !== 82 || commandReferenceCount !== 94) { failures.push( - `command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, + `command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, ); } - if (contract.artifacts.length !== 105) { - failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`); + if (contract.artifacts.length !== 107) { + failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); @@ -760,11 +760,11 @@ function validateContractSemantics( } const expectedGateIds = Array.from( - { length: 26 }, + { length: 27 }, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, ); if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) { - issue("gate registry must contain FE-GATE-001..026 in canonical order"); + issue("gate registry must contain FE-GATE-001..027 in canonical order"); } const expectedStages: ReadonlyArray = [ ["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY], @@ -835,7 +835,7 @@ function validateContractSemantics( const expectedJobOwnership: Readonly> = { merge_gate: ["FE-GATE-001", "FE-GATE-002", "FE-GATE-003", "FE-GATE-004", "FE-GATE-005", "FE-GATE-006", "FE-GATE-007", "FE-GATE-008", "FE-GATE-009", "FE-GATE-010", "FE-GATE-011", "FE-GATE-013", "FE-GATE-020"], release_gate: ["FE-GATE-012", "FE-GATE-014", "FE-GATE-019", "FE-GATE-026"], - immutable_build: ["FE-GATE-015"], + immutable_build: ["FE-GATE-015", "FE-GATE-027"], vulnerability_provider: [], provenance_provider: [], promotion: [], @@ -889,7 +889,13 @@ function validateContractSemantics( const expectedEnvironmentBindings: Readonly []>> = { merge_gate: [], release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }], - immutable_build: [], + immutable_build: [ + // FE-GATE-027 admits the built artifact to a named environment, so both + // the profile it was built from and the destination it is claimed for are + // declared inputs. An absent RELEASE_TARGET is a refusal, not a default. + { name: "APP_PROFILE", value: "${{ vars.APP_PROFILE }}" }, + { name: "RELEASE_TARGET", value: "${{ vars.RELEASE_TARGET }}" }, + ], vulnerability_provider: [ { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index c0e5ae9..f459f99 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -1342,9 +1342,28 @@ export const documentationReviewArtifactSchema = z reviewer: z.literal("wiki-diagram-reviewer"), standard: z.literal("rules/diagram-standards.md v2"), evidenceReport: z - .object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 }) + .object({ + repoPath: nonEmptyString, + upstreamCanonicalPath: nonEmptyString, + canonicalSha256: sha256, + }) .strict(), reportDigestValid: z.boolean(), + /** + * The declared review scope, derived from the installed route registry + * rather than read off a sentence. Both scope documents claimed six routes + * while ten were registered. + */ + routeScope: z.array( + z + .object({ + path: nonEmptyString, + missingRouteIds: z.array(nonEmptyString), + documented: z.boolean(), + }) + .strict(), + ).min(1), + routeScopeDocumented: z.boolean(), results: z.array( z .object({ @@ -1371,8 +1390,21 @@ export const documentationReviewArtifactSchema = z context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" }); } }); - if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) { - context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" }); + artifact.routeScope.forEach((entry, index) => { + if (entry.documented !== (entry.missingRouteIds.length === 0)) { + context.addIssue({ code: "custom", path: ["routeScope", index, "documented"], message: "must agree with the missing route list" }); + } + }); + if (artifact.routeScopeDocumented !== artifact.routeScope.every(({ documented }) => documented)) { + context.addIssue({ code: "custom", path: ["routeScopeDocumented"], message: "must agree with every scope document" }); + } + if ( + artifact.passed !== + (artifact.reportDigestValid && + artifact.routeScopeDocumented && + artifact.results.every(({ passed }) => passed)) + ) { + context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest, documented scope and review results" }); } }); diff --git a/scripts/lib/ci-contract-report.ts b/scripts/lib/ci-contract-report.ts index 74490a9..3aaf1d6 100644 --- a/scripts/lib/ci-contract-report.ts +++ b/scripts/lib/ci-contract-report.ts @@ -4,7 +4,7 @@ export const ciContractReportSchema = z .object({ schemaVersion: z.literal(2), nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u), - gateCount: z.literal(26), + gateCount: z.literal(27), commandDefinitionCount: z.number().int().positive(), commandReferenceCount: z.number().int().positive(), artifactCount: z.number().int().positive(), diff --git a/scripts/verify-documentation-readiness.ts b/scripts/verify-documentation-readiness.ts index f0c0f31..3dc7275 100644 --- a/scripts/verify-documentation-readiness.ts +++ b/scripts/verify-documentation-readiness.ts @@ -1,8 +1,24 @@ -import { mkdir, readFile } from "node:fs/promises"; +import { access, mkdir, readFile } from "node:fs/promises"; +import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts"; import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +/** + * Documents that state the review scope. The route registry is the source of + * truth for what that scope is, so these have to enumerate exactly the + * installed routes. + * + * Both said "six routes" while ten were registered: the four newest — the + * platform overview and three reference-resource screens — were outside the + * declared manual accessibility scope without anybody deciding they should be. + * A hand-typed count drifts silently, so it is derived here instead. + */ +const ROUTE_SCOPE_DOCUMENTS = Object.freeze([ + "README.md", + "docs/accessibility/manual-checklist.md", +]); + type DocumentationReview = Readonly<{ sourcePath: string; sha256: string; @@ -14,6 +30,7 @@ type DocumentationReview = Readonly<{ type ReviewLedger = Readonly<{ evidenceReport: Readonly<{ repoPath: string; + upstreamCanonicalPath: string; canonicalSha256: string; }>; reviews: Record; @@ -58,8 +75,30 @@ for (const [diagram, review] of Object.entries(ledger.reviews)) { const reportDigestValid = /^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) && evidence.includes(ledger.evidenceReport.canonicalSha256); + +const installedRouteIds = Object.values(ROUTE_REGISTRY) + .map((route) => route.routeId) + .sort(); +const routeScope = []; +for (const path of ROUTE_SCOPE_DOCUMENTS) { + let text: string; + try { + await access(path); + text = await readFile(path, "utf8"); + } catch { + routeScope.push({ path, missingRouteIds: [...installedRouteIds], documented: false }); + continue; + } + const missingRouteIds = installedRouteIds.filter( + (routeId) => !text.includes(routeId), + ); + routeScope.push({ path, missingRouteIds, documented: missingRouteIds.length === 0 }); +} +const routeScopeDocumented = routeScope.every((entry) => entry.documented); + const passed = reportDigestValid && + routeScopeDocumented && results.length === 2 && results.every((result) => result.passed); await mkdir("artifacts/quality", { recursive: true }); @@ -74,6 +113,8 @@ await writeValidatedJsonArtifact({ standard: ledger.standard, evidenceReport: ledger.evidenceReport, reportDigestValid, + routeScope, + routeScopeDocumented, results, passed, }, diff --git a/src/adapters/diagnostics/bounded-diagnostics.ts b/src/adapters/diagnostics/bounded-diagnostics.ts index cec5cac..c4d47a4 100644 --- a/src/adapters/diagnostics/bounded-diagnostics.ts +++ b/src/adapters/diagnostics/bounded-diagnostics.ts @@ -6,7 +6,7 @@ import { type DiagnosticRecordInput, } from "../../contracts/diagnostics.ts"; import { projectTelemetryEvent } from "../../contracts/telemetry.ts"; -import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts"; +import { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; export const noOpDiagnostics: DiagnosticsPort = Object.freeze({ record() {}, diff --git a/src/adapters/platform/bounded-capacity.ts b/src/adapters/platform/bounded-capacity.ts new file mode 100644 index 0000000..8e4935b --- /dev/null +++ b/src/adapters/platform/bounded-capacity.ts @@ -0,0 +1,21 @@ +/** + * N-11. A non-finite or fractional capacity silently disables eviction, so it is + * a construction-time configuration error rather than a runtime drop. + * + * This lives in the adapter kernel rather than inside the telemetry adapter: + * the diagnostics adapter needs the same guard, and importing it from telemetry + * made one concrete adapter depend on another for a rule that belongs to + * neither of them. + */ +export function assertBoundedCapacity( + value: number, + ceiling: number, + label: string, +): number { + if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) { + throw new TypeError( + `${label} must be a safe integer between 1 and ${ceiling}`, + ); + } + return value; +} diff --git a/src/adapters/telemetry/best-effort-telemetry.ts b/src/adapters/telemetry/best-effort-telemetry.ts index 01e8b4a..3d69c77 100644 --- a/src/adapters/telemetry/best-effort-telemetry.ts +++ b/src/adapters/telemetry/best-effort-telemetry.ts @@ -5,6 +5,7 @@ import type { TelemetryEventName, } from "../../contracts/telemetry.ts"; import type { TelemetryPort } from "../../application/ports/telemetry-port.ts"; +import { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; export type TelemetryAdapter = TelemetryPort & Readonly<{ @@ -45,22 +46,8 @@ type TelemetryLifecycle = "ACTIVE" | "DISPOSED"; /** N-11. Documented absolute ceiling for the in-memory best-effort queue. */ export const MAX_TELEMETRY_QUEUE = 10_000; -/** - * N-11. A non-finite or fractional capacity silently disables eviction, so it is - * a construction-time configuration error rather than a runtime drop. - */ -export function assertBoundedCapacity( - value: number, - ceiling: number, - label: string, -): number { - if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) { - throw new TypeError( - `${label} must be a safe integer between 1 and ${ceiling}`, - ); - } - return value; -} +export { assertBoundedCapacity } from "../platform/bounded-capacity.ts"; + export function createTelemetryAdapter( options: TelemetryAdapterOptions, diff --git a/src/application/policies/compatibility.ts b/src/application/policies/compatibility.ts index 486a954..4bd1b68 100644 --- a/src/application/policies/compatibility.ts +++ b/src/application/policies/compatibility.ts @@ -1,107 +1,21 @@ -export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ - "buildId", - "configSchemaVersion", - "apiContractVersion", - "assetManifestHash", - "releaseId", -] as const); - -export type CompatibilityTupleField = - (typeof COMPATIBILITY_TUPLE_FIELDS)[number]; - -export type CompatibilityTuple = Readonly< - Record ->; - -export type NumericVersion = Readonly<{ - major: number; - minor: number; - patch: number; -}>; - -export function parseNumericVersion(version: string): NumericVersion | null { - const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); - if (!match) return null; - return { - major: Number(match[1]), - minor: Number(match[2] ?? 0), - patch: Number(match[3] ?? 0), - }; -} - -export function isVersionCompatible( - supported: string, - actual: string, -): boolean { - const expected = parseNumericVersion(supported); - const candidate = parseNumericVersion(actual); - if (!expected || !candidate) return false; - return ( - expected.major === candidate.major && - candidate.minor >= expected.minor - ); -} - -export function verifyCompatibilityTuple(input: Readonly<{ - frontend: CompatibilityTuple; - runtime: CompatibilityTuple; -}>) { - const mismatches: CompatibilityTupleField[] = []; - if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); - if ( - !isVersionCompatible( - input.frontend.configSchemaVersion, - input.runtime.configSchemaVersion, - ) - ) { - mismatches.push("configSchemaVersion"); - } - if ( - !isVersionCompatible( - input.frontend.apiContractVersion, - input.runtime.apiContractVersion, - ) - ) { - mismatches.push("apiContractVersion"); - } - if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { - mismatches.push("assetManifestHash"); - } - - const releaseWarning: "releaseId" | null = - input.frontend.releaseId === input.runtime.releaseId - ? null - : "releaseId"; - return Object.freeze({ - compatible: mismatches.length === 0, - mismatches: Object.freeze(mismatches), - warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), - }); -} - -export type ObjectSchemaShape = Readonly<{ - required?: readonly string[]; - properties?: Readonly>; -}>; - -export type SchemaChangeClassification = "breaking" | "additive" | "none"; - -export function classifyObjectSchemaChange( - before: ObjectSchemaShape, - after: ObjectSchemaShape, -): SchemaChangeClassification { - const beforeRequired = new Set(before.required ?? []); - const afterRequired = new Set(after.required ?? []); - const removedProperties = Object.keys(before.properties ?? {}).filter( - (key) => !(key in (after.properties ?? {})), - ); - const addedRequired = [...afterRequired].filter( - (key) => !beforeRequired.has(key), - ); - if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; - - const addedProperties = Object.keys(after.properties ?? {}).filter( - (key) => !(key in (before.properties ?? {})), - ); - return addedProperties.length > 0 ? "additive" : "none"; -} +/** + * Release compatibility comparison. + * + * The implementation lives in `src/contracts/compatibility.ts`: it is a pure + * predicate over release tokens with no application state, and + * `src/contracts/release-tokens.ts` needs it, which previously made contracts + * import the application layer. This module re-exports it for application-side + * and script-side callers. + */ +export { + COMPATIBILITY_TUPLE_FIELDS, + classifyObjectSchemaChange, + isVersionCompatible, + parseNumericVersion, + verifyCompatibilityTuple, + type CompatibilityTuple, + type CompatibilityTupleField, + type NumericVersion, + type ObjectSchemaShape, + type SchemaChangeClassification, +} from "../../contracts/compatibility.ts"; diff --git a/src/application/policies/promotion-readiness.ts b/src/application/policies/promotion-readiness.ts index 9dcaf52..2f4f90f 100644 --- a/src/application/policies/promotion-readiness.ts +++ b/src/application/policies/promotion-readiness.ts @@ -20,6 +20,9 @@ export const PROMOTION_FORMULA = Object.freeze({ "FE-GATE-015", "FE-GATE-019", "FE-GATE-026", + // FE-GATE-027. A candidate is only release-ready once it has been admitted + // to a named environment; coherence alone never proved it belonged there. + "FE-GATE-027", ]), PROD_PROMOTION_READY: Object.freeze([ "FE-GATE-016", diff --git a/src/application/result.ts b/src/application/result.ts index 9440c1c..561ef7a 100644 --- a/src/application/result.ts +++ b/src/application/result.ts @@ -1,10 +1,13 @@ -import type { AppFailure } from "../contracts/errors.ts"; - /** * The single success/failure carrier used across application input boundaries. * Adapters map technology-specific errors to an application failure before * constructing this value. + * + * The type itself lives in `src/contracts` because both layers need it and + * neither owns it: `src/contracts/server-state.ts` and + * `src/contracts/cursor-pagination.ts` reached back into the application layer + * for it, which made the ownership of the shared vocabulary ambiguous in both + * directions. Contracts is the lower of the two, so the shared shape sits there + * and this module re-exports it for every existing application-side importer. */ -export type Result = - | Readonly<{ ok: true; value: Value }> - | Readonly<{ ok: false; error: Failure }>; +export type { Result } from "../contracts/result.ts"; diff --git a/src/contracts/compatibility.ts b/src/contracts/compatibility.ts new file mode 100644 index 0000000..486a954 --- /dev/null +++ b/src/contracts/compatibility.ts @@ -0,0 +1,107 @@ +export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ + "buildId", + "configSchemaVersion", + "apiContractVersion", + "assetManifestHash", + "releaseId", +] as const); + +export type CompatibilityTupleField = + (typeof COMPATIBILITY_TUPLE_FIELDS)[number]; + +export type CompatibilityTuple = Readonly< + Record +>; + +export type NumericVersion = Readonly<{ + major: number; + minor: number; + patch: number; +}>; + +export function parseNumericVersion(version: string): NumericVersion | null { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + }; +} + +export function isVersionCompatible( + supported: string, + actual: string, +): boolean { + const expected = parseNumericVersion(supported); + const candidate = parseNumericVersion(actual); + if (!expected || !candidate) return false; + return ( + expected.major === candidate.major && + candidate.minor >= expected.minor + ); +} + +export function verifyCompatibilityTuple(input: Readonly<{ + frontend: CompatibilityTuple; + runtime: CompatibilityTuple; +}>) { + const mismatches: CompatibilityTupleField[] = []; + if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); + if ( + !isVersionCompatible( + input.frontend.configSchemaVersion, + input.runtime.configSchemaVersion, + ) + ) { + mismatches.push("configSchemaVersion"); + } + if ( + !isVersionCompatible( + input.frontend.apiContractVersion, + input.runtime.apiContractVersion, + ) + ) { + mismatches.push("apiContractVersion"); + } + if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { + mismatches.push("assetManifestHash"); + } + + const releaseWarning: "releaseId" | null = + input.frontend.releaseId === input.runtime.releaseId + ? null + : "releaseId"; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), + }); +} + +export type ObjectSchemaShape = Readonly<{ + required?: readonly string[]; + properties?: Readonly>; +}>; + +export type SchemaChangeClassification = "breaking" | "additive" | "none"; + +export function classifyObjectSchemaChange( + before: ObjectSchemaShape, + after: ObjectSchemaShape, +): SchemaChangeClassification { + const beforeRequired = new Set(before.required ?? []); + const afterRequired = new Set(after.required ?? []); + const removedProperties = Object.keys(before.properties ?? {}).filter( + (key) => !(key in (after.properties ?? {})), + ); + const addedRequired = [...afterRequired].filter( + (key) => !beforeRequired.has(key), + ); + if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; + + const addedProperties = Object.keys(after.properties ?? {}).filter( + (key) => !(key in (before.properties ?? {})), + ); + return addedProperties.length > 0 ? "additive" : "none"; +} diff --git a/src/contracts/cursor-pagination.ts b/src/contracts/cursor-pagination.ts index 3297314..6e9dccb 100644 --- a/src/contracts/cursor-pagination.ts +++ b/src/contracts/cursor-pagination.ts @@ -1,4 +1,4 @@ -import type { Result } from "../application/result.ts"; +import type { Result } from "./result.ts"; export type CursorPage = Readonly<{ items: readonly Value[]; diff --git a/src/contracts/release-tokens.ts b/src/contracts/release-tokens.ts index 437db64..1c4135e 100644 --- a/src/contracts/release-tokens.ts +++ b/src/contracts/release-tokens.ts @@ -1,4 +1,4 @@ -import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts"; +import { verifyCompatibilityTuple } from "./compatibility.ts"; export const RELEASE_TOKEN_REGISTRY = Object.freeze({ appVersion: token("appVersion", "manifest", "human release label"), diff --git a/src/contracts/result.ts b/src/contracts/result.ts new file mode 100644 index 0000000..7ac9195 --- /dev/null +++ b/src/contracts/result.ts @@ -0,0 +1,15 @@ +import type { AppFailure } from "./errors.ts"; + +/** + * The single success/failure carrier used across application input boundaries. + * Adapters map technology-specific errors to an application failure before + * constructing this value. + * + * It lives in `src/contracts` because it is shared vocabulary rather than + * application behaviour: contracts modules describe results too, and reaching + * up into `src/application` for the shape made the dependency between the two + * packages point both ways. `src/application/result.ts` re-exports it. + */ +export type Result = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: Failure }>; diff --git a/src/contracts/server-state.ts b/src/contracts/server-state.ts index 79de43f..f7476c6 100644 --- a/src/contracts/server-state.ts +++ b/src/contracts/server-state.ts @@ -1,4 +1,4 @@ -import type { Result } from "../application/result.ts"; +import type { Result } from "./result.ts"; import type { QueryInvalidationTopic } from "./query-invalidation.ts"; import { createBoundQueryKey, diff --git a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap index abe3fdc..3e54e75 100644 --- a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap +++ b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap @@ -129,6 +129,9 @@ jobs: outputs: dist_sha256: \${{ steps.candidate.outputs.dist_sha256 }} archive_sha256: \${{ steps.candidate.outputs.archive_sha256 }} + env: + APP_PROFILE: "\${{ vars.APP_PROFILE }}" + RELEASE_TARGET: "\${{ vars.RELEASE_TARGET }}" steps: - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 036d9b3..d3dda0c 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -184,16 +184,16 @@ describe("CI gate contract", () => { const index = indexCiGateContract(contract); expect(contract.schemaVersion).toBe(2); expect(contract.gates.map(({ id }) => id)).toEqual( - Array.from({ length: 26 }, (_, index) => + Array.from({ length: 27 }, (_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`, ), ); expect(contract.jobs).toHaveLength(9); - expect(contract.commands).toHaveLength(81); - expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93); + expect(contract.commands).toHaveLength(82); + expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94); expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23); - expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(85); - expect(contract.artifacts).toHaveLength(105); + expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86); + expect(contract.artifacts).toHaveLength(107); expect(contract.stages).toHaveLength(5); expect(contract.retention.classes).toHaveLength(5); expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2); diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts index a2a86b0..7fbb1c4 100644 --- a/tests/unit/task3-selective-integration.test.ts +++ b/tests/unit/task3-selective-integration.test.ts @@ -177,10 +177,10 @@ describe("selective Task 3 contract closure", () => { it("accepts only the canonical exact-count authority and rejects orphan retention", async () => { const canonical = await loadCiGateContract(process.cwd()); - expect(canonical.gates).toHaveLength(26); - expect(canonical.commands).toHaveLength(81); - expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93); - expect(canonical.artifacts).toHaveLength(105); + expect(canonical.gates).toHaveLength(27); + expect(canonical.commands).toHaveLength(82); + expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94); + expect(canonical.artifacts).toHaveLength(107); expect(canonical.stages).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5);