Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e910a45ab3 | ||
|
|
c5b0d510d7 | ||
|
|
9b7005d59b | ||
|
|
fbb6191298 | ||
|
|
32b490a712 | ||
|
|
83d2ecb224 | ||
|
|
9e1a7a44b7 | ||
|
|
8b1b873056 | ||
|
|
d119635265 | ||
|
|
abaed0c103 | ||
|
|
04cc031774 | ||
|
|
e89599758b | ||
|
|
0b4aa4751a | ||
|
|
f4fd5967fc |
@@ -72,12 +72,8 @@ steps:
|
|||||||
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
||||||
gate-verify:
|
gate-verify:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
# Woodpecker's shallow marker makes merge-base reject even present parents;
|
|
||||||
# full history is required for activation ancestry and manifest provenance.
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- apk add --no-cache bubblewrap
|
|
||||||
- if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi
|
|
||||||
- pnpm gate:verify
|
- pnpm gate:verify
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ Investigate any of these immediately:
|
|||||||
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
||||||
- `no negative control` — a gate has no must-fail case.
|
- `no negative control` — a gate has no must-fail case.
|
||||||
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
||||||
- `PROVIDER EVIDENCE ... ABSENT` — retained external history was unavailable; do not infer merge-time success.
|
- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner.
|
||||||
|
|
||||||
## Updating a gate
|
## Updating a gate
|
||||||
|
|
||||||
1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`.
|
1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. Preserve recursive closed-schema validation for every nested object; new fields require explicit key and type handling.
|
||||||
2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification.
|
2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification.
|
||||||
3. Declare an exact inerting mutation and observe the verifier detect it.
|
3. Declare an exact inerting mutation and observe the verifier detect it.
|
||||||
4. If required and actual behavior differ, add a tracked remediation owner and justification.
|
4. If required and actual behavior differ, add a tracked remediation owner and justification.
|
||||||
@@ -26,10 +26,10 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK.
|
|||||||
|
|
||||||
## CI behavior
|
## CI behavior
|
||||||
|
|
||||||
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout so activation ancestry and historical manifest provenance can be checked; a shallow boundary must never be interpreted as non-ancestry.
|
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property.
|
||||||
|
|
||||||
Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository.
|
**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** The registered shrink-both, overclaim, and per-gate evidence-subject controls must remain red for their stated reasons.
|
||||||
|
|
||||||
PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself.
|
**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor.
|
||||||
|
|
||||||
The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. Tests recognize unavailability only from parent-generated Bubblewrap-launch provenance combined with proof that the sandbox entry command did not run. A denial-looking string from child-controlled output is not evidence. When RM-60 activates replay under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention.
|
`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pnpm gate:verify
|
|||||||
|
|
||||||
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||||
|
|
||||||
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||||
|
|
||||||
## Required versus actual
|
## Required versus actual
|
||||||
|
|
||||||
@@ -32,14 +32,12 @@ Restatements preserve original text, current text, reason, finding/task, and dat
|
|||||||
|
|
||||||
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
||||||
|
|
||||||
## Commit and provider boundary
|
## Current-tree and history-provenance boundary
|
||||||
|
|
||||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed.
|
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population, verifier inventory, and `gates/required-gates.baseline.json` are compared inside the checkout. Evidence-side subjects are consumed and compared with gate definitions for every gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
|
||||||
**DOES NOT:** Repository-controlled PR CI does not execute a commit's own verifier in an isolated replay. Doing so safely would require granting namespace capability before PR-controlled configuration or code runs; that same PR could consume the capability directly. This is an absent trust boundary, not unfinished hardening. RM-60 owns a runner-level rootless sandbox or protected immutable launcher; RM-59 owns the parallel artifact-integrity anchor.
|
**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed.
|
||||||
|
|
||||||
The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only when the result carries parent-generated Bubblewrap-launch provenance and proves the sandbox entry command never ran. Child-controlled text that merely reproduces a Bubblewrap denial is not accepted. Capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites.
|
The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02.
|
||||||
|
|
||||||
Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success.
|
Universal checks first prove populations non-empty. The production profile reads the same-checkout baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. A registered prose-claim control fails when the same-checkout mechanism is described as an adversarial protection.
|
||||||
|
|
||||||
Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement.
|
|
||||||
|
|||||||
+16
-11
@@ -22,9 +22,9 @@ Existing deterministic gates can return success without enforcing their stated p
|
|||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; retained provider CI evidence where available; and explicit deferral of isolated per-commit replay to RM-60's protected external authority.
|
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides protected external authority.
|
||||||
|
|
||||||
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
@@ -37,13 +37,17 @@ Existing deterministic gates can return success without enforcing their stated p
|
|||||||
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
||||||
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
||||||
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
||||||
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent.
|
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance.
|
||||||
|
11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from a same-checkout baseline and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate.
|
||||||
|
13. `RM02-REQ-13` (`D-40`): For every gate listed in the required inventory baseline, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||||
|
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and compared with the same-checkout baseline. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||||
|
|
||||||
#### RM02-REQ-10 meaning-change provenance
|
#### RM02-REQ-10 meaning-change provenance
|
||||||
|
|
||||||
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
||||||
- **Restatement:** PR CI performs unprivileged, fail-closed current-tree verification only. Isolated per-commit replay is deferred to a protected post-merge/main authority, where it is detection with a defined quarantine/revert response — explicitly not a pre-merge gate. Inability to establish the sandbox is hard nonzero, never a skip.
|
- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes.
|
||||||
- **Reason:** Isolated replay on PR CI would require granting namespace capability to PR-controlled configuration, which the same PR could use directly before containment. The trust boundary is impossible at the repository layer, not merely expensive. RM-60 owns the external pre-execution anchor; RM-59 tracks the corresponding artifact-integrity anchor.
|
- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened.
|
||||||
|
|
||||||
### Acceptance criteria
|
### Acceptance criteria
|
||||||
|
|
||||||
@@ -53,15 +57,16 @@ Existing deterministic gates can return success without enforcing their stated p
|
|||||||
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
||||||
5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
||||||
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||||
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention.
|
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, same-checkout inventory drift, and evidence-side subject consumption; it does not establish history provenance at all. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.**
|
||||||
|
8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; overstating the same-checkout inventory boundary; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||||
|
9. `RM02-AC-09`: Population controls iterate every gate listed in the baseline and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||||
|
|
||||||
### Risks, dependencies, and verification boundary
|
### Risks, dependencies, and verification boundary
|
||||||
|
|
||||||
- The repository verifier proves declared controls, modeled scenarios, bidirectional declared criterion/case relationships, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** infer arbitrary-English semantics, execute isolated per-commit replay, or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently.
|
- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, a same-checkout seven-gate baseline comparison, and evidence-side subject consumption.
|
||||||
- Sandbox refusal tests require parent-generated Bubblewrap-launch provenance and proof that the sandbox entry command never ran; child-controlled denial-looking text alone cannot establish unavailability.
|
- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state.
|
||||||
- Repo-only code cannot both grant namespace capability to PR configuration and prevent that same PR from using the capability directly. RM-60 owns a runner/provider-controlled pre-execution boundary; RM-59 owns the parallel artifact-integrity anchor.
|
- **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** A registered claim control fails if the checked artifacts overstate this boundary.
|
||||||
- Protected post-merge replay, once RM-60 exists, is detection only. Failure requires immediate quarantine of the affected result and revert of the offending merge; it is not equivalent to a pre-merge gate.
|
- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently.
|
||||||
- External branch protection and provider CI history supply merge-time current-tree evidence where retained. RM-25 tracks provider-side enforcement.
|
|
||||||
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Gate verification
|
## Gate verification
|
||||||
|
|
||||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, defect deltas, modeled boundaries, and commit/provider evidence.
|
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, and the enforced RM-60 history-provenance exclusion. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||||
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||||
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
||||||
|
|
||||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module checks activation/manifest provenance and retained external current-tree CI evidence without inferring missing evidence. Isolated own-tree execution remains fail-closed code for RM-60's future protected authority; repository-controlled PR CI does not invoke it.
|
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the same-checkout `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary.
|
||||||
|
|
||||||
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||||
|
|
||||||
@@ -66,16 +66,17 @@ Write and observe a failing test with a byte-mutated deployed counterpart. Imple
|
|||||||
|
|
||||||
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
||||||
|
|
||||||
### Task 6: Current-tree boundary, deferred replay, and provider evidence
|
### Task 6: Current-tree boundary and enforced history-provenance exclusion
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Create: `scripts/gate-history.mjs`
|
- Create: `gates/required-gates.baseline.json`
|
||||||
- Create: `scripts/gate-history.test.mjs`
|
- Create: `scripts/gate-history-exclusion-control.mjs`
|
||||||
|
- Create: `scripts/gate-inventory-shrink-control.mjs`
|
||||||
- Modify: `scripts/gate-verify.mjs`
|
- Modify: `scripts/gate-verify.mjs`
|
||||||
- Modify: `gates/gates.manifest.json`
|
- Modify: `gates/gates.manifest.json`
|
||||||
|
|
||||||
Test with a synthetic git repository containing two commits whose manifests differ. PR verification must state adjacent `DOES`/`DOES NOT` boundaries and must not execute the intermediate commit's verifier. Preserve isolated replay as a direct fail-closed primitive for RM-60's future protected pre-execution authority; sandbox failure remains nonzero. Add bounded Gitea/Woodpecker current-tree status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. Protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the same-checkout baseline, register must-fail attacks that shrink either paired representation, and register an overclaim control. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary.
|
||||||
|
|
||||||
### Task 7: CI and documentation
|
### Task 7: CI and documentation
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ This index binds remediation claims that live in orchestrator-owned `TASKS.md` w
|
|||||||
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
||||||
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
||||||
|
|
||||||
|
## Same-checkout inventory drift boundary
|
||||||
|
|
||||||
|
<!-- GATE-CLAIM:INVENTORY-DRIFT-BOUNDARY -->
|
||||||
|
|
||||||
|
- Source: RM-02 ruling after D-48/CWE-353 reproduced against the round-4 baseline.
|
||||||
|
- Boundary: Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.
|
||||||
|
- Negative control: `checkout-preflight/inventory-claim-overstatement` rewrites the boundary as protection and must go red.
|
||||||
|
|
||||||
## Criterion restatement provenance
|
## Criterion restatement provenance
|
||||||
|
|
||||||
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||||
|
|
||||||
|
**Tracking:** RM-61 / issue #1000
|
||||||
|
|
||||||
|
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||||
|
**Owner:** `coder-mos1`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||||
|
|
||||||
|
## Pre-registered kill criterion
|
||||||
|
|
||||||
|
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||||
|
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||||
|
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||||
|
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||||
|
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||||
|
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||||
|
|
||||||
|
## Initial evidence
|
||||||
|
|
||||||
|
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Requirements and kill criterion recorded before control implementation.
|
||||||
|
- [x] Historical full-JSON records captured.
|
||||||
|
- [x] Startup-failure control observed terminal.
|
||||||
|
- [x] Post-readiness crash control observed terminal.
|
||||||
|
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||||
|
- [x] Conditional exemption implementation.
|
||||||
|
|
||||||
|
## Tests / evidence
|
||||||
|
|
||||||
|
### Control 1 — real startup failure
|
||||||
|
|
||||||
|
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||||
|
- Pipeline: #2189, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||||
|
|
||||||
|
### Control 2 — real post-readiness crash
|
||||||
|
|
||||||
|
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||||
|
- Pipeline: #2191, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||||
|
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=61`.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||||
|
|
||||||
|
### Discrimination verdict
|
||||||
|
|
||||||
|
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||||
|
|
||||||
|
### Unit red-first checkpoint
|
||||||
|
|
||||||
|
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||||
|
|
||||||
|
### Control 2 setup attempt — invalid, excluded from evidence
|
||||||
|
|
||||||
|
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||||
|
- Pipeline: #2190, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||||
|
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||||
|
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||||
|
|
||||||
|
## Implementation evidence
|
||||||
|
|
||||||
|
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||||
|
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||||
|
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||||
|
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||||
|
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||||
|
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||||
|
- Python compile: PASS.
|
||||||
|
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||||
|
- `pnpm lint`: PASS, 25/25 tasks.
|
||||||
|
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||||
|
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||||
|
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||||
|
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||||
|
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||||
|
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||||
|
|
||||||
|
## Documentation checklist
|
||||||
|
|
||||||
|
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||||
|
- [x] Operator command documented in the Woodpecker tool README.
|
||||||
|
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||||
|
- [x] Tracking and retirement cite issue #1000.
|
||||||
|
- [x] Both positive and negative guarantee boundaries are stated.
|
||||||
|
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||||
@@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02
|
|||||||
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
||||||
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
||||||
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
||||||
- Focused Node tests: 38/38 pass after review hardening (27 verifier/wiring plus 11 history/provider tests).
|
- Focused Node tests: 54/54 pass after third-round hardening (36 verifier/wiring plus 18 history/provider tests).
|
||||||
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||||
- `pnpm lint`: pass (25/25 Turbo tasks).
|
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||||
- `pnpm format:check`: pass.
|
- `pnpm format:check`: pass.
|
||||||
@@ -66,6 +66,10 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro
|
|||||||
- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation.
|
- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation.
|
||||||
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
||||||
|
|
||||||
|
- Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content.
|
||||||
|
|
||||||
|
- Exact-head review at `32b490a7` found three population-level blockers. Genuine RED-first tests proved a gate change before author-delayed registry introduction fell outside the range, and empty criteria/gates/prose/scenario populations returned zero. History now anchors at the provider target merge-base; delayed introduction is a registered must-fail control. Production verification requires non-empty populations and a hardcoded seven-gate ID/source inventory before quantified checks. D-38/D-40 criteria now quantify over `gateRefs` exactly spanning every registered gate; each gate declares its evidence subject, and population controls mutate evidence subject and comparison type for every gate. The history record states both bootstrap directions: sound against a branch author unable to rewrite main, not sound against compromised/rewritten main, with residual owned by Builds 1-2. Pre-commit review rejected an initial production CLI `--fixture-profile` test relaxation as a vacuity bypass. That flag was removed; synthetic fixtures now use a non-executable test-support runner, while regression tests prove the shipped CLI rejects the flag and production population checks remain mandatory. Follow-up review then proved deleting `gateRefs` skipped population validation; a RED-first loop reproduced all three deletions, and the three general criterion IDs now require the field before exact-span validation.
|
||||||
|
|
||||||
## Documentation checklist
|
## Documentation checklist
|
||||||
|
|
||||||
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
||||||
@@ -73,9 +77,31 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro
|
|||||||
- Independent review documentation check pending rev-974 at the revised exact head.
|
- Independent review documentation check pending rev-974 at the revised exact head.
|
||||||
- Canonical documentation remains in-repository; no external publication requested.
|
- Canonical documentation remains in-repository; no external publication requested.
|
||||||
|
|
||||||
|
## Rebase onto RM-61
|
||||||
|
|
||||||
|
- Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts.
|
||||||
|
- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. An initial manifest update to `f4fd5967` had the right value but retained an author-controlled mechanism. Independent mutation proved HEAD, HEAD's parent, and the introduction commit could each make history coverage vacuous or partial. The manifest field is now forbidden; the verifier derives the boundary as the parent of the first first-parent registry-introduction commit, and registered must-fail controls reject all three unsafe candidates.
|
||||||
|
|
||||||
## Risks/blockers
|
## Risks/blockers
|
||||||
|
|
||||||
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
||||||
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
||||||
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
||||||
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
||||||
|
|
||||||
|
## coder-mos2 remediation — fourth round
|
||||||
|
|
||||||
|
- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification.
|
||||||
|
- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor.
|
||||||
|
- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly.
|
||||||
|
- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 <[email protected]>` using process-scoped author/committer variables; shared repository config was not modified.
|
||||||
|
- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards.
|
||||||
|
- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs.
|
||||||
|
- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round.
|
||||||
|
- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red.
|
||||||
|
- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class.
|
||||||
|
- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence.
|
||||||
|
- Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green.
|
||||||
|
- Coordinator ruling after CWE-353: retain the same-checkout inventory comparison but narrow its claim. Canonical current boundary: “Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.” This supersedes the earlier “independent/anchored” wording in the historical entries above; those entries remain as append-only evidence, not current claims.
|
||||||
|
- Genuine RED-first overclaim control: before claim remediation, `node scripts/gate-inventory-claim-control.mjs` exited 84 and named every artifact missing the narrowed boundary plus each current overclaim. The registered `inventory-claim-overstatement` case now rewrites the baseline purpose to a protection claim and must exit 84 with `INVENTORY_CLAIM_OVERSTATED`. Existing shrink-pair controls remain regression guards for the same-checkout drift property, not adversarial-integrity claims.
|
||||||
|
- Follow-up code review found a genuine propagation/control gap: `docs/PRD.md` still said “independent seven-gate baseline,” and the first forbidden regex did not match the intervening qualifier. The reviewer observed the control green against that overclaim. The PRD now says same-checkout, the detector rejects `independent … baseline` across bounded same-line qualifiers, the registered mutation uses the exact qualified wording, and a focused behavioral test requires exit 84 for it. Follow-up security review passed with risk `none` and explicitly accepted the narrowed RM-60 boundary.
|
||||||
|
|||||||
+441
-28
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c",
|
|
||||||
"gateRoots": ["gates"],
|
"gateRoots": ["gates"],
|
||||||
"governingClaimFiles": ["docs/remediation/MISSION.md", "docs/remediation/GATE-CLAIMS.md"],
|
"governingClaimFiles": ["docs/remediation/MISSION.md", "docs/remediation/GATE-CLAIMS.md"],
|
||||||
"coverageBoundary": {
|
"coverageBoundary": {
|
||||||
@@ -34,10 +33,19 @@
|
|||||||
"checkout-preflight/criterion-misbinding",
|
"checkout-preflight/criterion-misbinding",
|
||||||
"checkout-preflight/missing-meaning-provenance",
|
"checkout-preflight/missing-meaning-provenance",
|
||||||
"checkout-preflight/prose-claim-misbinding",
|
"checkout-preflight/prose-claim-misbinding",
|
||||||
|
"checkout-preflight/misspelled-outcome-field",
|
||||||
|
"checkout-preflight/wrong-outcome-field-type",
|
||||||
|
"checkout-preflight/empty-outcome-pattern",
|
||||||
|
"checkout-preflight/empty-registry-populations",
|
||||||
|
"checkout-preflight/all-gates-evidence-subject-bound",
|
||||||
|
"checkout-preflight/all-gates-type-strict",
|
||||||
"ci-queue-wait/no-status-required",
|
"ci-queue-wait/no-status-required",
|
||||||
"ci-queue-wait/unknown-option",
|
"ci-queue-wait/unknown-option",
|
||||||
"hook-pre-commit/lint-staged-failure",
|
"hook-pre-commit/lint-staged-failure",
|
||||||
"hook-pre-push/typecheck-failure"
|
"hook-pre-push/typecheck-failure",
|
||||||
|
"checkout-preflight/history-provenance-exclusion",
|
||||||
|
"checkout-preflight/inventory-source-and-manifest-shrink",
|
||||||
|
"checkout-preflight/inventory-claim-overstatement"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -103,18 +111,26 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "RM02-CURRENT-TREE-BOUNDARY",
|
"id": "RM02-CURRENT-TREE-BOUNDARY",
|
||||||
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.",
|
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.",
|
||||||
"currentText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
"currentText": "PR CI performs unprivileged, fail-closed current-tree verification only and makes no history-provenance assertion; RM-60 owns that external protected property.",
|
||||||
"claimType": "security",
|
"claimType": "security",
|
||||||
"source": "docs/PRD.md#rm02-req-10-meaning-change-provenance",
|
"source": "docs/PRD.md#rm02-req-10-meaning-change-provenance",
|
||||||
"meaningChanges": [
|
"meaningChanges": [
|
||||||
{
|
{
|
||||||
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.",
|
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.",
|
||||||
"restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
"restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
||||||
"reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.",
|
"reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.",
|
||||||
"finding": "D-25",
|
"finding": "D-25",
|
||||||
"task": "RM-60/RM-59",
|
"task": "RM-60/RM-59",
|
||||||
"date": "2026-08-01"
|
"date": "2026-08-01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"originalText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
||||||
|
"restatement": "PR CI performs unprivileged, fail-closed current-tree verification only and makes no history-provenance assertion; RM-60 owns that external protected property.",
|
||||||
|
"reason": "D-48 proved PR lifecycle code can rewrite every local git anchor before gate execution, so the repository history claim is removed rather than annotated as unverifiable.",
|
||||||
|
"finding": "D-48",
|
||||||
|
"task": "RM-60",
|
||||||
|
"date": "2026-08-01"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"caseRefs": ["checkout-preflight/privileged-pr-gate"]
|
"caseRefs": ["checkout-preflight/privileged-pr-gate"]
|
||||||
@@ -225,6 +241,96 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"]
|
"caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RM02-HISTORY-PROVENANCE-EXCLUDED",
|
||||||
|
"originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.",
|
||||||
|
"currentText": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.",
|
||||||
|
"claimType": "integrity",
|
||||||
|
"source": "docs/remediation/MISSION.md#first-class-principle-the-anchor-must-live-outside-the-audited-partys-authority",
|
||||||
|
"meaningChanges": [
|
||||||
|
{
|
||||||
|
"originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.",
|
||||||
|
"restatement": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.",
|
||||||
|
"reason": "PR-controlled lifecycle code executes before gate verification, so no local ref, config, remote URL, constant, or author-positioned path is trustworthy enough to anchor history provenance.",
|
||||||
|
"finding": "D-48",
|
||||||
|
"task": "RM-60",
|
||||||
|
"date": "2026-08-01"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"caseRefs": ["checkout-preflight/history-provenance-exclusion"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RM02-EVIDENCE-SUBJECT-BINDING",
|
||||||
|
"originalText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.",
|
||||||
|
"currentText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.",
|
||||||
|
"claimType": "integrity",
|
||||||
|
"source": "docs/remediation/TASKS.md#d-38",
|
||||||
|
"meaningChanges": [],
|
||||||
|
"caseRefs": ["checkout-preflight/all-gates-evidence-subject-bound"],
|
||||||
|
"gateRefs": [
|
||||||
|
"quality-typecheck",
|
||||||
|
"quality-lint",
|
||||||
|
"quality-format",
|
||||||
|
"checkout-preflight",
|
||||||
|
"ci-queue-wait",
|
||||||
|
"hook-pre-commit",
|
||||||
|
"hook-pre-push"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RM02-TYPE-STRICT-SCHEMA",
|
||||||
|
"originalText": "For every registered gate, each discriminator and comparison input is recursively closed and type-strict so malformed values cannot disable its assertion.",
|
||||||
|
"currentText": "For every registered gate, each discriminator and comparison input is recursively closed and type-strict so malformed values cannot disable its assertion.",
|
||||||
|
"claimType": "integrity",
|
||||||
|
"source": "docs/remediation/TASKS.md#d-40",
|
||||||
|
"meaningChanges": [],
|
||||||
|
"caseRefs": [
|
||||||
|
"checkout-preflight/misspelled-outcome-field",
|
||||||
|
"checkout-preflight/wrong-outcome-field-type",
|
||||||
|
"checkout-preflight/empty-outcome-pattern",
|
||||||
|
"checkout-preflight/all-gates-type-strict"
|
||||||
|
],
|
||||||
|
"gateRefs": [
|
||||||
|
"quality-typecheck",
|
||||||
|
"quality-lint",
|
||||||
|
"quality-format",
|
||||||
|
"checkout-preflight",
|
||||||
|
"ci-queue-wait",
|
||||||
|
"hook-pre-commit",
|
||||||
|
"hook-pre-push"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RM02-NONEMPTY-ANCHORED-QUANTIFICATION",
|
||||||
|
"originalText": "No universally quantified registry check runs until its population is proven non-empty and anchored.",
|
||||||
|
"currentText": "No universally quantified registry check runs over an empty population. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||||
|
"claimType": "integrity",
|
||||||
|
"source": "docs/remediation/TASKS.md#d-46",
|
||||||
|
"meaningChanges": [
|
||||||
|
{
|
||||||
|
"originalText": "No universally quantified registry check runs until its population is proven non-empty and anchored.",
|
||||||
|
"restatement": "No universally quantified registry check runs over an empty population. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||||
|
"reason": "D-48/CWE-353 established that a same-checkout baseline detects drift but is not an adversarial trust anchor.",
|
||||||
|
"finding": "D-48",
|
||||||
|
"task": "RM-60",
|
||||||
|
"date": "2026-08-01"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"caseRefs": [
|
||||||
|
"checkout-preflight/empty-registry-populations",
|
||||||
|
"checkout-preflight/inventory-source-and-manifest-shrink",
|
||||||
|
"checkout-preflight/inventory-claim-overstatement"
|
||||||
|
],
|
||||||
|
"gateRefs": [
|
||||||
|
"quality-typecheck",
|
||||||
|
"quality-lint",
|
||||||
|
"quality-format",
|
||||||
|
"checkout-preflight",
|
||||||
|
"ci-queue-wait",
|
||||||
|
"hook-pre-commit",
|
||||||
|
"hook-pre-push"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"proseClaims": [
|
"proseClaims": [
|
||||||
@@ -267,6 +373,11 @@
|
|||||||
"id": "CRITERION-RESTATEMENT",
|
"id": "CRITERION-RESTATEMENT",
|
||||||
"criterionId": "RM02-MEANING-PROVENANCE",
|
"criterionId": "RM02-MEANING-PROVENANCE",
|
||||||
"caseRef": "checkout-preflight/missing-meaning-provenance"
|
"caseRef": "checkout-preflight/missing-meaning-provenance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "INVENTORY-DRIFT-BOUNDARY",
|
||||||
|
"criterionId": "RM02-NONEMPTY-ANCHORED-QUANTIFICATION",
|
||||||
|
"caseRef": "checkout-preflight/inventory-claim-overstatement"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"compatibilityScenarios": [
|
"compatibilityScenarios": [
|
||||||
@@ -305,11 +416,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mergeAssertions": {
|
"mergeAssertions": {
|
||||||
"mode": "unprivileged-current-tree-pr-verification",
|
"mode": "unprivileged-current-tree-verification-with-history-provenance-excluded",
|
||||||
"deferredReplayOwner": "RM-60",
|
"deferredReplayOwner": "RM-60",
|
||||||
"trustDependencies": ["RM-25", "RM-59", "RM-60"],
|
"trustDependencies": ["RM-60"],
|
||||||
"providerEvidence": "assert retained current-tree terminal-success records for prior commits; report absent, expired, or current-running evidence without inference",
|
"postMergeResponse": "RM-60 defines provider-owned protected execution, quarantine, and revert behavior"
|
||||||
"postMergeResponse": "protected isolated replay is detection, not prevention; quarantine and revert on failure"
|
|
||||||
},
|
},
|
||||||
"gates": [
|
"gates": [
|
||||||
{
|
{
|
||||||
@@ -341,7 +451,10 @@
|
|||||||
"actual": {
|
"actual": {
|
||||||
"exitCode": 0
|
"exitCode": 0
|
||||||
},
|
},
|
||||||
"reasonPattern": ""
|
"reasonPattern": "",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-typecheck"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "type-error",
|
"id": "type-error",
|
||||||
@@ -366,6 +479,9 @@
|
|||||||
"content": "export const gateTypeError: string = 42;\n"
|
"content": "export const gateTypeError: string = 42;\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-typecheck"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -399,7 +515,10 @@
|
|||||||
"actual": {
|
"actual": {
|
||||||
"exitCode": 0
|
"exitCode": 0
|
||||||
},
|
},
|
||||||
"reasonPattern": ""
|
"reasonPattern": "",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-lint"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "invalid-syntax",
|
"id": "invalid-syntax",
|
||||||
@@ -424,6 +543,9 @@
|
|||||||
"content": "export const = ;\n"
|
"content": "export const = ;\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-lint"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -457,7 +579,10 @@
|
|||||||
"actual": {
|
"actual": {
|
||||||
"exitCode": 0
|
"exitCode": 0
|
||||||
},
|
},
|
||||||
"reasonPattern": ""
|
"reasonPattern": "",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-format"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "unformatted-json",
|
"id": "unformatted-json",
|
||||||
@@ -479,6 +604,9 @@
|
|||||||
"content": "{\"bad\":true,\"spacing\":[1,2,3]}\n"
|
"content": "{\"bad\":true,\"spacing\":[1,2,3]}\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "quality-format"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -514,7 +642,10 @@
|
|||||||
"exitCode": 0,
|
"exitCode": 0,
|
||||||
"outputPattern": "checkout preflight passed"
|
"outputPattern": "checkout preflight passed"
|
||||||
},
|
},
|
||||||
"reasonPattern": ""
|
"reasonPattern": "",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "stale-build-lock",
|
"id": "stale-build-lock",
|
||||||
@@ -536,6 +667,9 @@
|
|||||||
"content": "negative control\n"
|
"content": "negative control\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -561,6 +695,9 @@
|
|||||||
"replace": " gate-verify:\n image: *node_image\n privileged: true\n"
|
"replace": " gate-verify:\n image: *node_image\n privileged: true\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -586,11 +723,7 @@
|
|||||||
},
|
},
|
||||||
"reasonPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound",
|
"reasonPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound",
|
||||||
"fixture": {
|
"fixture": {
|
||||||
"copyPaths": [
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
"gates/gates.manifest.json",
|
|
||||||
"scripts/gate-verify.mjs",
|
|
||||||
"scripts/gate-history.mjs"
|
|
||||||
],
|
|
||||||
"replaceFiles": [
|
"replaceFiles": [
|
||||||
{
|
{
|
||||||
"path": "gates/gates.manifest.json",
|
"path": "gates/gates.manifest.json",
|
||||||
@@ -603,6 +736,9 @@
|
|||||||
"replace": "\"criterionIds\": [\"RM02-CHECK-RIGHT\"],"
|
"replace": "\"criterionIds\": [\"RM02-CHECK-RIGHT\"],"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -628,11 +764,7 @@
|
|||||||
},
|
},
|
||||||
"reasonPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance",
|
"reasonPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance",
|
||||||
"fixture": {
|
"fixture": {
|
||||||
"copyPaths": [
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
"gates/gates.manifest.json",
|
|
||||||
"scripts/gate-verify.mjs",
|
|
||||||
"scripts/gate-history.mjs"
|
|
||||||
],
|
|
||||||
"replaceFiles": [
|
"replaceFiles": [
|
||||||
{
|
{
|
||||||
"path": "gates/gates.manifest.json",
|
"path": "gates/gates.manifest.json",
|
||||||
@@ -640,6 +772,9 @@
|
|||||||
"replace": "\"currentText\": \"A restated criterion changed without provenance\","
|
"replace": "\"currentText\": \"A restated criterion changed without provenance\","
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -665,11 +800,7 @@
|
|||||||
},
|
},
|
||||||
"reasonPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL",
|
"reasonPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL",
|
||||||
"fixture": {
|
"fixture": {
|
||||||
"copyPaths": [
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
"gates/gates.manifest.json",
|
|
||||||
"scripts/gate-verify.mjs",
|
|
||||||
"scripts/gate-history.mjs"
|
|
||||||
],
|
|
||||||
"replaceFiles": [
|
"replaceFiles": [
|
||||||
{
|
{
|
||||||
"path": "gates/gates.manifest.json",
|
"path": "gates/gates.manifest.json",
|
||||||
@@ -677,6 +808,255 @@
|
|||||||
"replace": "\"caseRef\": \"quality-typecheck/type-error\""
|
"replace": "\"caseRef\": \"quality-typecheck/type-error\""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "misspelled-outcome-field",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": [
|
||||||
|
"node",
|
||||||
|
"scripts/gate-verify.mjs",
|
||||||
|
"--root",
|
||||||
|
".",
|
||||||
|
"--manifest",
|
||||||
|
"gates/gates.manifest.json",
|
||||||
|
"--structure-only"
|
||||||
|
],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required: unknown field outputPatern"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required: unknown field outputPatern"
|
||||||
|
},
|
||||||
|
"reasonPattern": "required: unknown field outputPatern",
|
||||||
|
"fixture": {
|
||||||
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
|
"replaceFiles": [
|
||||||
|
{
|
||||||
|
"path": "gates/gates.manifest.json",
|
||||||
|
"find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":",
|
||||||
|
"replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPatern\": \"checkout preflight passed\"\n },\n \"actual\":"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wrong-outcome-field-type",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": [
|
||||||
|
"node",
|
||||||
|
"scripts/gate-verify.mjs",
|
||||||
|
"--root",
|
||||||
|
".",
|
||||||
|
"--manifest",
|
||||||
|
"gates/gates.manifest.json",
|
||||||
|
"--structure-only"
|
||||||
|
],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required.exitCode: expected an integer"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required.exitCode: expected an integer"
|
||||||
|
},
|
||||||
|
"reasonPattern": "required.exitCode: expected an integer",
|
||||||
|
"fixture": {
|
||||||
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
|
"replaceFiles": [
|
||||||
|
{
|
||||||
|
"path": "gates/gates.manifest.json",
|
||||||
|
"find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":",
|
||||||
|
"replace": "\"required\": {\n \"exitCode\": \"0\",\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty-outcome-pattern",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": [
|
||||||
|
"node",
|
||||||
|
"scripts/gate-verify.mjs",
|
||||||
|
"--root",
|
||||||
|
".",
|
||||||
|
"--manifest",
|
||||||
|
"gates/gates.manifest.json",
|
||||||
|
"--structure-only"
|
||||||
|
],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required.outputPattern: expected a non-empty pattern"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "required.outputPattern: expected a non-empty pattern"
|
||||||
|
},
|
||||||
|
"reasonPattern": "required.outputPattern: expected a non-empty pattern",
|
||||||
|
"fixture": {
|
||||||
|
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||||
|
"replaceFiles": [
|
||||||
|
{
|
||||||
|
"path": "gates/gates.manifest.json",
|
||||||
|
"find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":",
|
||||||
|
"replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \" \"\n },\n \"actual\":"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "empty-registry-populations",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-empty-population-control.mjs"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "empty universally quantified registry populations rejected"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "empty universally quantified registry populations rejected"
|
||||||
|
},
|
||||||
|
"reasonPattern": "empty universally quantified registry populations rejected",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "all-gates-evidence-subject-bound",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-EVIDENCE-SUBJECT-BINDING"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-population-control.mjs", "evidence-subject"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "evidence-subject population control rejected every registered gate"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "evidence-subject population control rejected every registered gate"
|
||||||
|
},
|
||||||
|
"reasonPattern": "evidence-subject population control rejected every registered gate",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "all-gates-type-strict",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-population-control.mjs", "type-strict"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "type-strict population control rejected every registered gate"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 1,
|
||||||
|
"outputPattern": "type-strict population control rejected every registered gate"
|
||||||
|
},
|
||||||
|
"reasonPattern": "type-strict population control rejected every registered gate",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "history-provenance-exclusion",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-PROVENANCE-EXCLUDED"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-history-exclusion-control.mjs"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 79,
|
||||||
|
"outputPattern": "HISTORY_PROVENANCE_FORBIDDEN"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 79,
|
||||||
|
"outputPattern": "HISTORY_PROVENANCE_FORBIDDEN"
|
||||||
|
},
|
||||||
|
"reasonPattern": "HISTORY_PROVENANCE_FORBIDDEN",
|
||||||
|
"fixture": {
|
||||||
|
"copyPaths": ["scripts/gate-history-exclusion-control.mjs", "scripts/gate-verify.mjs"],
|
||||||
|
"replaceFiles": [
|
||||||
|
{
|
||||||
|
"path": "scripts/gate-verify.mjs",
|
||||||
|
"find": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^META-NEGATIVE-CONTROL /,",
|
||||||
|
"replace": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inventory-source-and-manifest-shrink",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-inventory-shrink-control.mjs"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 83,
|
||||||
|
"outputPattern": "INVENTORY_SHRINK_REJECTED"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 83,
|
||||||
|
"outputPattern": "INVENTORY_SHRINK_REJECTED"
|
||||||
|
},
|
||||||
|
"reasonPattern": "INVENTORY_SHRINK_REJECTED",
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inventory-claim-overstatement",
|
||||||
|
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"],
|
||||||
|
"mustFail": true,
|
||||||
|
"invocation": ["node", "scripts/gate-inventory-claim-control.mjs"],
|
||||||
|
"required": {
|
||||||
|
"exitCode": 84,
|
||||||
|
"outputPattern": "INVENTORY_CLAIM_OVERSTATED"
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"exitCode": 84,
|
||||||
|
"outputPattern": "INVENTORY_CLAIM_OVERSTATED"
|
||||||
|
},
|
||||||
|
"reasonPattern": "INVENTORY_CLAIM_OVERSTATED",
|
||||||
|
"fixture": {
|
||||||
|
"copyPaths": [
|
||||||
|
"scripts/gate-inventory-claim-control.mjs",
|
||||||
|
"gates/required-gates.baseline.json",
|
||||||
|
"gates/gates.manifest.json",
|
||||||
|
"docs/PRD.md",
|
||||||
|
"docs/ADMIN-GUIDE/quality-gate-registry.md",
|
||||||
|
"docs/DEVELOPER-GUIDE/quality-gate-registry.md",
|
||||||
|
"docs/remediation/GATE-CLAIMS.md",
|
||||||
|
"docs/plans/2026-08-01-rm-02-gate-registry.md",
|
||||||
|
"docs/SITEMAP.md"
|
||||||
|
],
|
||||||
|
"replaceFiles": [
|
||||||
|
{
|
||||||
|
"path": "gates/required-gates.baseline.json",
|
||||||
|
"find": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||||
|
"replace": "Provides an independent seven-gate baseline comparison."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "checkout-preflight"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -762,6 +1142,9 @@
|
|||||||
"defect": {
|
"defect": {
|
||||||
"owner": "RM-03",
|
"owner": "RM-03",
|
||||||
"reason": "The status classifier consumes its Python program from stdin, so piped provider JSON is not read and even terminal success becomes unknown."
|
"reason": "The status classifier consumes its Python program from stdin, so piped provider JSON is not read and even terminal success becomes unknown."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -824,6 +1207,9 @@
|
|||||||
"defect": {
|
"defect": {
|
||||||
"owner": "RM-03",
|
"owner": "RM-03",
|
||||||
"reason": "The status classifier does not read the provider payload, so --require-status never reaches no-status."
|
"reason": "The status classifier does not read the provider payload, so --require-status never reaches no-status."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -873,6 +1259,9 @@
|
|||||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -922,6 +1311,9 @@
|
|||||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -971,6 +1363,9 @@
|
|||||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1030,6 +1425,9 @@
|
|||||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1053,6 +1451,9 @@
|
|||||||
"fixture": {
|
"fixture": {
|
||||||
"copyPaths": ["packages/mosaic/framework/tools/git"],
|
"copyPaths": ["packages/mosaic/framework/tools/git"],
|
||||||
"writeFiles": []
|
"writeFiles": []
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "ci-queue-wait"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1101,6 +1502,9 @@
|
|||||||
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "hook-pre-commit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1129,6 +1533,9 @@
|
|||||||
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "hook-pre-commit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1177,6 +1584,9 @@
|
|||||||
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "hook-pre-push"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1205,6 +1615,9 @@
|
|||||||
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"subject": "hook-pre-push"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"purpose": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.",
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"id": "quality-typecheck",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality-lint",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality-format",
|
||||||
|
"source": "package.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkout-preflight",
|
||||||
|
"source": "scripts/preflight.mjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ci-queue-wait",
|
||||||
|
"source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hook-pre-commit",
|
||||||
|
"source": ".husky/pre-commit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hook-pre-push",
|
||||||
|
"source": ".husky/pre-push"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path.
|
|||||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||||
|
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||||
|
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||||
|
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||||
|
commit binding is a hard refusal. The verifier's sole interim
|
||||||
|
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||||
|
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||||
|
sanctioned merge path.
|
||||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||||
and `pr-ci-wait.sh`.
|
and `pr-ci-wait.sh`.
|
||||||
|
|||||||
@@ -868,6 +868,38 @@ steps:
|
|||||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||||
|
|
||||||
|
## Terminal-Green Full-Step Contract
|
||||||
|
|
||||||
|
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||||
|
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||||
|
--expect-commit "$PR_HEAD" -
|
||||||
|
```
|
||||||
|
|
||||||
|
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||||
|
|
||||||
|
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||||
|
|
||||||
|
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||||
|
|
||||||
|
Only this exact conjunction is exempted:
|
||||||
|
|
||||||
|
- pipeline and workflow state are `success`;
|
||||||
|
- exactly one non-success child exists;
|
||||||
|
- its name is `ci-postgres` and type is `service`;
|
||||||
|
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||||
|
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||||
|
|
||||||
|
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||||
|
|
||||||
|
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||||
|
|
||||||
|
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||||
|
|
||||||
## Post-Merge CI Monitoring (Hard Rule)
|
## Post-Merge CI Monitoring (Hard Rule)
|
||||||
|
|
||||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||||
|
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||||
|
|
||||||
## Common Options
|
## Common Options
|
||||||
|
|
||||||
@@ -59,4 +60,9 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
# Block until one or more pipelines finish (event-driven CI wait)
|
# Block until one or more pipelines finish (event-driven CI wait)
|
||||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||||
|
|
||||||
|
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||||
```
|
```
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first contract harness for RM-61 / #1000.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||||
|
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
write_fixture() {
|
||||||
|
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||||
|
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||||
|
steps = [
|
||||||
|
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||||
|
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||||
|
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||||
|
]
|
||||||
|
json.dump({
|
||||||
|
"number": 9999,
|
||||||
|
"status": pipeline_status,
|
||||||
|
"commit": "a" * 40,
|
||||||
|
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||||
|
}, open(path, "w"))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_exit() {
|
||||||
|
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||||
|
set +e
|
||||||
|
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||||
|
actual=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||||
|
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'PASS %s\n' "$label"
|
||||||
|
printf '%s' "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ordinary terminal green.
|
||||||
|
write_fixture "$TMP/green.json" success success 0 '' success
|
||||||
|
out=$(expect_exit 0 green "$TMP/green.json")
|
||||||
|
grep -q '"total_steps": 3' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 0' <<<"$out"
|
||||||
|
|
||||||
|
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||||
|
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||||
|
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||||
|
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||||
|
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 1' <<<"$out"
|
||||||
|
|
||||||
|
# Negative controls: both real PostgreSQL failures must remain red.
|
||||||
|
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||||
|
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||||
|
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||||
|
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||||
|
|
||||||
|
# The exemption is signature-scoped, not step-scoped.
|
||||||
|
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||||
|
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||||
|
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||||
|
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||||
|
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||||
|
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||||
|
|
||||||
|
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||||
|
changed = json.loads(json.dumps(record))
|
||||||
|
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||||
|
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||||
|
PY
|
||||||
|
for label in false true float string null; do
|
||||||
|
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||||
|
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||||
|
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||||
|
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||||
|
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||||
|
|
||||||
|
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||||
|
set +e
|
||||||
|
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||||
|
missing_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||||
|
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||||
|
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
record.pop("commit")
|
||||||
|
json.dump(record, open(sys.argv[2], "w"))
|
||||||
|
PY
|
||||||
|
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||||
|
|
||||||
|
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
|
||||||
|
|
||||||
|
RM-61 permits one named, signature-scoped exception for issue #1000. The
|
||||||
|
exception retires when #1000 is fixed; all other non-success states block.
|
||||||
|
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
|
||||||
|
It does not fetch, retry, or re-trigger pipelines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
|
||||||
|
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
|
||||||
|
POD_NOT_FOUND = re.compile(
|
||||||
|
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fail_usage(message: str) -> int:
|
||||||
|
print(f"terminal-green contract input error: {message}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def load_record(argument: str | None) -> dict[str, Any]:
|
||||||
|
if argument in (None, "-"):
|
||||||
|
value = json.load(sys.stdin)
|
||||||
|
else:
|
||||||
|
with Path(argument).open(encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("pipeline record must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
|
||||||
|
error = step.get("error")
|
||||||
|
exit_code = step.get("exit_code")
|
||||||
|
return (
|
||||||
|
step.get("name") == "ci-postgres"
|
||||||
|
and step.get("type") == "service"
|
||||||
|
and step.get("state") == "failure"
|
||||||
|
and type(exit_code) is int
|
||||||
|
and not isinstance(exit_code, bool)
|
||||||
|
and exit_code == 0
|
||||||
|
and isinstance(error, str)
|
||||||
|
and POD_NOT_FOUND.fullmatch(error) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
|
||||||
|
anomalies: list[dict[str, Any]] = []
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
pipeline_status = record.get("status")
|
||||||
|
actual_commit = record.get("commit")
|
||||||
|
if actual_commit != expected_commit:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "pipeline commit does not equal the expected PR head",
|
||||||
|
"expected_commit": expected_commit,
|
||||||
|
"actual_commit": actual_commit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if pipeline_status != "success":
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "pipeline status is not success",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
workflows = record.get("workflows")
|
||||||
|
if not isinstance(workflows, list) or not workflows:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "pipeline",
|
||||||
|
"name": str(record.get("number", "unknown")),
|
||||||
|
"state": pipeline_status,
|
||||||
|
"reason": "workflows are missing or empty",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
workflows = []
|
||||||
|
|
||||||
|
for workflow_index, workflow in enumerate(workflows):
|
||||||
|
if not isinstance(workflow, dict):
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": str(workflow_index),
|
||||||
|
"state": None,
|
||||||
|
"reason": "workflow is not an object",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
workflow_name = str(workflow.get("name", workflow_index))
|
||||||
|
if workflow.get("state") != "success":
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": workflow_name,
|
||||||
|
"state": workflow.get("state"),
|
||||||
|
"reason": "workflow state is not success",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
children = workflow.get("children")
|
||||||
|
if not isinstance(children, list) or not children:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "workflow",
|
||||||
|
"name": workflow_name,
|
||||||
|
"state": workflow.get("state"),
|
||||||
|
"reason": "child-step list is missing or empty",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for child_index, child in enumerate(children):
|
||||||
|
if not isinstance(child, dict):
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "step",
|
||||||
|
"name": f"{workflow_name}[{child_index}]",
|
||||||
|
"state": None,
|
||||||
|
"reason": "step is not an object",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
steps.append(child)
|
||||||
|
if child.get("state") == "success":
|
||||||
|
continue
|
||||||
|
if is_issue_1000_artifact(child):
|
||||||
|
candidates.append(child)
|
||||||
|
continue
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "step",
|
||||||
|
"name": child.get("name"),
|
||||||
|
"type": child.get("type"),
|
||||||
|
"state": child.get("state"),
|
||||||
|
"exit_code": child.get("exit_code"),
|
||||||
|
"error": child.get("error"),
|
||||||
|
"reason": "non-success step does not match the #1000 teardown signature",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(candidates) > 1:
|
||||||
|
anomalies.append(
|
||||||
|
{
|
||||||
|
"scope": "exemption",
|
||||||
|
"name": EXEMPTION_ID,
|
||||||
|
"state": "invalid",
|
||||||
|
"reason": "the #1000 exemption may apply to exactly one step",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
exemption_applies = len(candidates) == 1 and not anomalies
|
||||||
|
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"schema_version": "mosaic-terminal-green/v1",
|
||||||
|
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
|
||||||
|
"pipeline_number": record.get("number"),
|
||||||
|
"commit": actual_commit,
|
||||||
|
"expected_commit": expected_commit,
|
||||||
|
"pipeline_status": pipeline_status,
|
||||||
|
"total_steps": len(steps),
|
||||||
|
"state_counts": dict(sorted(state_counts.items())),
|
||||||
|
"exempted_steps": 1 if exemption_applies else 0,
|
||||||
|
"anomalies": anomalies,
|
||||||
|
}
|
||||||
|
if exemption_applies:
|
||||||
|
candidate = candidates[0]
|
||||||
|
result["exemptions"] = [
|
||||||
|
{
|
||||||
|
"exemption_id": EXEMPTION_ID,
|
||||||
|
"step": candidate.get("name"),
|
||||||
|
"signature": candidate.get("error"),
|
||||||
|
"tracking_issue": EXEMPTION_ISSUE,
|
||||||
|
"retires_when": "issue #1000 is fixed",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
result["exemptions"] = []
|
||||||
|
|
||||||
|
return (0 if not anomalies else 1), result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="verify the full Woodpecker terminal-green child-step contract"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--expect-commit",
|
||||||
|
required=True,
|
||||||
|
metavar="FULL_SHA",
|
||||||
|
help="full 40-hex PR-head commit that the pipeline record must match",
|
||||||
|
)
|
||||||
|
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
|
||||||
|
parser.error("--expect-commit must be a full 40-hex commit")
|
||||||
|
arguments.expect_commit = arguments.expect_commit.lower()
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = parse_arguments()
|
||||||
|
try:
|
||||||
|
record = load_record(arguments.record)
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
return fail_usage(str(error))
|
||||||
|
code, result = verify(record, arguments.expect_commit)
|
||||||
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { verifyRegistry } from './gate-verify.mjs';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const manifest = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||||
|
manifest.gateRoots = ['.mosaic-empty-gate-root'];
|
||||||
|
manifest.criteria = [];
|
||||||
|
manifest.gates = [];
|
||||||
|
manifest.proseClaims = [];
|
||||||
|
manifest.compatibilityScenarios = [];
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-empty-population-'));
|
||||||
|
try {
|
||||||
|
const manifestPath = path.join(directory, 'manifest.json');
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||||
|
const result = await verifyRegistry({
|
||||||
|
root,
|
||||||
|
manifest: manifestPath,
|
||||||
|
structureOnly: true,
|
||||||
|
fixtureProfile: false,
|
||||||
|
});
|
||||||
|
const required = ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios'];
|
||||||
|
const missing = required.filter(
|
||||||
|
(population) =>
|
||||||
|
!result.failures.some((failure) =>
|
||||||
|
failure.includes(`${population} population must be non-empty and anchored`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
process.stdout.write(`empty populations were not rejected: ${missing.join(', ')}\n`);
|
||||||
|
process.exitCode = 0;
|
||||||
|
} else {
|
||||||
|
process.stderr.write(
|
||||||
|
'empty universally quantified registry populations rejected before evaluation\n',
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { assertCurrentTreeObservation } from './gate-verify.mjs';
|
||||||
|
|
||||||
|
const prohibitedReports = [
|
||||||
|
'HISTORY PROVENANCE VERIFIED local-ref',
|
||||||
|
'COMMIT ANCESTRY VERIFIED origin/main',
|
||||||
|
'PROVIDER LINEAGE SUCCESS pipeline-7',
|
||||||
|
];
|
||||||
|
const accepted = prohibitedReports.filter((report) => {
|
||||||
|
try {
|
||||||
|
assertCurrentTreeObservation(report);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||||
|
const stdoutWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length;
|
||||||
|
const stderrWrites = [...verifier.matchAll(/process\.stderr\.write\s*\(/g)].length;
|
||||||
|
const gatedErrorWrites = [...verifier.matchAll(/process\.stderr\.write\(`GATE VERIFY FAILED:/g)]
|
||||||
|
.length;
|
||||||
|
const currentTreeWriterCalls = [...verifier.matchAll(/writeCurrentTreeOutput\s*\(/g)].length;
|
||||||
|
const productionRendererWired =
|
||||||
|
stdoutWrites === 1 &&
|
||||||
|
stderrWrites === 2 &&
|
||||||
|
gatedErrorWrites === 2 &&
|
||||||
|
currentTreeWriterCalls === 3 &&
|
||||||
|
/function writeCurrentTreeOutput\(output\) \{\s*assertCurrentTreeObservation\(output\);\s*process\.stdout\.write/s.test(
|
||||||
|
verifier,
|
||||||
|
) &&
|
||||||
|
/for \(const observation of observations\) \{\s*writeCurrentTreeOutput\(observation\);\s*\}/s.test(
|
||||||
|
verifier,
|
||||||
|
) &&
|
||||||
|
/writeCurrentTreeOutput\(\s*`REGISTRY SUMMARY open behavior deltas:/s.test(verifier) &&
|
||||||
|
!/\bconsole\.(?:log|info|debug|error|warn)\s*\(/.test(verifier);
|
||||||
|
|
||||||
|
if (accepted.length > 0 || !productionRendererWired) {
|
||||||
|
process.stderr.write(
|
||||||
|
`HISTORY_PROVENANCE_FORBIDDEN: closed current-tree observation renderer rejected=${prohibitedReports.length - accepted.length}/${prohibitedReports.length} production-wired=${productionRendererWired}\n`,
|
||||||
|
);
|
||||||
|
process.exit(79);
|
||||||
|
}
|
||||||
|
process.stdout.write('history provenance reporting capability is absent; owner RM-60\n');
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
function git(root, args, { allowFailure = false } = {}) {
|
|
||||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
||||||
if (result.status !== 0 && !allowFailure) {
|
|
||||||
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listProspectiveCommits(root, activationCommit, head = 'HEAD') {
|
|
||||||
const result = git(root, [
|
|
||||||
'rev-list',
|
|
||||||
'--first-parent',
|
|
||||||
'--reverse',
|
|
||||||
`${activationCommit}..${head}`,
|
|
||||||
]);
|
|
||||||
return result.stdout.trim() ? result.stdout.trim().split('\n') : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readManifestAtCommit(root, commit) {
|
|
||||||
const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]);
|
|
||||||
return JSON.parse(result.stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotAuthoritativeTree(root) {
|
|
||||||
const snapshot = new Map();
|
|
||||||
async function walk(current) {
|
|
||||||
for (const child of await readdir(current, { withFileTypes: true })) {
|
|
||||||
if (['.git', '.home', 'node_modules'].includes(child.name)) continue;
|
|
||||||
const absolute = path.join(current, child.name);
|
|
||||||
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
||||||
const stats = await lstat(absolute);
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
await walk(absolute);
|
|
||||||
} else if (stats.isSymbolicLink()) {
|
|
||||||
snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`);
|
|
||||||
} else if (stats.isFile()) {
|
|
||||||
const digest = createHash('sha256')
|
|
||||||
.update(await readFile(absolute))
|
|
||||||
.digest('hex');
|
|
||||||
snapshot.set(relative, `file:${stats.mode}:${digest}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(root);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authoritativeTreeChanges(root, snapshot) {
|
|
||||||
const changes = [];
|
|
||||||
for (const [relative, expected] of snapshot) {
|
|
||||||
const absolute = path.join(root, relative);
|
|
||||||
let actual;
|
|
||||||
try {
|
|
||||||
const stats = await lstat(absolute);
|
|
||||||
if (stats.isSymbolicLink()) {
|
|
||||||
actual = `symlink:${stats.mode}:${await readlink(absolute)}`;
|
|
||||||
} else if (stats.isFile()) {
|
|
||||||
const digest = createHash('sha256')
|
|
||||||
.update(await readFile(absolute))
|
|
||||||
.digest('hex');
|
|
||||||
actual = `file:${stats.mode}:${digest}`;
|
|
||||||
} else {
|
|
||||||
actual = `other:${stats.mode}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') throw error;
|
|
||||||
actual = 'missing';
|
|
||||||
}
|
|
||||||
if (actual !== expected) changes.push(relative);
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) {
|
|
||||||
const sandboxArgs = [
|
|
||||||
'--unshare-net',
|
|
||||||
'--unshare-pid',
|
|
||||||
'--unshare-ipc',
|
|
||||||
'--unshare-uts',
|
|
||||||
'--die-with-parent',
|
|
||||||
'--new-session',
|
|
||||||
'--clearenv',
|
|
||||||
];
|
|
||||||
for (const systemPath of ['/usr', '/bin', '/lib', '/lib64', '/etc']) {
|
|
||||||
if (existsSync(systemPath)) sandboxArgs.push('--ro-bind', systemPath, systemPath);
|
|
||||||
}
|
|
||||||
sandboxArgs.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--bind', root, '/work');
|
|
||||||
if (storePath) sandboxArgs.push('--ro-bind', storePath, '/pnpm-store');
|
|
||||||
const corepackHome = path.join(process.env.HOME ?? '', '.cache', 'node', 'corepack');
|
|
||||||
if (existsSync(corepackHome)) sandboxArgs.push('--ro-bind', corepackHome, '/corepack');
|
|
||||||
sandboxArgs.push(
|
|
||||||
'--chdir',
|
|
||||||
'/work',
|
|
||||||
'--setenv',
|
|
||||||
'HOME',
|
|
||||||
'/work/.home',
|
|
||||||
'--setenv',
|
|
||||||
'PATH',
|
|
||||||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
||||||
'--setenv',
|
|
||||||
'LANG',
|
|
||||||
'C.UTF-8',
|
|
||||||
'--setenv',
|
|
||||||
'CI',
|
|
||||||
'true',
|
|
||||||
);
|
|
||||||
if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store');
|
|
||||||
if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack');
|
|
||||||
const enteredMarker = `__MOSAIC_BWRAP_ENTERED_${randomUUID()}__`;
|
|
||||||
sandboxArgs.push(
|
|
||||||
'/bin/sh',
|
|
||||||
'-c',
|
|
||||||
'printf "%s\\n" "$1"; shift; exec "$@"',
|
|
||||||
'mosaic-bwrap-entry',
|
|
||||||
enteredMarker,
|
|
||||||
command,
|
|
||||||
...args,
|
|
||||||
);
|
|
||||||
const result = spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout });
|
|
||||||
const sandboxEntered = result.stdout?.includes(enteredMarker) === true;
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
stdout: (result.stdout ?? '').replace(`${enteredMarker}\n`, ''),
|
|
||||||
sandboxLauncher: 'bwrap',
|
|
||||||
sandboxEntered,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function replayCommit(root, commit) {
|
|
||||||
const replayRoot = await mkdtemp(
|
|
||||||
path.join(path.dirname(root), `.gate-history-${commit.slice(0, 12)}-`),
|
|
||||||
);
|
|
||||||
const archive = `${replayRoot}.tar`;
|
|
||||||
try {
|
|
||||||
git(root, ['archive', '--format=tar', `--output=${archive}`, commit]);
|
|
||||||
const extract = spawnSync('tar', ['-xf', archive, '-C', replayRoot], { encoding: 'utf8' });
|
|
||||||
if (extract.status !== 0) {
|
|
||||||
return { status: extract.status, stdout: extract.stdout, stderr: extract.stderr };
|
|
||||||
}
|
|
||||||
const authoritativeSnapshot = await snapshotAuthoritativeTree(replayRoot);
|
|
||||||
await mkdir(path.join(replayRoot, '.home'), { recursive: true });
|
|
||||||
let storePath;
|
|
||||||
try {
|
|
||||||
await access(path.join(replayRoot, 'package.json'));
|
|
||||||
const init = spawnSync('git', ['init', '--quiet', replayRoot], { encoding: 'utf8' });
|
|
||||||
if (init.status !== 0) return init;
|
|
||||||
const store = spawnSync('pnpm', ['store', 'path'], { encoding: 'utf8' });
|
|
||||||
if (store.status !== 0) return store;
|
|
||||||
storePath = store.stdout.trim();
|
|
||||||
const install = bubblewrap(
|
|
||||||
replayRoot,
|
|
||||||
'pnpm',
|
|
||||||
['install', '--frozen-lockfile', '--offline'],
|
|
||||||
{ storePath, timeout: 600_000 },
|
|
||||||
);
|
|
||||||
if (install.status !== 0 || install.error || install.signal) {
|
|
||||||
return {
|
|
||||||
...install,
|
|
||||||
stderr: `historical frozen dependency install failed: ${install.error?.message || install.stderr || install.stdout || ''}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const authoritativeChanges = await authoritativeTreeChanges(
|
|
||||||
replayRoot,
|
|
||||||
authoritativeSnapshot,
|
|
||||||
);
|
|
||||||
if (authoritativeChanges.length > 0) {
|
|
||||||
return {
|
|
||||||
status: 1,
|
|
||||||
stdout: '',
|
|
||||||
stderr: `authoritative archived file changed during historical install: ${authoritativeChanges.join(', ')}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') throw error;
|
|
||||||
}
|
|
||||||
return bubblewrap(
|
|
||||||
replayRoot,
|
|
||||||
process.execPath,
|
|
||||||
[
|
|
||||||
'/work/scripts/gate-verify.mjs',
|
|
||||||
'--root',
|
|
||||||
'/work',
|
|
||||||
'--manifest',
|
|
||||||
'gates/gates.manifest.json',
|
|
||||||
'--skip-history',
|
|
||||||
],
|
|
||||||
{ storePath },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await rm(archive, { force: true });
|
|
||||||
await rm(replayRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assessProviderEvidence(commit, pipelines) {
|
|
||||||
const matches = pipelines.filter((candidate) => candidate.commit === commit);
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
detail:
|
|
||||||
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']);
|
|
||||||
const stepStates = new Set([
|
|
||||||
'success',
|
|
||||||
'failure',
|
|
||||||
'error',
|
|
||||||
'pending',
|
|
||||||
'running',
|
|
||||||
'queued',
|
|
||||||
'skipped',
|
|
||||||
]);
|
|
||||||
const numbers = matches.map((candidate) => candidate.number);
|
|
||||||
const malformed = matches.some(
|
|
||||||
(candidate) =>
|
|
||||||
typeof candidate.commit !== 'string' ||
|
|
||||||
candidate.commit.length === 0 ||
|
|
||||||
!Number.isInteger(candidate.number) ||
|
|
||||||
!pipelineStates.has(candidate.status) ||
|
|
||||||
!Array.isArray(candidate.steps) ||
|
|
||||||
candidate.steps.some(
|
|
||||||
(step) =>
|
|
||||||
typeof step?.name !== 'string' ||
|
|
||||||
typeof step?.status !== 'string' ||
|
|
||||||
!stepStates.has(step.status),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (malformed || new Set(numbers).size !== numbers.length) {
|
|
||||||
return {
|
|
||||||
state: 'terminal-failure',
|
|
||||||
detail: 'provider records are malformed or have ambiguous pipeline numbers',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const pipeline = [...matches].sort((left, right) => right.number - left.number)[0];
|
|
||||||
const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify');
|
|
||||||
if (gateSteps.length !== 1) {
|
|
||||||
return {
|
|
||||||
state: 'terminal-failure',
|
|
||||||
detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const [gateStep] = gateSteps;
|
|
||||||
if (pipeline.status === 'success' && gateStep.status === 'success') {
|
|
||||||
return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' };
|
|
||||||
}
|
|
||||||
if (['pending', 'running', 'queued'].includes(pipeline.status)) {
|
|
||||||
return { state: 'current-running', detail: `pipeline is ${pipeline.status}` };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: 'terminal-failure',
|
|
||||||
detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProviderEvidence() {
|
|
||||||
const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
|
||||||
if (!evidenceFile) return [];
|
|
||||||
const parsed = JSON.parse(await readFile(evidenceFile, 'utf8'));
|
|
||||||
if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array');
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMainCommit(root, head) {
|
|
||||||
if (process.env.CI_COMMIT_BRANCH === 'main') return true;
|
|
||||||
const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], {
|
|
||||||
allowFailure: true,
|
|
||||||
});
|
|
||||||
return result.status === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyHistory({ root, manifest }) {
|
|
||||||
const failures = [];
|
|
||||||
const observations = [];
|
|
||||||
const head = git(root, ['rev-parse', 'HEAD']).stdout.trim();
|
|
||||||
if (!manifest.activationCommit) {
|
|
||||||
failures.push('history activationCommit is missing');
|
|
||||||
return { failures, observations };
|
|
||||||
}
|
|
||||||
const activationCheck = git(
|
|
||||||
root,
|
|
||||||
['merge-base', '--is-ancestor', manifest.activationCommit, head],
|
|
||||||
{ allowFailure: true },
|
|
||||||
);
|
|
||||||
if (activationCheck.status !== 0) {
|
|
||||||
failures.push(
|
|
||||||
`history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`,
|
|
||||||
);
|
|
||||||
return { failures, observations };
|
|
||||||
}
|
|
||||||
const onMain = isMainCommit(root, head);
|
|
||||||
// RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions:
|
|
||||||
// DOES: run every registered current-tree gate and declared inerting mutation on PR CI,
|
|
||||||
// unprivileged and fail-closed.
|
|
||||||
// DOES NOT: execute a commit's own verifier in an isolated PR replay. PR-controlled code would
|
|
||||||
// otherwise need the namespace capability intended to contain that same code. That external
|
|
||||||
// trust boundary must be runner/provider-owned before any PR executable or config is evaluated.
|
|
||||||
observations.push(
|
|
||||||
`RM-02 EXECUTION BOUNDARY ${head}: DOES: verify the current tree and declared inerting mutations on every PR, unprivileged and fail-closed; DOES NOT: execute isolated per-commit verifier replay in repository-controlled CI; owner RM-60, cross-reference RM-59`,
|
|
||||||
);
|
|
||||||
if (!onMain) {
|
|
||||||
observations.push(
|
|
||||||
`PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; retained provider evidence starts after merge and no replay success is inferred`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipelines = onMain ? await loadProviderEvidence() : [];
|
|
||||||
const commits = await listProspectiveCommits(root, manifest.activationCommit, head);
|
|
||||||
for (const commit of commits) {
|
|
||||||
let commitManifest;
|
|
||||||
try {
|
|
||||||
commitManifest = await readManifestAtCommit(root, commit);
|
|
||||||
} catch (error) {
|
|
||||||
failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (commitManifest.schemaVersion !== manifest.schemaVersion) {
|
|
||||||
failures.push(`${commit}: own-tree registry schema is not supported`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const evidence = assessProviderEvidence(commit, pipelines);
|
|
||||||
if (commit === head) {
|
|
||||||
observations.push(
|
|
||||||
`CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
observations.push(
|
|
||||||
`INTERMEDIATE REPLAY DEFERRED ${commit}: isolated own-tree execution is not performed by repository-controlled CI; owner RM-60, cross-reference RM-59; no success is inferred`,
|
|
||||||
);
|
|
||||||
if (!onMain) {
|
|
||||||
observations.push(
|
|
||||||
`PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
observations.push(
|
|
||||||
`POST-MERGE DETECTION BOUNDARY ${commit}: protected isolated replay awaits RM-60; when available, a failure requires quarantine/revert and is detection, not pre-merge prevention`,
|
|
||||||
);
|
|
||||||
if (evidence.state === 'terminal-failure') {
|
|
||||||
failures.push(
|
|
||||||
`${commit}: retained provider evidence is not terminal-success (${evidence.detail})`,
|
|
||||||
);
|
|
||||||
} else if (evidence.state === 'terminal-success') {
|
|
||||||
observations.push(`PROVIDER EVIDENCE ${commit}: terminal-success (${evidence.detail})`);
|
|
||||||
} else {
|
|
||||||
observations.push(
|
|
||||||
`PROVIDER EVIDENCE ${commit}: ${evidence.state.toUpperCase()} (${evidence.detail}); no merge-time success is inferred`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { failures, observations };
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
assessProviderEvidence,
|
|
||||||
listProspectiveCommits,
|
|
||||||
readManifestAtCommit,
|
|
||||||
replayCommit,
|
|
||||||
verifyHistory,
|
|
||||||
} from './gate-history.mjs';
|
|
||||||
|
|
||||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`);
|
|
||||||
|
|
||||||
function sandboxUnavailable(result) {
|
|
||||||
if (result.sandboxLauncher !== 'bwrap' || result.sandboxEntered === true) return false;
|
|
||||||
const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`;
|
|
||||||
const bubblewrapSpawnDenied =
|
|
||||||
['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) &&
|
|
||||||
/spawnSync bwrap/i.test(result.error?.message ?? '');
|
|
||||||
if (
|
|
||||||
!bubblewrapSpawnDenied &&
|
|
||||||
!/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function git(root, ...args) {
|
|
||||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
return result.stdout.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitManifest(root, marker) {
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'gates', 'gates.manifest.json'),
|
|
||||||
`${JSON.stringify({ schemaVersion: 1, marker })}\n`,
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', marker);
|
|
||||||
return git(root, 'rev-parse', 'HEAD');
|
|
||||||
}
|
|
||||||
|
|
||||||
test.after(async () => {
|
|
||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sandbox refusal classification requires Bubblewrap provenance', () => {
|
|
||||||
for (const code of ['EPERM', 'EACCES', 'ENOENT']) {
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({
|
|
||||||
status: null,
|
|
||||||
error: { code, message: `spawnSync bwrap ${code}` },
|
|
||||||
sandboxLauncher: 'bwrap',
|
|
||||||
sandboxEntered: false,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({
|
|
||||||
status: null,
|
|
||||||
error: { code: 'EPERM', message: 'spawnSync git EPERM' },
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({
|
|
||||||
status: 1,
|
|
||||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
|
||||||
sandboxLauncher: 'bwrap',
|
|
||||||
sandboxEntered: false,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({
|
|
||||||
status: 1,
|
|
||||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
sandboxUnavailable({
|
|
||||||
status: 1,
|
|
||||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
|
||||||
sandboxLauncher: 'bwrap',
|
|
||||||
sandboxEntered: true,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prospective history reads each commit own manifest rather than the current tree', async () => {
|
|
||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
|
||||||
await mkdir(fixtureRoot, { recursive: true });
|
|
||||||
git(fixtureRoot, 'init', '-q');
|
|
||||||
git(fixtureRoot, 'config', 'user.name', 'gate-test');
|
|
||||||
git(fixtureRoot, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(fixtureRoot, 'activation.txt'), 'activation\n');
|
|
||||||
git(fixtureRoot, 'add', '.');
|
|
||||||
git(fixtureRoot, 'commit', '-m', 'activation');
|
|
||||||
const activation = git(fixtureRoot, 'rev-parse', 'HEAD');
|
|
||||||
const first = await commitManifest(fixtureRoot, 'FIRST');
|
|
||||||
const second = await commitManifest(fixtureRoot, 'SECOND');
|
|
||||||
|
|
||||||
assert.deepEqual(await listProspectiveCommits(fixtureRoot, activation, second), [first, second]);
|
|
||||||
assert.equal((await readManifestAtCommit(fixtureRoot, first)).marker, 'FIRST');
|
|
||||||
assert.equal((await readManifestAtCommit(fixtureRoot, second)).marker, 'SECOND');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historical replay executes each selected commit verifier from that commit tree', async () => {
|
|
||||||
const root = `${fixtureRoot}-replay`;
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
git(root, 'init', '-q');
|
|
||||||
git(root, 'config', 'user.name', 'gate-test');
|
|
||||||
git(root, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"process.stderr.write('OLD TREE INERT\\n'); process.exitCode = 1;\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'inert historical verifier');
|
|
||||||
const inert = git(root, 'rev-parse', 'HEAD');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"process.stdout.write('NEW TREE VERIFIED\\n');\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'fixed historical verifier');
|
|
||||||
const fixed = git(root, 'rev-parse', 'HEAD');
|
|
||||||
|
|
||||||
const inertResult = await replayCommit(root, inert);
|
|
||||||
const fixedResult = await replayCommit(root, fixed);
|
|
||||||
if (sandboxUnavailable(inertResult) || sandboxUnavailable(fixedResult)) return;
|
|
||||||
assert.notEqual(inertResult.status, 0);
|
|
||||||
assert.match(inertResult.stderr, /OLD TREE INERT/);
|
|
||||||
assert.equal(fixedResult.status, 0);
|
|
||||||
assert.match(fixedResult.stdout, /NEW TREE VERIFIED/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historical install lifecycle cannot replace an authoritative verifier', async () => {
|
|
||||||
const root = `${fixtureRoot}-install-tamper`;
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
git(root, 'init', '-q');
|
|
||||||
git(root, 'config', 'user.name', 'gate-test');
|
|
||||||
git(root, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"process.stderr.write('ORIGINAL VERIFIER RAN\\n'); process.exitCode = 7;\n",
|
|
||||||
);
|
|
||||||
await writeFile(path.join(root, 'forged.mjs'), "process.stdout.write('FORGED SUCCESS\\n');\n");
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'package.json'),
|
|
||||||
`${JSON.stringify({
|
|
||||||
name: 'historical-install-tamper',
|
|
||||||
version: '1.0.0',
|
|
||||||
scripts: { postinstall: 'cp forged.mjs scripts/gate-verify.mjs' },
|
|
||||||
})}\n`,
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'pnpm-lock.yaml'),
|
|
||||||
"lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'tampering lifecycle fixture');
|
|
||||||
const commit = git(root, 'rev-parse', 'HEAD');
|
|
||||||
|
|
||||||
const result = await replayCommit(root, commit);
|
|
||||||
assert.notEqual(result.status, 0);
|
|
||||||
if (sandboxUnavailable(result)) return;
|
|
||||||
assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i);
|
|
||||||
assert.doesNotMatch(result.stdout, /FORGED SUCCESS/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historical verifier receives no current-process secret environment', async () => {
|
|
||||||
const root = `${fixtureRoot}-secretless`;
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
git(root, 'init', '-q');
|
|
||||||
git(root, 'config', 'user.name', 'gate-test');
|
|
||||||
git(root, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"if (process.env.REPLAY_SENTINEL) { process.stderr.write('SECRET LEAKED\\n'); process.exitCode = 9; } else { process.stdout.write('SECRETLESS\\n'); }\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'secretless replay fixture');
|
|
||||||
const commit = git(root, 'rev-parse', 'HEAD');
|
|
||||||
|
|
||||||
process.env.REPLAY_SENTINEL = 'must-not-cross-boundary';
|
|
||||||
try {
|
|
||||||
const result = await replayCommit(root, commit);
|
|
||||||
if (sandboxUnavailable(result)) return;
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
assert.match(result.stdout, /SECRETLESS/);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
`${result.stdout}${result.stderr}`,
|
|
||||||
/SECRET LEAKED|must-not-cross-boundary/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
delete process.env.REPLAY_SENTINEL;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historical replay cannot observe a sibling process in the runner PID namespace', async () => {
|
|
||||||
const root = `${fixtureRoot}-pidless`;
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
git(root, 'init', '-q');
|
|
||||||
git(root, 'config', 'user.name', 'gate-test');
|
|
||||||
git(root, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
||||||
|
|
||||||
const sleeper = spawn('sleep', ['30'], {
|
|
||||||
env: { ...process.env, REPLAY_PID_SENTINEL: 'must-not-be-visible' },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
`import { existsSync } from 'node:fs';\nif (existsSync('/proc/${sleeper.pid}/environ')) { process.stderr.write('HOST PID VISIBLE\\n'); process.exitCode = 9; } else { process.stdout.write('PIDLESS\\n'); }\n`,
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'pid-isolated replay fixture');
|
|
||||||
const commit = git(root, 'rev-parse', 'HEAD');
|
|
||||||
const result = await replayCommit(root, commit);
|
|
||||||
if (sandboxUnavailable(result)) return;
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
|
||||||
assert.match(result.stdout, /PIDLESS/);
|
|
||||||
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/);
|
|
||||||
} finally {
|
|
||||||
sleeper.kill('SIGTERM');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PR verification states the RM-60 boundary without executing an intermediate verifier', async () => {
|
|
||||||
const root = `${fixtureRoot}-feature`;
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
await mkdir(root, { recursive: true });
|
|
||||||
git(root, 'init', '-q');
|
|
||||||
git(root, 'config', 'user.name', 'gate-test');
|
|
||||||
git(root, 'config', 'user.email', '[email protected]');
|
|
||||||
await writeFile(path.join(root, 'activation.txt'), 'activation\n');
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'activation');
|
|
||||||
const activation = git(root, 'rev-parse', 'HEAD');
|
|
||||||
git(root, 'update-ref', 'refs/remotes/origin/main', activation);
|
|
||||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
|
||||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
|
||||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"process.stderr.write('INTERMEDIATE INERT\\n'); process.exitCode = 1;\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'inert intermediate');
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
|
||||||
"process.stdout.write('HEAD HEALTHY\\n');\n",
|
|
||||||
);
|
|
||||||
git(root, 'add', '.');
|
|
||||||
git(root, 'commit', '-m', 'healthy head');
|
|
||||||
|
|
||||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
|
||||||
process.env.CI_COMMIT_BRANCH = 'feature/rm-02';
|
|
||||||
try {
|
|
||||||
const result = await verifyHistory({
|
|
||||||
root,
|
|
||||||
manifest: { schemaVersion: 1, activationCommit: activation },
|
|
||||||
});
|
|
||||||
assert.deepEqual(result.failures, []);
|
|
||||||
assert.ok(
|
|
||||||
result.observations.some((observation) =>
|
|
||||||
/DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
result.observations.some((observation) =>
|
|
||||||
/INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation)));
|
|
||||||
} finally {
|
|
||||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
|
||||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('provider evidence distinguishes retained success, failure, and absent history', () => {
|
|
||||||
const pipelines = [
|
|
||||||
{
|
|
||||||
commit: 'aaa',
|
|
||||||
number: 1,
|
|
||||||
status: 'success',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
commit: 'bbb',
|
|
||||||
number: 2,
|
|
||||||
status: 'failure',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
assert.deepEqual(assessProviderEvidence('aaa', pipelines), {
|
|
||||||
state: 'terminal-success',
|
|
||||||
detail: 'pipeline and gate-verify step succeeded',
|
|
||||||
});
|
|
||||||
assert.equal(assessProviderEvidence('bbb', pipelines).state, 'terminal-failure');
|
|
||||||
assert.equal(assessProviderEvidence('ccc', pipelines).state, 'absent');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('duplicate gate-verify steps cannot establish provider success', () => {
|
|
||||||
const result = assessProviderEvidence('aaa', [
|
|
||||||
{
|
|
||||||
commit: 'aaa',
|
|
||||||
number: 7,
|
|
||||||
status: 'success',
|
|
||||||
steps: [
|
|
||||||
{ name: 'gate-verify', status: 'success' },
|
|
||||||
{ name: 'gate-verify', status: 'failure' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert.equal(result.state, 'terminal-failure');
|
|
||||||
assert.match(result.detail, /ambiguous.*gate-verify/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a malformed single provider record cannot establish success', () => {
|
|
||||||
assert.equal(
|
|
||||||
assessProviderEvidence('aaa', [
|
|
||||||
{ commit: 'aaa', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
|
||||||
]).state,
|
|
||||||
'terminal-failure',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
assessProviderEvidence('bbb', [
|
|
||||||
{
|
|
||||||
commit: 'bbb',
|
|
||||||
number: 1,
|
|
||||||
status: 'surprising',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
||||||
},
|
|
||||||
]).state,
|
|
||||||
'terminal-failure',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('provider evidence selects the highest numbered rerun deterministically', () => {
|
|
||||||
const failedThenSucceeded = [
|
|
||||||
{
|
|
||||||
commit: 'aaa',
|
|
||||||
number: 10,
|
|
||||||
status: 'failure',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
commit: 'aaa',
|
|
||||||
number: 11,
|
|
||||||
status: 'success',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const succeededThenFailed = [
|
|
||||||
{
|
|
||||||
commit: 'bbb',
|
|
||||||
number: 21,
|
|
||||||
status: 'success',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
commit: 'bbb',
|
|
||||||
number: 22,
|
|
||||||
status: 'failure',
|
|
||||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
assert.equal(assessProviderEvidence('aaa', failedThenSucceeded).state, 'terminal-success');
|
|
||||||
assert.equal(assessProviderEvidence('bbb', succeededThenFailed).state, 'terminal-failure');
|
|
||||||
assert.equal(
|
|
||||||
assessProviderEvidence('ccc', [
|
|
||||||
{ commit: 'ccc', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
|
||||||
{ commit: 'ccc', status: 'failure', steps: [{ name: 'gate-verify', status: 'failure' }] },
|
|
||||||
]).state,
|
|
||||||
'terminal-failure',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const boundary =
|
||||||
|
"Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.";
|
||||||
|
const artifacts = [
|
||||||
|
'gates/required-gates.baseline.json',
|
||||||
|
'gates/gates.manifest.json',
|
||||||
|
'docs/PRD.md',
|
||||||
|
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||||
|
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||||
|
'docs/remediation/GATE-CLAIMS.md',
|
||||||
|
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||||
|
'docs/SITEMAP.md',
|
||||||
|
];
|
||||||
|
const forbidden = [
|
||||||
|
/\bindependent\b[^\n.]{0,80}\bbaseline\b/i,
|
||||||
|
/independently baselined/i,
|
||||||
|
/independently anchored inventory/i,
|
||||||
|
/protected (?:inventory )?(?:anchor|baseline)/i,
|
||||||
|
/inventory (?:anchor|anchored)/i,
|
||||||
|
];
|
||||||
|
const failures = [];
|
||||||
|
for (const relativePath of artifacts) {
|
||||||
|
const contents = await readFile(path.join(process.cwd(), relativePath), 'utf8');
|
||||||
|
const claims =
|
||||||
|
relativePath === 'gates/gates.manifest.json'
|
||||||
|
? JSON.parse(contents)
|
||||||
|
.criteria.map((criterion) => criterion.currentText)
|
||||||
|
.join('\n')
|
||||||
|
: contents;
|
||||||
|
if (!claims.includes(boundary)) failures.push(`${relativePath}: narrowed boundary is missing`);
|
||||||
|
const overclaim = forbidden.find((pattern) => pattern.test(claims));
|
||||||
|
if (overclaim) failures.push(`${relativePath}: inventory protection is overstated`);
|
||||||
|
}
|
||||||
|
if (failures.length > 0) {
|
||||||
|
for (const failure of failures) {
|
||||||
|
process.stderr.write(`INVENTORY_CLAIM_OVERSTATED: ${failure}\n`);
|
||||||
|
}
|
||||||
|
process.exit(84);
|
||||||
|
}
|
||||||
|
process.stdout.write('inventory drift boundary is stated in both directions; owner RM-60\n');
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const removedGateId = 'hook-pre-push';
|
||||||
|
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||||
|
|
||||||
|
function shrinkManifest(manifest) {
|
||||||
|
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||||
|
assert.ok(removedGate);
|
||||||
|
const removedCaseRefs = new Set(
|
||||||
|
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||||
|
);
|
||||||
|
const removedCriterionIds = new Set(
|
||||||
|
manifest.criteria
|
||||||
|
.filter(
|
||||||
|
(criterion) =>
|
||||||
|
criterion.caseRefs.length > 0 &&
|
||||||
|
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||||
|
)
|
||||||
|
.map((criterion) => criterion.id),
|
||||||
|
);
|
||||||
|
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||||
|
manifest.criteria = manifest.criteria
|
||||||
|
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||||
|
.map((criterion) => ({
|
||||||
|
...criterion,
|
||||||
|
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||||
|
...(criterion.gateRefs
|
||||||
|
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
manifest.proseClaims = manifest.proseClaims.filter(
|
||||||
|
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||||
|
);
|
||||||
|
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||||
|
.map((scenario) => ({
|
||||||
|
...scenario,
|
||||||
|
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||||
|
}))
|
||||||
|
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||||
|
for (const gate of manifest.gates) {
|
||||||
|
for (const gateCase of gate.cases) {
|
||||||
|
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||||
|
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attack(mode) {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||||
|
const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||||
|
if (source.split(inventoryEntry).length - 1 !== 1) {
|
||||||
|
throw new Error('source inventory fixture drifted');
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source,
|
||||||
|
);
|
||||||
|
const baseline = JSON.parse(
|
||||||
|
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (mode === 'baseline-manifest') {
|
||||||
|
baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId);
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||||
|
`${JSON.stringify(baseline)}\n`,
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
shrinkManifest(manifest);
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
'--root',
|
||||||
|
fixture,
|
||||||
|
'--manifest',
|
||||||
|
'gates/gates.manifest.json',
|
||||||
|
'--structure-only',
|
||||||
|
],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||||
|
return (
|
||||||
|
result.status !== 0 &&
|
||||||
|
new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceManifestRejected = await attack('source-manifest');
|
||||||
|
const baselineManifestRejected = await attack('baseline-manifest');
|
||||||
|
if (sourceManifestRejected && baselineManifestRejected) {
|
||||||
|
process.stderr.write(
|
||||||
|
'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n',
|
||||||
|
);
|
||||||
|
process.exit(83);
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { verifyRegistry } from './gate-verify.mjs';
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const root = process.cwd();
|
||||||
|
const source = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8'));
|
||||||
|
const expectedGateIds = source.gates.map((gate) => gate.id);
|
||||||
|
if (expectedGateIds.length === 0) {
|
||||||
|
process.stderr.write('gate population control requires a non-empty anchored inventory\n');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectedForEveryGate(mutate, diagnostic) {
|
||||||
|
for (const gateId of expectedGateIds) {
|
||||||
|
const manifest = structuredClone(source);
|
||||||
|
const gate = manifest.gates.find((candidate) => candidate.id === gateId);
|
||||||
|
mutate(gate);
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-population-control-'));
|
||||||
|
const manifestPath = path.join(directory, 'manifest.json');
|
||||||
|
try {
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||||
|
const result = await verifyRegistry({
|
||||||
|
root,
|
||||||
|
manifest: manifestPath,
|
||||||
|
structureOnly: true,
|
||||||
|
fixtureProfile: false,
|
||||||
|
});
|
||||||
|
if (!result.failures.some((failure) => diagnostic(failure, gateId))) return false;
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rejected;
|
||||||
|
if (mode === 'evidence-subject') {
|
||||||
|
rejected = true;
|
||||||
|
for (const gateId of expectedGateIds) {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-evidence-consumption-'));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(directory, 'gates'), { recursive: true });
|
||||||
|
const probe = path.join(directory, 'gates', 'probe.sh');
|
||||||
|
await writeFile(probe, '#!/bin/sh\necho EVIDENCE_PROBE >&2\nexit 7\n');
|
||||||
|
await chmod(probe, 0o755);
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
gateRoots: ['gates'],
|
||||||
|
governingClaimFiles: [],
|
||||||
|
coverageBoundary: { included: ['evidence fixture'], excluded: [], trackedBy: 'RM-02' },
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
id: 'EVIDENCE-CONSUMPTION',
|
||||||
|
originalText: 'Consumed evidence stays bound to its gate.',
|
||||||
|
currentText: 'Consumed evidence stays bound to its gate.',
|
||||||
|
claimType: 'integrity',
|
||||||
|
source: 'gate-population-control',
|
||||||
|
meaningChanges: [],
|
||||||
|
caseRefs: [`${gateId}/probe`],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
proseClaims: [],
|
||||||
|
compatibilityScenarios: [],
|
||||||
|
gates: [
|
||||||
|
{
|
||||||
|
id: gateId,
|
||||||
|
source: 'gates/probe.sh',
|
||||||
|
invocation: ['gates/probe.sh'],
|
||||||
|
deployment: { kind: 'none', reason: 'population fixture' },
|
||||||
|
inertMutation: {
|
||||||
|
file: 'gates/probe.sh',
|
||||||
|
find: 'exit 7',
|
||||||
|
replace: 'exit 0',
|
||||||
|
caseId: 'probe',
|
||||||
|
expected: { exitCode: 0 },
|
||||||
|
},
|
||||||
|
cases: [
|
||||||
|
{
|
||||||
|
id: 'probe',
|
||||||
|
criterionIds: ['EVIDENCE-CONSUMPTION'],
|
||||||
|
mustFail: true,
|
||||||
|
required: { exitCode: 7 },
|
||||||
|
actual: { exitCode: 7 },
|
||||||
|
evidence: { subject: 'different-gate-subject' },
|
||||||
|
reasonPattern: 'EVIDENCE_PROBE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(directory, 'gates', 'gates.manifest.json');
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||||
|
const result = await verifyRegistry({
|
||||||
|
root: directory,
|
||||||
|
manifest: manifestPath,
|
||||||
|
structureOnly: false,
|
||||||
|
fixtureProfile: true,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!result.failures.some(
|
||||||
|
(failure) =>
|
||||||
|
failure.includes(`gate ${gateId}: consumed evidence subject`) &&
|
||||||
|
failure.includes('does not match gate definition'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
rejected = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (mode === 'type-strict') {
|
||||||
|
rejected = await rejectedForEveryGate(
|
||||||
|
(gate) => {
|
||||||
|
gate.cases[0].actual.exitCode = '0';
|
||||||
|
},
|
||||||
|
(failure, gateId) =>
|
||||||
|
failure.includes(
|
||||||
|
`${gateId}/${source.gates.find((gate) => gate.id === gateId).cases[0].id}.actual.exitCode`,
|
||||||
|
) && failure.includes('expected an integer'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process.stderr.write(`unknown gate population control ${String(mode)}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rejected) {
|
||||||
|
process.stdout.write(`${mode} population control did not reject every registered gate\n`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stderr.write(`${mode} population control rejected every registered gate\n`);
|
||||||
|
process.exit(1);
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const verifierPath = path.join(root, 'scripts', 'gate-verify.mjs');
|
||||||
|
const manifestPath = path.join(root, 'gates', 'gates.manifest.json');
|
||||||
|
const requiredGateId = 'hook-pre-push';
|
||||||
|
|
||||||
|
function output(result) {
|
||||||
|
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shrinkManifest(manifest, removedGateId) {
|
||||||
|
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||||
|
assert.ok(removedGate, `fixture gate ${removedGateId} must exist`);
|
||||||
|
const removedCaseRefs = new Set(
|
||||||
|
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||||
|
);
|
||||||
|
const removedCriterionIds = new Set(
|
||||||
|
manifest.criteria
|
||||||
|
.filter(
|
||||||
|
(criterion) =>
|
||||||
|
criterion.caseRefs.length > 0 &&
|
||||||
|
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||||
|
)
|
||||||
|
.map((criterion) => criterion.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||||
|
manifest.criteria = manifest.criteria
|
||||||
|
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||||
|
.map((criterion) => ({
|
||||||
|
...criterion,
|
||||||
|
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||||
|
...(criterion.gateRefs
|
||||||
|
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
manifest.proseClaims = manifest.proseClaims.filter(
|
||||||
|
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||||
|
);
|
||||||
|
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||||
|
.map((scenario) => ({
|
||||||
|
...scenario,
|
||||||
|
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||||
|
}))
|
||||||
|
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||||
|
for (const gate of manifest.gates) {
|
||||||
|
for (const gateCase of gate.cases) {
|
||||||
|
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||||
|
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shrinking the verifier inventory and manifest together is rejected by a same-checkout baseline', async () => {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-'));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||||
|
const verifier = await readFile(verifierPath, 'utf8');
|
||||||
|
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||||
|
assert.equal(verifier.split(inventoryEntry).length - 1, 1, 'source inventory fixture drifted');
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
verifier.replace(inventoryEntry, ''),
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||||
|
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||||
|
shrinkManifest(manifest, requiredGateId);
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
'--root',
|
||||||
|
fixture,
|
||||||
|
'--manifest',
|
||||||
|
'gates/gates.manifest.json',
|
||||||
|
'--structure-only',
|
||||||
|
],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red');
|
||||||
|
assert.match(output(result), /same-checkout required-gate baseline.*hook-pre-push/i);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shrinking the same-checkout baseline and manifest together is rejected by verifier inventory', async () => {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-'));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||||
|
await copyFile(verifierPath, path.join(fixture, 'scripts', 'gate-verify.mjs'));
|
||||||
|
const baseline = JSON.parse(
|
||||||
|
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
baseline.gates = baseline.gates.filter((gate) => gate.id !== requiredGateId);
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||||
|
`${JSON.stringify(baseline)}\n`,
|
||||||
|
);
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||||
|
shrinkManifest(manifest, requiredGateId);
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
'--root',
|
||||||
|
fixture,
|
||||||
|
'--manifest',
|
||||||
|
'gates/gates.manifest.json',
|
||||||
|
'--structure-only',
|
||||||
|
],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.notEqual(result.status, 0, 'shrinking baseline and manifest together must go red');
|
||||||
|
assert.match(output(result), /verifier inventory.*hook-pre-push.*baseline/i);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every gate carries an evidence-side subject distinct from its definition', async () => {
|
||||||
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||||
|
for (const gate of manifest.gates) {
|
||||||
|
assert.ok(gate.cases.length > 0, `${gate.id} must have consumable evidence`);
|
||||||
|
for (const gateCase of gate.cases) {
|
||||||
|
assert.equal(
|
||||||
|
gateCase.evidence?.subject,
|
||||||
|
gate.id,
|
||||||
|
`${gate.id}/${gateCase.id} must source its subject from the evidence record`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory claim control rejects qualified independence wording', async () => {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-inventory-overclaim-'));
|
||||||
|
const artifacts = [
|
||||||
|
'gates/required-gates.baseline.json',
|
||||||
|
'gates/gates.manifest.json',
|
||||||
|
'docs/PRD.md',
|
||||||
|
'docs/ADMIN-GUIDE/quality-gate-registry.md',
|
||||||
|
'docs/DEVELOPER-GUIDE/quality-gate-registry.md',
|
||||||
|
'docs/remediation/GATE-CLAIMS.md',
|
||||||
|
'docs/plans/2026-08-01-rm-02-gate-registry.md',
|
||||||
|
'docs/SITEMAP.md',
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
await copyFile(
|
||||||
|
path.join(root, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||||
|
path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs'),
|
||||||
|
);
|
||||||
|
for (const relativePath of artifacts) {
|
||||||
|
const target = path.join(fixture, relativePath);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await copyFile(path.join(root, relativePath), target);
|
||||||
|
}
|
||||||
|
const prd = path.join(fixture, 'docs', 'PRD.md');
|
||||||
|
await writeFile(
|
||||||
|
prd,
|
||||||
|
`${await readFile(prd, 'utf8')}\nThis provides an independent seven-gate baseline comparison.\n`,
|
||||||
|
);
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs')],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 84, output(result));
|
||||||
|
assert.match(output(result), /INVENTORY_CLAIM_OVERSTATED.*docs\/PRD\.md/i);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evidence population control depends on production result consumption wiring', async () => {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-'));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||||
|
const verifier = await readFile(verifierPath, 'utf8');
|
||||||
|
const consumer = ` const subjectFailure = consumeEvidenceSubject(gate, result.evidence);\n if (subjectFailure) failures.push(subjectFailure);\n`;
|
||||||
|
assert.equal(verifier.split(consumer).length - 1, 1, 'consumer fixture drifted');
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
verifier.replace(consumer, ''),
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
path.join(root, 'scripts', 'gate-population-control.mjs'),
|
||||||
|
path.join(fixture, 'scripts', 'gate-population-control.mjs'),
|
||||||
|
);
|
||||||
|
await copyFile(manifestPath, path.join(fixture, 'gates', 'gates.manifest.json'));
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(fixture, 'scripts', 'gate-population-control.mjs'), 'evidence-subject'],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, output(result));
|
||||||
|
assert.match(output(result), /did not reject every registered gate/i);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production verification has a closed current-tree observation renderer', async () => {
|
||||||
|
const verifier = await readFile(verifierPath, 'utf8');
|
||||||
|
assert.doesNotMatch(verifier, /from ['"]\.\/gate-history\.mjs['"]/);
|
||||||
|
assert.doesNotMatch(verifier, /\bverifyHistory\s*\(/);
|
||||||
|
assert.doesNotMatch(verifier, /history[-_ ]?provenance/i);
|
||||||
|
const attacks = [
|
||||||
|
[
|
||||||
|
'history wording',
|
||||||
|
' /^META-NEGATIVE-CONTROL /,',
|
||||||
|
' /^HISTORY PROVENANCE VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'renamed ancestry wording',
|
||||||
|
' /^META-NEGATIVE-CONTROL /,',
|
||||||
|
' /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'provider lineage wording',
|
||||||
|
' /^META-NEGATIVE-CONTROL /,',
|
||||||
|
' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,',
|
||||||
|
],
|
||||||
|
['writer assertion bypass', ' assertCurrentTreeObservation(output);\n', ''],
|
||||||
|
[
|
||||||
|
'final success stdout bypass',
|
||||||
|
' writeCurrentTreeOutput(\n `REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`,\n );',
|
||||||
|
" process.stdout.write('COMMIT ANCESTRY VERIFIED\\n');",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'direct success stderr bypass',
|
||||||
|
" const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||||
|
" process.stderr.write('PROVIDER LINEAGE SUCCESS\\n');\n const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [name, find, replace] of attacks) {
|
||||||
|
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-'));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||||
|
assert.equal(verifier.split(find).length - 1, 1, `${name}: fixture drifted`);
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||||
|
verifier.replace(find, replace),
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
path.join(root, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||||
|
path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||||
|
);
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs')],
|
||||||
|
{ cwd: fixture, encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 79, `${name}: ${output(result)}`);
|
||||||
|
assert.match(output(result), /HISTORY_PROVENANCE_FORBIDDEN/);
|
||||||
|
} finally {
|
||||||
|
await rm(fixture, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+396
-33
@@ -20,22 +20,32 @@ import {
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
import { verifyHistory } from './gate-history.mjs';
|
|
||||||
|
|
||||||
const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']);
|
const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']);
|
||||||
|
const POPULATION_CRITERION_IDS = new Set([
|
||||||
|
'RM02-EVIDENCE-SUBJECT-BINDING',
|
||||||
|
'RM02-TYPE-STRICT-SCHEMA',
|
||||||
|
'RM02-NONEMPTY-ANCHORED-QUANTIFICATION',
|
||||||
|
]);
|
||||||
|
const REQUIRED_GATE_INVENTORY = new Map([
|
||||||
|
['quality-typecheck', 'package.json'],
|
||||||
|
['quality-lint', 'package.json'],
|
||||||
|
['quality-format', 'package.json'],
|
||||||
|
['checkout-preflight', 'scripts/preflight.mjs'],
|
||||||
|
['ci-queue-wait', 'packages/mosaic/framework/tools/git/ci-queue-wait.sh'],
|
||||||
|
['hook-pre-commit', '.husky/pre-commit'],
|
||||||
|
['hook-pre-push', '.husky/pre-push'],
|
||||||
|
]);
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const options = {
|
const options = {
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
manifest: 'gates/gates.manifest.json',
|
manifest: 'gates/gates.manifest.json',
|
||||||
skipHistory: false,
|
|
||||||
structureOnly: false,
|
structureOnly: false,
|
||||||
};
|
};
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const value = argv[index];
|
const value = argv[index];
|
||||||
if (value === '--root') options.root = path.resolve(argv[++index]);
|
if (value === '--root') options.root = path.resolve(argv[++index]);
|
||||||
else if (value === '--manifest') options.manifest = argv[++index];
|
else if (value === '--manifest') options.manifest = argv[++index];
|
||||||
else if (value === '--skip-history') options.skipHistory = true;
|
|
||||||
else if (value === '--structure-only') options.structureOnly = true;
|
else if (value === '--structure-only') options.structureOnly = true;
|
||||||
else throw new Error(`unknown option: ${value}`);
|
else throw new Error(`unknown option: ${value}`);
|
||||||
}
|
}
|
||||||
@@ -208,7 +218,8 @@ async function runCase(root, gate, gateCase) {
|
|||||||
expand(value, caseRoot),
|
expand(value, caseRoot),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
return runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment);
|
const result = runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment);
|
||||||
|
return { ...result, evidence: structuredClone(gateCase.evidence) };
|
||||||
} finally {
|
} finally {
|
||||||
if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true });
|
if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -288,14 +299,149 @@ function rejectDuplicateIds(values, label, failures) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateClosedSchema(manifest, failures) {
|
function requireString(value, label, failures, { allowEmpty = false } = {}) {
|
||||||
|
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
|
||||||
|
failures.push(`${label}: expected ${allowEmpty ? 'a string' : 'a non-empty string'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStringArray(value, label, failures, { allowEmpty = false } = {}) {
|
||||||
|
if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
|
||||||
|
failures.push(`${label}: expected ${allowEmpty ? 'an array' : 'a non-empty array'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
||||||
|
failures.push(`${label}: entries must be non-empty strings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOutcome(value, label, failures) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
value,
|
||||||
|
new Set(['exitCode', 'outputPattern', 'notOutputPattern']),
|
||||||
|
label,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
if (typeof value?.exitCode !== 'number' || !Number.isInteger(value.exitCode)) {
|
||||||
|
failures.push(`${label}.exitCode: expected an integer`);
|
||||||
|
}
|
||||||
|
for (const key of ['outputPattern', 'notOutputPattern']) {
|
||||||
|
if (value?.[key] !== undefined && typeof value[key] !== 'string') {
|
||||||
|
failures.push(`${label}.${key}: expected a string`);
|
||||||
|
} else if (typeof value?.[key] === 'string' && value[key].trim().length === 0) {
|
||||||
|
failures.push(`${label}.${key}: expected a non-empty pattern`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFixture(fixture, label, failures) {
|
||||||
|
if (fixture === undefined) return;
|
||||||
|
rejectUnknownKeys(
|
||||||
|
fixture,
|
||||||
|
new Set(['writeFiles', 'replaceFiles', 'removePaths', 'copyPaths']),
|
||||||
|
label,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
const writeFiles = Array.isArray(fixture?.writeFiles) ? fixture.writeFiles : [];
|
||||||
|
const replaceFiles = Array.isArray(fixture?.replaceFiles) ? fixture.replaceFiles : [];
|
||||||
|
for (const [index, entry] of writeFiles.entries()) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
entry,
|
||||||
|
new Set(['path', 'content', 'mode']),
|
||||||
|
`${label}.writeFiles[${index}]`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
requireString(entry?.path, `${label}.writeFiles[${index}].path`, failures);
|
||||||
|
if (typeof entry?.content !== 'string')
|
||||||
|
failures.push(`${label}.writeFiles[${index}].content: expected a string`);
|
||||||
|
if (entry?.mode !== undefined && (!Number.isInteger(entry.mode) || entry.mode < 0)) {
|
||||||
|
failures.push(`${label}.writeFiles[${index}].mode: expected a non-negative integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [index, entry] of replaceFiles.entries()) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
entry,
|
||||||
|
new Set(['path', 'find', 'replace']),
|
||||||
|
`${label}.replaceFiles[${index}]`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
requireString(entry?.path, `${label}.replaceFiles[${index}].path`, failures);
|
||||||
|
if (typeof entry?.find !== 'string')
|
||||||
|
failures.push(`${label}.replaceFiles[${index}].find: expected a string`);
|
||||||
|
if (typeof entry?.replace !== 'string')
|
||||||
|
failures.push(`${label}.replaceFiles[${index}].replace: expected a string`);
|
||||||
|
}
|
||||||
|
for (const key of ['removePaths', 'copyPaths']) {
|
||||||
|
if (fixture?.[key] !== undefined)
|
||||||
|
validateStringArray(fixture[key], `${label}.${key}`, failures, { allowEmpty: true });
|
||||||
|
}
|
||||||
|
for (const key of ['writeFiles', 'replaceFiles']) {
|
||||||
|
if (fixture?.[key] !== undefined && !Array.isArray(fixture[key])) {
|
||||||
|
failures.push(`${label}.${key}: expected an array`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEnvironment(environment, label, failures) {
|
||||||
|
if (environment === undefined) return;
|
||||||
|
if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
|
||||||
|
failures.push(`${label}: expected an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(environment)) {
|
||||||
|
if (!key || typeof value !== 'string') failures.push(`${label}.${key}: expected a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoriesEqual(left, right) {
|
||||||
|
return structuredValuesEqual([...left.entries()], [...right.entries()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeEvidenceSubject(gate, evidence) {
|
||||||
|
if (evidence?.subject !== gate.id) {
|
||||||
|
return `gate ${gate.id}: consumed evidence subject ${String(evidence?.subject)} does not match gate definition`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_TREE_OBSERVATION_PATTERNS = [
|
||||||
|
/^META-NEGATIVE-CONTROL /,
|
||||||
|
/^DEPLOYMENT-NEGATIVE-CONTROL /,
|
||||||
|
/^DEPLOYED IDENTITY UNAVAILABLE /,
|
||||||
|
/^DEFECT /,
|
||||||
|
/^COMPATIBILITY /,
|
||||||
|
/^REGISTRY SUMMARY open behavior deltas: [0-9]+; required-behavior conformance is not asserted while deltas remain$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function assertCurrentTreeObservation(observation) {
|
||||||
|
if (!CURRENT_TREE_OBSERVATION_PATTERNS.some((pattern) => pattern.test(observation))) {
|
||||||
|
throw new Error(`unsupported observation class: ${observation}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCurrentTreeOutput(output) {
|
||||||
|
assertCurrentTreeObservation(output);
|
||||||
|
process.stdout.write(`${output}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateClosedSchema(
|
||||||
|
manifest,
|
||||||
|
failures,
|
||||||
|
{ fixtureProfile = false, requiredGateInventory } = {},
|
||||||
|
) {
|
||||||
|
if (!fixtureProfile) {
|
||||||
|
for (const population of ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios']) {
|
||||||
|
if (!Array.isArray(manifest[population]) || manifest[population].length === 0) {
|
||||||
|
failures.push(`${population} population must be non-empty and anchored before evaluation`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (manifest.schemaVersion !== 1)
|
if (manifest.schemaVersion !== 1)
|
||||||
failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`);
|
failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`);
|
||||||
rejectUnknownKeys(
|
rejectUnknownKeys(
|
||||||
manifest,
|
manifest,
|
||||||
new Set([
|
new Set([
|
||||||
'schemaVersion',
|
'schemaVersion',
|
||||||
'activationCommit',
|
|
||||||
'gateRoots',
|
'gateRoots',
|
||||||
'governingClaimFiles',
|
'governingClaimFiles',
|
||||||
'coverageBoundary',
|
'coverageBoundary',
|
||||||
@@ -308,6 +454,37 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
'manifest',
|
'manifest',
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
validateStringArray(manifest.gateRoots, 'manifest.gateRoots', failures);
|
||||||
|
validateStringArray(manifest.governingClaimFiles, 'manifest.governingClaimFiles', failures, {
|
||||||
|
allowEmpty: true,
|
||||||
|
});
|
||||||
|
rejectUnknownKeys(
|
||||||
|
manifest.coverageBoundary,
|
||||||
|
new Set(['included', 'excluded', 'trackedBy']),
|
||||||
|
'coverageBoundary',
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
validateStringArray(manifest.coverageBoundary?.included, 'coverageBoundary.included', failures);
|
||||||
|
validateStringArray(manifest.coverageBoundary?.excluded, 'coverageBoundary.excluded', failures, {
|
||||||
|
allowEmpty: true,
|
||||||
|
});
|
||||||
|
requireString(manifest.coverageBoundary?.trackedBy, 'coverageBoundary.trackedBy', failures);
|
||||||
|
if (manifest.mergeAssertions !== undefined) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
manifest.mergeAssertions,
|
||||||
|
new Set(['mode', 'deferredReplayOwner', 'trustDependencies', 'postMergeResponse']),
|
||||||
|
'mergeAssertions',
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
for (const key of ['mode', 'deferredReplayOwner', 'postMergeResponse']) {
|
||||||
|
requireString(manifest.mergeAssertions?.[key], `mergeAssertions.${key}`, failures);
|
||||||
|
}
|
||||||
|
validateStringArray(
|
||||||
|
manifest.mergeAssertions?.trustDependencies,
|
||||||
|
'mergeAssertions.trustDependencies',
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
rejectDuplicateIds(manifest.criteria, 'criterion', failures);
|
rejectDuplicateIds(manifest.criteria, 'criterion', failures);
|
||||||
for (const criterion of manifest.criteria ?? []) {
|
for (const criterion of manifest.criteria ?? []) {
|
||||||
rejectUnknownKeys(
|
rejectUnknownKeys(
|
||||||
@@ -320,10 +497,38 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
'source',
|
'source',
|
||||||
'meaningChanges',
|
'meaningChanges',
|
||||||
'caseRefs',
|
'caseRefs',
|
||||||
|
'gateRefs',
|
||||||
]),
|
]),
|
||||||
`criterion ${criterion.id}`,
|
`criterion ${criterion.id}`,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
for (const key of ['id', 'originalText', 'currentText', 'claimType', 'source']) {
|
||||||
|
requireString(criterion[key], `criterion ${criterion.id}.${key}`, failures);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(criterion.meaningChanges)) {
|
||||||
|
failures.push(`criterion ${criterion.id}.meaningChanges: expected an array`);
|
||||||
|
}
|
||||||
|
for (const [index, change] of (criterion.meaningChanges ?? []).entries()) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
change,
|
||||||
|
new Set(['originalText', 'restatement', 'reason', 'finding', 'task', 'date']),
|
||||||
|
`criterion ${criterion.id}.meaningChanges[${index}]`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
for (const key of ['originalText', 'restatement', 'reason', 'finding', 'task', 'date']) {
|
||||||
|
requireString(
|
||||||
|
change?.[key],
|
||||||
|
`criterion ${criterion.id}.meaningChanges[${index}].${key}`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (POPULATION_CRITERION_IDS.has(criterion.id) && criterion.gateRefs === undefined) {
|
||||||
|
failures.push(`${criterion.id}: gateRefs population binding is required`);
|
||||||
|
}
|
||||||
|
if (criterion.gateRefs !== undefined) {
|
||||||
|
validateStringArray(criterion.gateRefs, `criterion ${criterion.id}.gateRefs`, failures);
|
||||||
|
}
|
||||||
if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) {
|
if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) {
|
||||||
failures.push(`${criterion.id}: no declared exercising cases`);
|
failures.push(`${criterion.id}: no declared exercising cases`);
|
||||||
} else {
|
} else {
|
||||||
@@ -343,6 +548,9 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
`prose claim ${claim.id}`,
|
`prose claim ${claim.id}`,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
for (const key of ['id', 'criterionId', 'caseRef']) {
|
||||||
|
requireString(claim[key], `GATE-CLAIM:${claim.id}.${key}`, failures);
|
||||||
|
}
|
||||||
if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) {
|
if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) {
|
||||||
failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`);
|
failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`);
|
||||||
}
|
}
|
||||||
@@ -363,17 +571,54 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
`compatibility scenario ${scenario.id}`,
|
`compatibility scenario ${scenario.id}`,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
for (const key of ['id', 'construction']) {
|
||||||
|
requireString(scenario[key], `compatibility scenario ${scenario.id}.${key}`, failures);
|
||||||
|
}
|
||||||
if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) {
|
if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) {
|
||||||
failures.push(`${scenario.id}: compatibility construction has no referenced conditions`);
|
failures.push(`${scenario.id}: compatibility construction has no referenced conditions`);
|
||||||
|
} else {
|
||||||
|
validateStringArray(scenario.caseRefs, `${scenario.id}.caseRefs`, failures);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) {
|
if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) {
|
||||||
failures.push(`${scenario.id}: compatibility construction invocation is missing`);
|
failures.push(`${scenario.id}: compatibility construction invocation is missing`);
|
||||||
|
} else {
|
||||||
|
validateStringArray(scenario.invocation, `${scenario.id}.invocation`, failures);
|
||||||
}
|
}
|
||||||
if (typeof scenario.expected?.exitCode !== 'number') {
|
validateOutcome(scenario.expected, `${scenario.id}.expected`, failures);
|
||||||
failures.push(`${scenario.id}: compatibility construction exact expected exit is missing`);
|
validateEnvironment(scenario.environment, `${scenario.id}.environment`, failures);
|
||||||
}
|
validateFixture(scenario.fixture, `${scenario.id}.fixture`, failures);
|
||||||
}
|
}
|
||||||
rejectDuplicateIds(manifest.gates, 'gate', failures);
|
rejectDuplicateIds(manifest.gates, 'gate', failures);
|
||||||
|
if (!fixtureProfile) {
|
||||||
|
if (!(requiredGateInventory instanceof Map) || requiredGateInventory.size === 0) {
|
||||||
|
failures.push('same-checkout required-gate baseline is absent or empty');
|
||||||
|
} else {
|
||||||
|
if (!inventoriesEqual(REQUIRED_GATE_INVENTORY, requiredGateInventory)) {
|
||||||
|
for (const [requiredId, requiredSource] of requiredGateInventory) {
|
||||||
|
if (REQUIRED_GATE_INVENTORY.get(requiredId) !== requiredSource) {
|
||||||
|
failures.push(
|
||||||
|
`same-checkout required-gate baseline rejects verifier inventory drift at ${requiredId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) {
|
||||||
|
if (requiredGateInventory.get(requiredId) !== requiredSource) {
|
||||||
|
failures.push(
|
||||||
|
`verifier inventory ${requiredId} is absent or changed in same-checkout required-gate baseline`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [requiredId, requiredSource] of requiredGateInventory) {
|
||||||
|
const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId);
|
||||||
|
if (!registered || registered.source !== requiredSource) {
|
||||||
|
failures.push(
|
||||||
|
`same-checkout required-gate baseline rejects manifest drift at ${requiredId}: required source ${requiredSource}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const gate of manifest.gates ?? []) {
|
for (const gate of manifest.gates ?? []) {
|
||||||
rejectUnknownKeys(
|
rejectUnknownKeys(
|
||||||
gate,
|
gate,
|
||||||
@@ -389,13 +634,57 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
`gate ${gate.id}`,
|
`gate ${gate.id}`,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
|
requireString(gate.id, `gate ${gate.id}.id`, failures);
|
||||||
|
requireString(gate.source, `gate ${gate.id}.source`, failures);
|
||||||
rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures);
|
rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures);
|
||||||
if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) {
|
if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) {
|
||||||
failures.push(`${gate.id}: exact invocation is missing`);
|
failures.push(`${gate.id}: exact invocation is missing`);
|
||||||
|
} else {
|
||||||
|
validateStringArray(gate.invocation, `${gate.id}.invocation`, failures);
|
||||||
}
|
}
|
||||||
|
if (gate.discoveryAliases !== undefined) {
|
||||||
|
validateStringArray(gate.discoveryAliases, `${gate.id}.discoveryAliases`, failures, {
|
||||||
|
allowEmpty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rejectUnknownKeys(
|
||||||
|
gate.deployment,
|
||||||
|
new Set(['kind', 'reason', 'source', 'path', 'unavailableOwner', 'observedSha256']),
|
||||||
|
`${gate.id}.deployment`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
if (!['none', 'file'].includes(gate.deployment?.kind)) {
|
if (!['none', 'file'].includes(gate.deployment?.kind)) {
|
||||||
failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`);
|
failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`);
|
||||||
|
} else if (gate.deployment.kind === 'none') {
|
||||||
|
requireString(gate.deployment.reason, `${gate.id}.deployment.reason`, failures);
|
||||||
|
} else {
|
||||||
|
for (const key of ['source', 'path', 'unavailableOwner', 'observedSha256']) {
|
||||||
|
requireString(gate.deployment[key], `${gate.id}.deployment.${key}`, failures);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
rejectUnknownKeys(
|
||||||
|
gate.inertMutation,
|
||||||
|
new Set(['file', 'find', 'replace', 'caseId', 'expected', 'sandboxFiles']),
|
||||||
|
`${gate.id}.inertMutation`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
for (const key of ['file', 'find', 'replace']) {
|
||||||
|
if (typeof gate.inertMutation?.[key] !== 'string') {
|
||||||
|
failures.push(`${gate.id}.inertMutation.${key}: expected a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gate.inertMutation?.caseId !== undefined) {
|
||||||
|
requireString(gate.inertMutation.caseId, `${gate.id}.inertMutation.caseId`, failures);
|
||||||
|
}
|
||||||
|
if (gate.inertMutation?.sandboxFiles !== undefined) {
|
||||||
|
validateStringArray(
|
||||||
|
gate.inertMutation.sandboxFiles,
|
||||||
|
`${gate.id}.inertMutation.sandboxFiles`,
|
||||||
|
failures,
|
||||||
|
{ allowEmpty: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateOutcome(gate.inertMutation?.expected, `${gate.id}.inertMutation.expected`, failures);
|
||||||
for (const gateCase of gate.cases ?? []) {
|
for (const gateCase of gate.cases ?? []) {
|
||||||
rejectUnknownKeys(
|
rejectUnknownKeys(
|
||||||
gateCase,
|
gateCase,
|
||||||
@@ -406,6 +695,7 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
'invocation',
|
'invocation',
|
||||||
'required',
|
'required',
|
||||||
'actual',
|
'actual',
|
||||||
|
'evidence',
|
||||||
'reasonPattern',
|
'reasonPattern',
|
||||||
'environment',
|
'environment',
|
||||||
'fixture',
|
'fixture',
|
||||||
@@ -414,31 +704,57 @@ function validateClosedSchema(manifest, failures) {
|
|||||||
`${gate.id}/${gateCase.id}`,
|
`${gate.id}/${gateCase.id}`,
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
if (
|
requireString(gateCase.id, `${gate.id}/${gateCase.id}.id`, failures);
|
||||||
typeof gateCase.required?.exitCode !== 'number' ||
|
validateStringArray(
|
||||||
typeof gateCase.actual?.exitCode !== 'number'
|
gateCase.criterionIds,
|
||||||
) {
|
`${gate.id}/${gateCase.id}.criterionIds`,
|
||||||
failures.push(
|
failures,
|
||||||
`${gate.id}/${gateCase.id}: required and actual exact exit codes are mandatory`,
|
);
|
||||||
);
|
if (typeof gateCase.mustFail !== 'boolean') {
|
||||||
|
failures.push(`${gate.id}/${gateCase.id}.mustFail: expected a boolean`);
|
||||||
}
|
}
|
||||||
if (
|
validateOutcome(gateCase.required, `${gate.id}/${gateCase.id}.required`, failures);
|
||||||
gateCase.invocation &&
|
validateOutcome(gateCase.actual, `${gate.id}/${gateCase.id}.actual`, failures);
|
||||||
(!Array.isArray(gateCase.invocation) || gateCase.invocation.length === 0)
|
rejectUnknownKeys(
|
||||||
) {
|
gateCase.evidence,
|
||||||
failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`);
|
new Set(['subject']),
|
||||||
|
`${gate.id}/${gateCase.id}.evidence`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
requireString(
|
||||||
|
gateCase.evidence?.subject,
|
||||||
|
`${gate.id}/${gateCase.id}.evidence.subject`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
if (gateCase.invocation !== undefined) {
|
||||||
|
validateStringArray(gateCase.invocation, `${gate.id}/${gateCase.id}.invocation`, failures);
|
||||||
|
}
|
||||||
|
if (typeof gateCase.reasonPattern !== 'string') {
|
||||||
|
failures.push(`${gate.id}/${gateCase.id}.reasonPattern: expected a string`);
|
||||||
}
|
}
|
||||||
if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) {
|
if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`,
|
`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
validateEnvironment(gateCase.environment, `${gate.id}/${gateCase.id}.environment`, failures);
|
||||||
|
validateFixture(gateCase.fixture, `${gate.id}/${gateCase.id}.fixture`, failures);
|
||||||
|
if (gateCase.defect !== undefined) {
|
||||||
|
rejectUnknownKeys(
|
||||||
|
gateCase.defect,
|
||||||
|
new Set(['owner', 'reason']),
|
||||||
|
`${gate.id}/${gateCase.id}.defect`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
requireString(gateCase.defect.owner, `${gate.id}/${gateCase.id}.defect.owner`, failures);
|
||||||
|
requireString(gateCase.defect.reason, `${gate.id}/${gateCase.id}.defect.reason`, failures);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateStructure(manifest, failures) {
|
function validateStructure(manifest, failures, options = {}) {
|
||||||
validateClosedSchema(manifest, failures);
|
validateClosedSchema(manifest, failures, options);
|
||||||
const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion]));
|
const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion]));
|
||||||
const boundCriteria = new Set();
|
const boundCriteria = new Set();
|
||||||
const negativeBoundCriteria = new Set();
|
const negativeBoundCriteria = new Set();
|
||||||
@@ -466,6 +782,22 @@ function validateStructure(manifest, failures) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registeredGateIds = new Set((manifest.gates ?? []).map((gate) => gate.id));
|
||||||
|
for (const criterion of criteria.values()) {
|
||||||
|
if (criterion.gateRefs === undefined) continue;
|
||||||
|
const referenced = new Set(criterion.gateRefs);
|
||||||
|
for (const gateId of registeredGateIds) {
|
||||||
|
if (!referenced.has(gateId)) {
|
||||||
|
failures.push(`${criterion.id}: gate population binding is missing ${gateId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const gateId of referenced) {
|
||||||
|
if (!registeredGateIds.has(gateId)) {
|
||||||
|
failures.push(`${criterion.id}: gate population binding references unknown gate ${gateId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const claim of manifest.proseClaims ?? []) {
|
for (const claim of manifest.proseClaims ?? []) {
|
||||||
if (!criteria.has(claim.criterionId)) {
|
if (!criteria.has(claim.criterionId)) {
|
||||||
failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`);
|
failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`);
|
||||||
@@ -775,8 +1107,40 @@ export async function verifyRegistry(options) {
|
|||||||
const observations = [];
|
const observations = [];
|
||||||
const manifestPath = path.resolve(options.root, options.manifest);
|
const manifestPath = path.resolve(options.root, options.manifest);
|
||||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||||
|
let requiredGateInventory;
|
||||||
|
if (!options.fixtureProfile) {
|
||||||
|
const baselinePath = path.resolve(options.root, 'gates/required-gates.baseline.json');
|
||||||
|
try {
|
||||||
|
const baseline = JSON.parse(await readFile(baselinePath, 'utf8'));
|
||||||
|
if (
|
||||||
|
baseline.schemaVersion !== 1 ||
|
||||||
|
!Array.isArray(baseline.gates) ||
|
||||||
|
Object.keys(baseline).some((key) => !['schemaVersion', 'purpose', 'gates'].includes(key)) ||
|
||||||
|
baseline.gates.some(
|
||||||
|
(gate) =>
|
||||||
|
!gate ||
|
||||||
|
typeof gate !== 'object' ||
|
||||||
|
Array.isArray(gate) ||
|
||||||
|
Object.keys(gate).some((key) => !['id', 'source'].includes(key)) ||
|
||||||
|
typeof gate.id !== 'string' ||
|
||||||
|
gate.id.length === 0 ||
|
||||||
|
typeof gate.source !== 'string' ||
|
||||||
|
gate.source.length === 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
failures.push('same-checkout required-gate baseline has unsupported structure');
|
||||||
|
} else {
|
||||||
|
requiredGateInventory = new Map(baseline.gates.map((gate) => [gate.id, gate.source]));
|
||||||
|
if (requiredGateInventory.size !== baseline.gates.length) {
|
||||||
|
failures.push('same-checkout required-gate baseline has duplicate gate ids');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`same-checkout required-gate baseline cannot be read: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
validateStructure(manifest, failures);
|
validateStructure(manifest, failures, { ...options, requiredGateInventory });
|
||||||
if (options.structureOnly) return { failures, manifest, observations };
|
if (options.structureOnly) return { failures, manifest, observations };
|
||||||
|
|
||||||
async function collectPhaseFailure(label, action) {
|
async function collectPhaseFailure(label, action) {
|
||||||
@@ -807,6 +1171,8 @@ export async function verifyRegistry(options) {
|
|||||||
if (!result) continue;
|
if (!result) continue;
|
||||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||||
try {
|
try {
|
||||||
|
const subjectFailure = consumeEvidenceSubject(gate, result.evidence);
|
||||||
|
if (subjectFailure) failures.push(subjectFailure);
|
||||||
if (!outcomeMatches(gateCase.actual, result)) {
|
if (!outcomeMatches(gateCase.actual, result)) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`,
|
`${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`,
|
||||||
@@ -841,21 +1207,18 @@ export async function verifyRegistry(options) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const { failures, observations, manifest } = await verifyRegistry(options);
|
const { failures, observations } = await verifyRegistry(options);
|
||||||
if (!options.skipHistory && failures.length === 0) {
|
for (const observation of observations) {
|
||||||
const history = await verifyHistory({ root: options.root, manifest });
|
writeCurrentTreeOutput(observation);
|
||||||
failures.push(...history.failures);
|
|
||||||
observations.push(...history.observations);
|
|
||||||
}
|
}
|
||||||
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;
|
const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;
|
||||||
process.stdout.write(
|
writeCurrentTreeOutput(
|
||||||
`registry observations matched; open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain\n`,
|
`REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(`GATE VERIFY FAILED: ${error.message}\n`);
|
process.stderr.write(`GATE VERIFY FAILED: ${error.message}\n`);
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs');
|
const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs');
|
||||||
|
const fixtureRunner = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'scripts',
|
||||||
|
'test-support',
|
||||||
|
'gate-verify-fixture-runner.mjs',
|
||||||
|
);
|
||||||
const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`);
|
const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`);
|
||||||
|
|
||||||
async function fixture(name = 'case') {
|
async function fixture(name = 'case') {
|
||||||
@@ -18,7 +24,6 @@ async function fixture(name = 'case') {
|
|||||||
function baseManifest() {
|
function baseManifest() {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
activationCommit: null,
|
|
||||||
gateRoots: ['gates'],
|
gateRoots: ['gates'],
|
||||||
governingClaimFiles: [],
|
governingClaimFiles: [],
|
||||||
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
|
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
|
||||||
@@ -55,6 +60,7 @@ function baseManifest() {
|
|||||||
invocation: ['gates/meta-fixture.sh'],
|
invocation: ['gates/meta-fixture.sh'],
|
||||||
required: { exitCode: 7 },
|
required: { exitCode: 7 },
|
||||||
actual: { exitCode: 7 },
|
actual: { exitCode: 7 },
|
||||||
|
evidence: { subject: 'meta-fixture' },
|
||||||
reasonPattern: 'META_REJECT',
|
reasonPattern: 'META_REJECT',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -74,6 +80,14 @@ async function writeManifest(root, manifest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function verify(root, extraArgs = []) {
|
function verify(root, extraArgs = []) {
|
||||||
|
return spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[fixtureRunner, '--root', root, '--manifest', 'gates/gates.manifest.json', ...extraArgs],
|
||||||
|
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyProductionStructure(root, extraArgs = []) {
|
||||||
return spawnSync(
|
return spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[
|
[
|
||||||
@@ -82,7 +96,7 @@ function verify(root, extraArgs = []) {
|
|||||||
root,
|
root,
|
||||||
'--manifest',
|
'--manifest',
|
||||||
'gates/gates.manifest.json',
|
'gates/gates.manifest.json',
|
||||||
'--skip-history',
|
'--structure-only',
|
||||||
...extraArgs,
|
...extraArgs,
|
||||||
],
|
],
|
||||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||||
@@ -97,6 +111,102 @@ test.after(async () => {
|
|||||||
await rm(fixtureBase, { recursive: true, force: true });
|
await rm(fixtureBase, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('universally quantified registry checks reject empty populations before evaluation', async () => {
|
||||||
|
const root = await fixture('empty-registry-populations');
|
||||||
|
await mkdir(path.join(root, 'empty-gate-root'), { recursive: true });
|
||||||
|
const manifest = baseManifest();
|
||||||
|
manifest.gateRoots = ['empty-gate-root'];
|
||||||
|
manifest.criteria = [];
|
||||||
|
manifest.proseClaims = [];
|
||||||
|
manifest.compatibilityScenarios = [];
|
||||||
|
manifest.gates = [];
|
||||||
|
await writeManifest(root, manifest);
|
||||||
|
|
||||||
|
const result = verifyProductionStructure(root);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(output(result), /criteria population.*non-empty.*anchored/i);
|
||||||
|
assert.match(output(result), /gates population.*non-empty.*anchored/i);
|
||||||
|
assert.match(output(result), /proseClaims population.*non-empty.*anchored/i);
|
||||||
|
assert.match(output(result), /compatibilityScenarios population.*non-empty.*anchored/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production verifier exposes no fixture-profile population bypass', async () => {
|
||||||
|
const root = await fixture('no-production-fixture-profile');
|
||||||
|
const result = verifyProductionStructure(root, ['--fixture-profile']);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(output(result), /unknown option: --fixture-profile/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anchored gate inventory and population criteria cannot shrink together', async () => {
|
||||||
|
const source = JSON.parse(
|
||||||
|
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const root = await fixture('shrunken-gate-population');
|
||||||
|
await copyFile(
|
||||||
|
path.join(process.cwd(), 'gates', 'required-gates.baseline.json'),
|
||||||
|
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||||
|
);
|
||||||
|
source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push');
|
||||||
|
for (const criterion of source.criteria) {
|
||||||
|
if (criterion.gateRefs) {
|
||||||
|
criterion.gateRefs = criterion.gateRefs.filter((gateId) => gateId !== 'hook-pre-push');
|
||||||
|
}
|
||||||
|
criterion.caseRefs = criterion.caseRefs.filter(
|
||||||
|
(caseRef) => !caseRef.startsWith('hook-pre-push/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeManifest(root, source);
|
||||||
|
|
||||||
|
const result = verifyProductionStructure(root);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(
|
||||||
|
output(result),
|
||||||
|
/same-checkout required-gate baseline rejects manifest drift at hook-pre-push/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('general population criteria cannot delete their gateRefs binding', async () => {
|
||||||
|
const requiredCriteria = [
|
||||||
|
'RM02-EVIDENCE-SUBJECT-BINDING',
|
||||||
|
'RM02-TYPE-STRICT-SCHEMA',
|
||||||
|
'RM02-NONEMPTY-ANCHORED-QUANTIFICATION',
|
||||||
|
];
|
||||||
|
for (const criterionId of requiredCriteria) {
|
||||||
|
const source = JSON.parse(
|
||||||
|
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const root = await fixture(`missing-gate-refs-${criterionId}`);
|
||||||
|
delete source.criteria.find((criterion) => criterion.id === criterionId).gateRefs;
|
||||||
|
await writeManifest(root, source);
|
||||||
|
const result = verifyProductionStructure(root);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(
|
||||||
|
output(result),
|
||||||
|
new RegExp(`${criterionId}.*gateRefs.*required`, 'i'),
|
||||||
|
criterionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('general population criteria must span every registered gate', async () => {
|
||||||
|
const source = JSON.parse(
|
||||||
|
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const root = await fixture('incomplete-gate-refs');
|
||||||
|
source.criteria.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING').gateRefs =
|
||||||
|
source.criteria
|
||||||
|
.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING')
|
||||||
|
.gateRefs.filter((gateId) => gateId !== 'quality-lint');
|
||||||
|
await writeManifest(root, source);
|
||||||
|
|
||||||
|
const result = verifyProductionStructure(root);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(
|
||||||
|
output(result),
|
||||||
|
/RM02-EVIDENCE-SUBJECT-BINDING.*gate population binding is missing quality-lint/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('an externally inerted failure branch makes verification nonzero and names the gate', async () => {
|
test('an externally inerted failure branch makes verification nonzero and names the gate', async () => {
|
||||||
const root = await fixture('external-inert');
|
const root = await fixture('external-inert');
|
||||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||||
@@ -157,6 +267,117 @@ test('duplicate stable ids and unsupported schema versions are rejected', async
|
|||||||
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
|
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('misspelled nested assertion fields are rejected instead of becoming optional', async () => {
|
||||||
|
const root = await fixture('nested-schema-typo');
|
||||||
|
await writeGate(root);
|
||||||
|
const manifest = baseManifest();
|
||||||
|
manifest.gates[0].cases[0].required.outputPatern = 'META_REJECT';
|
||||||
|
manifest.gates[0].cases[0].actual.outputPatern = 'META_REJECT';
|
||||||
|
await writeManifest(root, manifest);
|
||||||
|
|
||||||
|
const result = verify(root);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(output(result), /required.*unknown field outputPatern/i);
|
||||||
|
assert.match(output(result), /actual.*unknown field outputPatern/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('present outcome patterns cannot be empty assertion bypasses', async () => {
|
||||||
|
const root = await fixture('nested-schema-empty-patterns');
|
||||||
|
await writeGate(root);
|
||||||
|
const manifest = baseManifest();
|
||||||
|
manifest.gates[0].cases[0].required.outputPattern = '';
|
||||||
|
manifest.gates[0].cases[0].actual.notOutputPattern = ' ';
|
||||||
|
await writeManifest(root, manifest);
|
||||||
|
|
||||||
|
const result = verify(root, ['--structure-only']);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(output(result), /required.outputPattern: expected a non-empty pattern/i);
|
||||||
|
assert.match(output(result), /actual.notOutputPattern: expected a non-empty pattern/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested discriminator and comparison fields reject wrong types', async () => {
|
||||||
|
const root = await fixture('nested-schema-types');
|
||||||
|
await writeGate(root);
|
||||||
|
const manifest = baseManifest();
|
||||||
|
manifest.gates[0].cases[0].mustFail = 'true';
|
||||||
|
manifest.gates[0].cases[0].required.exitCode = '7';
|
||||||
|
manifest.gates[0].cases[0].actual.outputPattern = 7;
|
||||||
|
await writeManifest(root, manifest);
|
||||||
|
|
||||||
|
const result = verify(root, ['--structure-only']);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(output(result), /mustFail: expected a boolean/i);
|
||||||
|
assert.match(output(result), /required.exitCode: expected an integer/i);
|
||||||
|
assert.match(output(result), /actual.outputPattern: expected a string/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recursive closed-schema guards reject unknown fields in every nested assertion object', async () => {
|
||||||
|
const source = JSON.parse(
|
||||||
|
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const checkout = source.gates.find((gate) => gate.id === 'checkout-preflight');
|
||||||
|
const stale = checkout.cases.find((gateCase) => gateCase.id === 'stale-build-lock');
|
||||||
|
const queue = source.gates.find((gate) => gate.id === 'ci-queue-wait');
|
||||||
|
const queueCase = queue.cases.find((gateCase) => gateCase.id === 'terminal-success');
|
||||||
|
const targets = [
|
||||||
|
['coverageBoundary', (manifest) => manifest.coverageBoundary],
|
||||||
|
['mergeAssertions', (manifest) => manifest.mergeAssertions],
|
||||||
|
[
|
||||||
|
'meaningChanges',
|
||||||
|
(manifest) =>
|
||||||
|
manifest.criteria.find((criterion) => criterion.meaningChanges.length).meaningChanges[0],
|
||||||
|
],
|
||||||
|
['proseClaims', (manifest) => manifest.proseClaims[0]],
|
||||||
|
['compatibility expected', (manifest) => manifest.compatibilityScenarios[0].expected],
|
||||||
|
[
|
||||||
|
'deployment',
|
||||||
|
(manifest) => manifest.gates.find((gate) => gate.id === 'ci-queue-wait').deployment,
|
||||||
|
],
|
||||||
|
['inertMutation', (manifest) => manifest.gates[0].inertMutation],
|
||||||
|
['inert expected', (manifest) => manifest.gates[0].inertMutation.expected],
|
||||||
|
['required', (manifest) => manifest.gates[0].cases[0].required],
|
||||||
|
['actual', (manifest) => manifest.gates[0].cases[0].actual],
|
||||||
|
[
|
||||||
|
'fixture',
|
||||||
|
(manifest) =>
|
||||||
|
manifest.gates
|
||||||
|
.find((gate) => gate.id === 'checkout-preflight')
|
||||||
|
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'write entry',
|
||||||
|
(manifest) =>
|
||||||
|
manifest.gates
|
||||||
|
.find((gate) => gate.id === 'checkout-preflight')
|
||||||
|
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture.writeFiles[0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'replace entry',
|
||||||
|
(manifest) =>
|
||||||
|
manifest.gates
|
||||||
|
.find((gate) => gate.id === 'checkout-preflight')
|
||||||
|
.cases.find((gateCase) => gateCase.id === 'criterion-misbinding').fixture.replaceFiles[0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'defect',
|
||||||
|
(manifest) =>
|
||||||
|
manifest.gates
|
||||||
|
.find((gate) => gate.id === 'ci-queue-wait')
|
||||||
|
.cases.find((gateCase) => gateCase.id === 'terminal-success').defect,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
assert.ok(stale.fixture && queueCase.defect);
|
||||||
|
for (const [name, select] of targets) {
|
||||||
|
const root = await fixture(`recursive-${name.replaceAll(' ', '-')}`);
|
||||||
|
const manifest = structuredClone(source);
|
||||||
|
select(manifest).unexpectedNestedField = true;
|
||||||
|
await writeManifest(root, manifest);
|
||||||
|
const result = verify(root, ['--structure-only']);
|
||||||
|
assert.notEqual(result.status, 0, `${name}: ${output(result)}`);
|
||||||
|
assert.match(output(result), /unknown field unexpectedNestedField/i, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
|
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
|
||||||
const root = await fixture('path-traversal');
|
const root = await fixture('path-traversal');
|
||||||
await writeGate(root);
|
await writeGate(root);
|
||||||
@@ -433,6 +654,7 @@ test('compatibility scenarios execute referenced conditions as one construction'
|
|||||||
fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] },
|
fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] },
|
||||||
required: { exitCode: 7 },
|
required: { exitCode: 7 },
|
||||||
actual: { exitCode: 7 },
|
actual: { exitCode: 7 },
|
||||||
|
evidence: { subject: 'meta-fixture' },
|
||||||
reasonPattern: 'SECOND_REASON',
|
reasonPattern: 'SECOND_REASON',
|
||||||
environment: { SECOND_REASON: 'SECOND_REASON' },
|
environment: { SECOND_REASON: 'SECOND_REASON' },
|
||||||
});
|
});
|
||||||
@@ -546,10 +768,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert
|
|||||||
);
|
);
|
||||||
assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale');
|
assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale');
|
||||||
await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource);
|
await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource);
|
||||||
await copyFile(
|
|
||||||
path.join(process.cwd(), 'scripts', 'gate-history.mjs'),
|
|
||||||
path.join(alteredScripts, 'gate-history.mjs'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
@@ -559,7 +777,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert
|
|||||||
root,
|
root,
|
||||||
'--manifest',
|
'--manifest',
|
||||||
'gates/gates.manifest.json',
|
'gates/gates.manifest.json',
|
||||||
'--skip-history',
|
|
||||||
],
|
],
|
||||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,12 +13,8 @@ const expectedTriggers = `when:
|
|||||||
- event: push
|
- event: push
|
||||||
branch: main`;
|
branch: main`;
|
||||||
const expectedGateStep = ` image: *node_image
|
const expectedGateStep = ` image: *node_image
|
||||||
# Woodpecker's shallow marker makes merge-base reject even present parents;
|
|
||||||
# full history is required for activation ancestry and manifest provenance.
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- apk add --no-cache bubblewrap
|
|
||||||
- if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi
|
|
||||||
- pnpm gate:verify
|
- pnpm gate:verify
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
@@ -52,7 +48,11 @@ export function assertUnprivilegedGateStep(pipeline) {
|
|||||||
// Closed textual construction by design: accepting arbitrary YAML syntax here
|
// Closed textual construction by design: accepting arbitrary YAML syntax here
|
||||||
// would require a duplicate-key-preserving parser. Exact equality rejects all
|
// would require a duplicate-key-preserving parser. Exact equality rejects all
|
||||||
// extra keys, quoted/escaped key spellings, aliases, and mapping merges.
|
// extra keys, quoted/escaped key spellings, aliases, and mapping merges.
|
||||||
assert.equal(matches[0][1].trimEnd(), expectedGateStep, 'gate-verify step must match closed unprivileged construction');
|
assert.equal(
|
||||||
|
matches[0][1].trimEnd(),
|
||||||
|
expectedGateStep,
|
||||||
|
'gate-verify step must match closed unprivileged construction',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('package.json exposes the canonical gate:verify command', async () => {
|
test('package.json exposes the canonical gate:verify command', async () => {
|
||||||
@@ -77,7 +77,10 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger
|
|||||||
' "<<": *privileged-step\n',
|
' "<<": *privileged-step\n',
|
||||||
];
|
];
|
||||||
for (const addition of additions) {
|
for (const addition of additions) {
|
||||||
const changed = pipeline.replace(' gate-verify:\n image:', ` gate-verify:\n${addition} image:`);
|
const changed = pipeline.replace(
|
||||||
|
' gate-verify:\n image:',
|
||||||
|
` gate-verify:\n${addition} image:`,
|
||||||
|
);
|
||||||
assert.throws(() => assertUnprivilegedGateStep(changed));
|
assert.throws(() => assertUnprivilegedGateStep(changed));
|
||||||
}
|
}
|
||||||
const privilegedInstall = pipeline.replace(
|
const privilegedInstall = pipeline.replace(
|
||||||
@@ -85,10 +88,7 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger
|
|||||||
' install:\n privileged: true\n image:',
|
' install:\n privileged: true\n image:',
|
||||||
);
|
);
|
||||||
const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`;
|
const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`;
|
||||||
const noPullRequest = pipeline.replace(
|
const noPullRequest = pipeline.replace(' - event: [pull_request, manual]', ' - event: manual');
|
||||||
' - event: [pull_request, manual]',
|
|
||||||
' - event: manual',
|
|
||||||
);
|
|
||||||
const filteredPullRequest = pipeline.replace(
|
const filteredPullRequest = pipeline.replace(
|
||||||
' - event: [pull_request, manual]',
|
' - event: [pull_request, manual]',
|
||||||
' - event: [pull_request, manual]\n path: [scripts/**]',
|
' - event: [pull_request, manual]\n path: [scripts/**]',
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { verifyRegistry } from '../gate-verify.mjs';
|
||||||
|
|
||||||
|
let root;
|
||||||
|
let manifest = 'gates/gates.manifest.json';
|
||||||
|
let structureOnly = false;
|
||||||
|
for (let index = 0; index < process.argv.slice(2).length; index += 1) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const value = args[index];
|
||||||
|
if (value === '--root') root = path.resolve(args[++index]);
|
||||||
|
else if (value === '--manifest') manifest = args[++index];
|
||||||
|
else if (value === '--structure-only') structureOnly = true;
|
||||||
|
else throw new Error(`unknown fixture-runner option: ${value}`);
|
||||||
|
}
|
||||||
|
if (!root) throw new Error('fixture runner requires --root');
|
||||||
|
const { failures, observations } = await verifyRegistry({
|
||||||
|
root,
|
||||||
|
manifest,
|
||||||
|
structureOnly,
|
||||||
|
fixtureProfile: true,
|
||||||
|
});
|
||||||
|
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
||||||
|
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||||
|
if (failures.length > 0) process.exitCode = 1;
|
||||||
Reference in New Issue
Block a user