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:
co-authored by
Claude Opus 5
parent
7485cd86e4
commit
3ea3397691
@@ -156,6 +156,41 @@
|
|||||||
"path": "^(src/(presentation|bootstrap)|react|react-dom|@tanstack)"
|
"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",
|
"name": "no-circular-dependencies",
|
||||||
"severity": "error",
|
"severity": "error",
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||||
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
|
archive_sha256: ${{ steps.candidate.outputs.archive_sha256 }}
|
||||||
|
env:
|
||||||
|
APP_PROFILE: "${{ vars.APP_PROFILE }}"
|
||||||
|
RELEASE_TARGET: "${{ vars.RELEASE_TARGET }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -140,7 +140,12 @@ corepack pnpm exec playwright install --with-deps chromium firefox webkit
|
|||||||
Two gates intentionally need external evidence:
|
Two gates intentionally need external evidence:
|
||||||
|
|
||||||
- `review:a11y-manual` needs a signed human keyboard/focus/screen-reader review
|
- `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
|
- `collect:web-vitals-evidence` stays `FAIL_UNVERIFIED` until a reviewed minimum
|
||||||
eligible-sample threshold and 28 days of production data exist.
|
eligible-sample threshold and 28 days of production data exist.
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -2275,10 +2275,20 @@
|
|||||||
"condition": "release",
|
"condition": "release",
|
||||||
"timeoutMinutes": 45,
|
"timeoutMinutes": 45,
|
||||||
"gateIds": [
|
"gateIds": [
|
||||||
"FE-GATE-015"
|
"FE-GATE-015",
|
||||||
|
"FE-GATE-027"
|
||||||
],
|
],
|
||||||
"browserGateIds": [],
|
"browserGateIds": [],
|
||||||
"environment": [],
|
"environment": [
|
||||||
|
{
|
||||||
|
"name": "APP_PROFILE",
|
||||||
|
"value": "${{ vars.APP_PROFILE }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RELEASE_TARGET",
|
||||||
|
"value": "${{ vars.RELEASE_TARGET }}"
|
||||||
|
}
|
||||||
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"kind": "checkout"
|
"kind": "checkout"
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
# Manual accessibility review checklist
|
# Manual accessibility review checklist
|
||||||
|
|
||||||
Automated axe checks do not establish WCAG conformance. A human reviewer must
|
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
|
release candidate and sign them. The required scope is derived from the route
|
||||||
registry: `APP_HOME`, `EXAMPLES_UI`, `EXAMPLES_STATES`, `EXAMPLES_AUTH`,
|
registry: `APP_HOME`, `EXAMPLES_PLATFORM`, `EXAMPLES_UI`, `EXAMPLES_STATES`,
|
||||||
`REFERENCE_RESOURCE_LIST`, and `NOT_FOUND`. Copy the template fields exactly; the
|
`EXAMPLES_AUTH`, `NOT_FOUND`, `REFERENCE_RESOURCE_LIST`,
|
||||||
gate rejects blank identity/timestamp/signature fields, pending verdicts,
|
`REFERENCE_RESOURCE_DETAIL`, `REFERENCE_RESOURCE_FORM` and
|
||||||
mismatched release IDs, or missing routes.
|
`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:
|
Allowed item verdicts:
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,33 @@ The following edges are forbidden:
|
|||||||
|
|
||||||
- domain to application, presentation, adapters, bootstrap, React, or browser globals
|
- domain to application, presentation, adapters, bootstrap, React, or browser globals
|
||||||
- application to presentation, concrete 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
|
- 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
|
- an adapter to presentation, bootstrap internals, or another concrete adapter
|
||||||
- feature domain/application to its presentation or outbound adapter, and
|
- feature domain/application to its presentation or outbound adapter, and
|
||||||
feature presentation to its outbound adapter
|
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
|
`bootstrap` contains composition only. Business rules and page-specific
|
||||||
orchestration belong to domain/application.
|
orchestration belong to domain/application.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"standard": "rules/diagram-standards.md v2",
|
"standard": "rules/diagram-standards.md v2",
|
||||||
"evidenceReport": {
|
"evidenceReport": {
|
||||||
"repoPath": "docs/architecture/review-evidence.md",
|
"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"
|
"canonicalSha256": "b4d2a35e4f07e176717786408f98dab5cee1047f77f6ff61f5faeddfccd78a29"
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
@@ -25,5 +25,6 @@
|
|||||||
"thresholdSatisfied": true,
|
"thresholdSatisfied": true,
|
||||||
"scope": "immutable static assets and mutable /config.json delivery"
|
"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`."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -723,9 +723,13 @@ function findArchitectureViolations(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const dependency of dependencies) {
|
for (const dependency of dependencies) {
|
||||||
|
const sourceGroups = rule.from?.path
|
||||||
|
? (new RegExp(rule.from.path, "u").exec(dependency.source)?.slice(1) ??
|
||||||
|
[])
|
||||||
|
: [];
|
||||||
if (
|
if (
|
||||||
matchesPath(dependency.source, rule.from) &&
|
matchesPath(dependency.source, rule.from) &&
|
||||||
matchesPath(dependency.target, rule.to)
|
matchesPath(dependency.target, rule.to, sourceGroups)
|
||||||
) {
|
) {
|
||||||
violations.push({
|
violations.push({
|
||||||
rule: rule.name,
|
rule: rule.name,
|
||||||
@@ -746,16 +750,52 @@ function findArchitectureViolations(
|
|||||||
function matchesPath(
|
function matchesPath(
|
||||||
modulePath: string,
|
modulePath: string,
|
||||||
criterion: PathRule | undefined,
|
criterion: PathRule | undefined,
|
||||||
|
sourceGroups: readonly string[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!criterion) return true;
|
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 false;
|
||||||
}
|
}
|
||||||
return !(
|
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 {
|
function validateArchitectureRules(rules: readonly ArchitectureRule[]): void {
|
||||||
if (!rules.some((rule) => rule.to?.circular === true)) {
|
if (!rules.some((rule) => rule.to?.circular === true)) {
|
||||||
throw new Error("Architecture configuration must contain a circular rule");
|
throw new Error("Architecture configuration must contain a circular rule");
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ if (!architecture?.evidenceArtifactIds.some((id) => index.artifacts.get(id)?.pat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expectedGateIds = Array.from(
|
const expectedGateIds = Array.from(
|
||||||
{ length: 26 },
|
{ length: 27 },
|
||||||
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||||
);
|
);
|
||||||
const passingResults: Record<string, GateResult> = Object.fromEntries(
|
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.stderr.write(`CI contract failed:\n${failures.join("\n")}\n`);
|
||||||
process.exit(1);
|
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");
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||||
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4";
|
"4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -474,16 +474,16 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
(total, gate) => total + gate.commandIds.length,
|
(total, gate) => total + gate.commandIds.length,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
if (contract.gates.length !== 26) {
|
if (contract.gates.length !== 27) {
|
||||||
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
|
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(
|
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) {
|
if (contract.artifacts.length !== 107) {
|
||||||
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
|
failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||||
@@ -760,11 +760,11 @@ function validateContractSemantics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expectedGateIds = Array.from(
|
const expectedGateIds = Array.from(
|
||||||
{ length: 26 },
|
{ length: 27 },
|
||||||
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
(_, index) => `FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||||
);
|
);
|
||||||
if (JSON.stringify(contract.gates.map(({ id }) => id)) !== JSON.stringify(expectedGateIds)) {
|
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[]]> = [
|
const expectedStages: ReadonlyArray<readonly [string, string, readonly string[], readonly string[]]> = [
|
||||||
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
|
["merge", "MERGE_READY", [], PROMOTION_FORMULA.MERGE_READY],
|
||||||
@@ -835,7 +835,7 @@ function validateContractSemantics(
|
|||||||
const expectedJobOwnership: Readonly<Record<string, readonly string[]>> = {
|
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"],
|
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"],
|
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: [],
|
vulnerability_provider: [],
|
||||||
provenance_provider: [],
|
provenance_provider: [],
|
||||||
promotion: [],
|
promotion: [],
|
||||||
@@ -889,7 +889,13 @@ function validateContractSemantics(
|
|||||||
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
|
const expectedEnvironmentBindings: Readonly<Record<string, readonly Readonly<{ name: string; value: string }> []>> = {
|
||||||
merge_gate: [],
|
merge_gate: [],
|
||||||
release_gate: [{ name: "HOSTING_BASE_URL", value: "${{ vars.HOSTING_BASE_URL }}" }],
|
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: [
|
vulnerability_provider: [
|
||||||
{ name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" },
|
{ 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" },
|
{ name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" },
|
||||||
|
|||||||
@@ -1342,9 +1342,28 @@ export const documentationReviewArtifactSchema = z
|
|||||||
reviewer: z.literal("wiki-diagram-reviewer"),
|
reviewer: z.literal("wiki-diagram-reviewer"),
|
||||||
standard: z.literal("rules/diagram-standards.md v2"),
|
standard: z.literal("rules/diagram-standards.md v2"),
|
||||||
evidenceReport: z
|
evidenceReport: z
|
||||||
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
|
.object({
|
||||||
|
repoPath: nonEmptyString,
|
||||||
|
upstreamCanonicalPath: nonEmptyString,
|
||||||
|
canonicalSha256: sha256,
|
||||||
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
reportDigestValid: z.boolean(),
|
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(
|
results: z.array(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
@@ -1371,8 +1390,21 @@ export const documentationReviewArtifactSchema = z
|
|||||||
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
|
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
|
artifact.routeScope.forEach((entry, index) => {
|
||||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
|
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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const ciContractReportSchema = z
|
|||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(2),
|
schemaVersion: z.literal(2),
|
||||||
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
nodeVersion: z.string().regex(/^\d+\.\d+\.\d+$/u),
|
||||||
gateCount: z.literal(26),
|
gateCount: z.literal(27),
|
||||||
commandDefinitionCount: z.number().int().positive(),
|
commandDefinitionCount: z.number().int().positive(),
|
||||||
commandReferenceCount: z.number().int().positive(),
|
commandReferenceCount: z.number().int().positive(),
|
||||||
artifactCount: z.number().int().positive(),
|
artifactCount: z.number().int().positive(),
|
||||||
|
|||||||
@@ -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 { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.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<{
|
type DocumentationReview = Readonly<{
|
||||||
sourcePath: string;
|
sourcePath: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
@@ -14,6 +30,7 @@ type DocumentationReview = Readonly<{
|
|||||||
type ReviewLedger = Readonly<{
|
type ReviewLedger = Readonly<{
|
||||||
evidenceReport: Readonly<{
|
evidenceReport: Readonly<{
|
||||||
repoPath: string;
|
repoPath: string;
|
||||||
|
upstreamCanonicalPath: string;
|
||||||
canonicalSha256: string;
|
canonicalSha256: string;
|
||||||
}>;
|
}>;
|
||||||
reviews: Record<string, DocumentationReview>;
|
reviews: Record<string, DocumentationReview>;
|
||||||
@@ -58,8 +75,30 @@ for (const [diagram, review] of Object.entries(ledger.reviews)) {
|
|||||||
const reportDigestValid =
|
const reportDigestValid =
|
||||||
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
|
/^[0-9a-f]{64}$/.test(ledger.evidenceReport.canonicalSha256) &&
|
||||||
evidence.includes(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 =
|
const passed =
|
||||||
reportDigestValid &&
|
reportDigestValid &&
|
||||||
|
routeScopeDocumented &&
|
||||||
results.length === 2 &&
|
results.length === 2 &&
|
||||||
results.every((result) => result.passed);
|
results.every((result) => result.passed);
|
||||||
await mkdir("artifacts/quality", { recursive: true });
|
await mkdir("artifacts/quality", { recursive: true });
|
||||||
@@ -74,6 +113,8 @@ await writeValidatedJsonArtifact({
|
|||||||
standard: ledger.standard,
|
standard: ledger.standard,
|
||||||
evidenceReport: ledger.evidenceReport,
|
evidenceReport: ledger.evidenceReport,
|
||||||
reportDigestValid,
|
reportDigestValid,
|
||||||
|
routeScope,
|
||||||
|
routeScopeDocumented,
|
||||||
results,
|
results,
|
||||||
passed,
|
passed,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type DiagnosticRecordInput,
|
type DiagnosticRecordInput,
|
||||||
} from "../../contracts/diagnostics.ts";
|
} from "../../contracts/diagnostics.ts";
|
||||||
import { projectTelemetryEvent } from "../../contracts/telemetry.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({
|
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
|
||||||
record() {},
|
record() {},
|
||||||
|
|||||||
@@ -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,
|
TelemetryEventName,
|
||||||
} from "../../contracts/telemetry.ts";
|
} from "../../contracts/telemetry.ts";
|
||||||
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
|
||||||
|
import { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||||
|
|
||||||
export type TelemetryAdapter = TelemetryPort &
|
export type TelemetryAdapter = TelemetryPort &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
@@ -45,22 +46,8 @@ type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
|
|||||||
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
|
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
|
||||||
export const MAX_TELEMETRY_QUEUE = 10_000;
|
export const MAX_TELEMETRY_QUEUE = 10_000;
|
||||||
|
|
||||||
/**
|
export { assertBoundedCapacity } from "../platform/bounded-capacity.ts";
|
||||||
* 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 function createTelemetryAdapter(
|
export function createTelemetryAdapter(
|
||||||
options: TelemetryAdapterOptions,
|
options: TelemetryAdapterOptions,
|
||||||
|
|||||||
@@ -1,107 +1,21 @@
|
|||||||
export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([
|
/**
|
||||||
"buildId",
|
* Release compatibility comparison.
|
||||||
"configSchemaVersion",
|
*
|
||||||
"apiContractVersion",
|
* The implementation lives in `src/contracts/compatibility.ts`: it is a pure
|
||||||
"assetManifestHash",
|
* predicate over release tokens with no application state, and
|
||||||
"releaseId",
|
* `src/contracts/release-tokens.ts` needs it, which previously made contracts
|
||||||
] as const);
|
* import the application layer. This module re-exports it for application-side
|
||||||
|
* and script-side callers.
|
||||||
export type CompatibilityTupleField =
|
*/
|
||||||
(typeof COMPATIBILITY_TUPLE_FIELDS)[number];
|
export {
|
||||||
|
COMPATIBILITY_TUPLE_FIELDS,
|
||||||
export type CompatibilityTuple = Readonly<
|
classifyObjectSchemaChange,
|
||||||
Record<CompatibilityTupleField, string>
|
isVersionCompatible,
|
||||||
>;
|
parseNumericVersion,
|
||||||
|
verifyCompatibilityTuple,
|
||||||
export type NumericVersion = Readonly<{
|
type CompatibilityTuple,
|
||||||
major: number;
|
type CompatibilityTupleField,
|
||||||
minor: number;
|
type NumericVersion,
|
||||||
patch: number;
|
type ObjectSchemaShape,
|
||||||
}>;
|
type SchemaChangeClassification,
|
||||||
|
} from "../../contracts/compatibility.ts";
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const PROMOTION_FORMULA = Object.freeze({
|
|||||||
"FE-GATE-015",
|
"FE-GATE-015",
|
||||||
"FE-GATE-019",
|
"FE-GATE-019",
|
||||||
"FE-GATE-026",
|
"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([
|
PROD_PROMOTION_READY: Object.freeze([
|
||||||
"FE-GATE-016",
|
"FE-GATE-016",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { AppFailure } from "../contracts/errors.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single success/failure carrier used across application input boundaries.
|
* The single success/failure carrier used across application input boundaries.
|
||||||
* Adapters map technology-specific errors to an application failure before
|
* Adapters map technology-specific errors to an application failure before
|
||||||
* constructing this value.
|
* 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> =
|
export type { Result } from "../contracts/result.ts";
|
||||||
| Readonly<{ ok: true; value: Value }>
|
|
||||||
| Readonly<{ ok: false; error: Failure }>;
|
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { Result } from "../application/result.ts";
|
import type { Result } from "./result.ts";
|
||||||
|
|
||||||
export type CursorPage<Value> = Readonly<{
|
export type CursorPage<Value> = Readonly<{
|
||||||
items: readonly Value[];
|
items: readonly Value[];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { verifyCompatibilityTuple } from "../application/policies/compatibility.ts";
|
import { verifyCompatibilityTuple } from "./compatibility.ts";
|
||||||
|
|
||||||
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
|
export const RELEASE_TOKEN_REGISTRY = Object.freeze({
|
||||||
appVersion: token("appVersion", "manifest", "human release label"),
|
appVersion: token("appVersion", "manifest", "human release label"),
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { Result } from "../application/result.ts";
|
import type { Result } from "./result.ts";
|
||||||
import type { QueryInvalidationTopic } from "./query-invalidation.ts";
|
import type { QueryInvalidationTopic } from "./query-invalidation.ts";
|
||||||
import {
|
import {
|
||||||
createBoundQueryKey,
|
createBoundQueryKey,
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
dist_sha256: \${{ steps.candidate.outputs.dist_sha256 }}
|
dist_sha256: \${{ steps.candidate.outputs.dist_sha256 }}
|
||||||
archive_sha256: \${{ steps.candidate.outputs.archive_sha256 }}
|
archive_sha256: \${{ steps.candidate.outputs.archive_sha256 }}
|
||||||
|
env:
|
||||||
|
APP_PROFILE: "\${{ vars.APP_PROFILE }}"
|
||||||
|
RELEASE_TARGET: "\${{ vars.RELEASE_TARGET }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -184,16 +184,16 @@ describe("CI gate contract", () => {
|
|||||||
const index = indexCiGateContract(contract);
|
const index = indexCiGateContract(contract);
|
||||||
expect(contract.schemaVersion).toBe(2);
|
expect(contract.schemaVersion).toBe(2);
|
||||||
expect(contract.gates.map(({ id }) => id)).toEqual(
|
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")}`,
|
`FE-GATE-${String(index + 1).padStart(3, "0")}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(contract.jobs).toHaveLength(9);
|
expect(contract.jobs).toHaveLength(9);
|
||||||
expect(contract.commands).toHaveLength(81);
|
expect(contract.commands).toHaveLength(82);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(93);
|
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
|
||||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(85);
|
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86);
|
||||||
expect(contract.artifacts).toHaveLength(105);
|
expect(contract.artifacts).toHaveLength(107);
|
||||||
expect(contract.stages).toHaveLength(5);
|
expect(contract.stages).toHaveLength(5);
|
||||||
expect(contract.retention.classes).toHaveLength(5);
|
expect(contract.retention.classes).toHaveLength(5);
|
||||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
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 () => {
|
it("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||||
const canonical = await loadCiGateContract(process.cwd());
|
const canonical = await loadCiGateContract(process.cwd());
|
||||||
expect(canonical.gates).toHaveLength(26);
|
expect(canonical.gates).toHaveLength(27);
|
||||||
expect(canonical.commands).toHaveLength(81);
|
expect(canonical.commands).toHaveLength(82);
|
||||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(93);
|
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
||||||
expect(canonical.artifacts).toHaveLength(105);
|
expect(canonical.artifacts).toHaveLength(107);
|
||||||
expect(canonical.stages).toHaveLength(5);
|
expect(canonical.stages).toHaveLength(5);
|
||||||
expect(canonical.retention.classes).toHaveLength(5);
|
expect(canonical.retention.classes).toHaveLength(5);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user