fix: make the architecture and documentation rules say what is actually true

Three boundaries the layer contract declares had no executable rule behind
them, so the code drifted across all three while every gate stayed green.

`src/contracts` reached back up into `src/application` for the shared `Result`
carrier and the compatibility predicate. Neither package owned the shared
vocabulary and the dependency pointed both ways. Both now live in contracts —
the lower package — and application re-exports them, so no caller moves.

A concrete adapter was not supposed to depend on another concrete adapter, but
only adapter-to-presentation was enforced, and `diagnostics` imported a guard
out of `telemetry`. The guard belongs to neither, so it moved to the adapter
kernel. Stating the rule needed the checker to resolve `$1` in a `to` pattern
against the importing module's own directory; the alternative is one rule per
adapter group, which silently stops covering a group the moment one is added.

Product assembly leaks out of bootstrap: generic presentation reads the
installed-feature registries. That is a real refactor, so the rule freezes the
exact set of modules doing it today rather than pretending it is fixed — a new
edge fails. The two remaining open edges are named in the config, not silent.

Each rule was verified by introducing the violation it forbids and confirming
the gate rejects it.

The documentation drifted the same way. README and the manual accessibility
checklist both said six routes while ten were registered, which left the
platform overview and three reference-resource screens outside the declared
manual review scope without anyone deciding they should be. The scope is now
derived from the route registry by `verify:documentation`, so the sentence
cannot outlive the registry again. The review ledger also named a canonical
path that does not exist in this tree; it is upstream provenance, and it now
says so instead of looking like a broken repository reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 17:23:10 +09:00
co-authored by Claude Opus 5
parent 7485cd86e4
commit 3ea3397691
27 changed files with 428 additions and 172 deletions
+35
View File
@@ -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",
+3
View File
@@ -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:
+6 -1
View File
@@ -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.
+12 -2
View File
@@ -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"
+13 -5
View File
@@ -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:
+22
View File
@@ -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.
+3 -2
View File
@@ -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`."
}
+43 -3
View File
@@ -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");
+2 -2
View File
@@ -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<string, GateResult> = 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");
+17 -11
View File
@@ -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<readonly [string, string, readonly string[], readonly string[]]> = [
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
@@ -835,7 +835,7 @@ function validateContractSemantics(
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
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<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
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" },
+35 -3
View File
@@ -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" });
}
});
+1 -1
View File
@@ -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(),
+42 -1
View File
@@ -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<string, DocumentationReview>;
@@ -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,
},
@@ -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() {},
+21
View File
@@ -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;
}
@@ -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,
+21 -107
View File
@@ -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<CompatibilityTupleField, string>
>;
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<Record<string, unknown>>;
}>;
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";
@@ -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",
+8 -5
View File
@@ -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<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
export type { Result } from "../contracts/result.ts";
+107
View File
@@ -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<CompatibilityTupleField, string>
>;
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<Record<string, unknown>>;
}>;
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";
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from "../application/result.ts";
import type { Result } from "./result.ts";
export type CursorPage<Value> = Readonly<{
items: readonly Value[];
+1 -1
View File
@@ -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"),
+15
View File
@@ -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<Value, Failure = AppFailure> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: Failure }>;
+1 -1
View File
@@ -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,
@@ -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:
+5 -5
View File
@@ -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);
@@ -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);