Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74a692fd81 | ||
|
|
9abf3a5124 | ||
|
|
12b37c097f | ||
|
|
d4d32a80b2 | ||
|
|
c703cc50eb | ||
|
|
3d2b712355 | ||
|
|
245e0c427d |
@@ -16,17 +16,17 @@
|
|||||||
> the PR head, squash merge to `next`, and acceptance evidence in notes.
|
> the PR head, squash merge to `next`, and acceptance evidence in notes.
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). |
|
| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). |
|
||||||
| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). |
|
| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). |
|
||||||
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
|
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
|
||||||
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
|
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
|
||||||
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
|
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
|
||||||
| RI-3-001 | in-progress | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | |
|
| RI-3-001 | done | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | PR #1302 (head e06a47fac591): CI green (2484), independent review APPROVED (Gitea review 187, fargo seat, 2026-08-18) — 54 rows / 21 canonical checks / dispositions 43-2-9-0 verified by row-count and code spot-checks. Merged by fargo at pinned head. |
|
||||||
| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | |
|
| RI-3-002 | done | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | | PR #1308 (head 68279d61): CI green (2506), independent review APPROVED (Gitea review 188, fred, seven mutations incl. vacuous-pass + stage-removal). Merged by fargo at pinned head → next @ 245e0c4. Follow-up #1309 (digest wording). |
|
||||||
| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. |
|
| RI-4-001 | done | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. | PR #1294 (head 8d258e1d): CI green (2466), review 174. Merged 2026-08-18 overnight wave → next @ d92de53. |
|
||||||
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
|
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
|
||||||
| RI-V-001 | not-started | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | |
|
| RI-V-001 | in-progress | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | | Evidence pack live on branch docs/ri-050-release-evidence — all five requirements evidenced; registry credential fixed (jarvis, #1275 c23239) and PROVEN green: pipeline 2517 (retry of 2512, identical commit) all steps green incl. build-gateway; pack PR next, then topher review + merge, close #1275. |
|
||||||
|
|
||||||
## Dispatch waves (max 2 parallel workers)
|
## Dispatch waves (max 2 parallel workers)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# RI-050 Release Evidence Pack (alpha 0.0.50 release-integrity floor)
|
||||||
|
|
||||||
|
> Status: **DRAFT — proof complete, awaiting review + merge**. All five normative requirements (RI-N1..N5) merged to `next` behind the live gate. Registry credential fixed 2026-08-18 23:47Z and **proven end-to-end**: push pipeline **2517** (retry of failed 2512 at the identical commit d4d32a8, only the secret changed between runs) — all steps green including `build-gateway`. Remaining for closure: this pack PR reviewed (topher), merged to `next`, its own push pipeline green, #1275 closed. Last updated 2026-08-19 by fargo (day-takeover orchestrator).
|
||||||
|
> Card: RI-V-001. All sections marked ⏳ pending their card's merge. Normative source:
|
||||||
|
> `docs/PRD.md` § Release Integrity Workstream (#1275).
|
||||||
|
|
||||||
|
## RI-N1 — Canonical terminal verification + exact-commit publish gate
|
||||||
|
|
||||||
|
| exhibit | evidence | where |
|
||||||
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
|
||||||
|
| Gate live, fail-closed | Push pipeline **2486**: `verify` ran at exact commit, FAILED on a real latent defect (gateway cross-user-isolation cleanup on the no-DB path), and `build` / `publish-npm` / `build-gateway` were all **skipped**. First push in repo history that did not publish ungated (prior ungated publishes beside failing builds: 2439, 2462, 2482). | Woodpecker repo 47 pipeline 2486 |
|
||||||
|
| Gate-caught defect fixed | PR **#1304** (afterAll honors `dbAvailable`; both paths verified: dead-port 28 skipped + file passes; live-5433 28 passed). Review 180 (fred). | PR #1304 |
|
||||||
|
| First gated green npm publish | Push pipeline **2488** (post-#1304): `verify` GREEN → `build` GREEN → `publish-next-npm` GREEN, all publish effects behind the gate. | Woodpecker pipeline 2488 |
|
||||||
|
| Negative controls | PR **#1305**: structural DAG tests (S1 missing edge, S2 renamed effect incl. command-based npm/kaniko detection, S3 detach, S4 failure:ignore/success override, S5 when-filter, S6 HEAD-mover between verify and publish with legitimate-recheckout positive control, S7 removal) + subset-stage composition control in verify-release.test.mjs. Mutation-verified by the dispatching seat in both directions (true bypass → S1 assertion fires; non-bypass edit → correctly green). Scripts tests 20/20, CI 2490 green. | PR #1305 |
|
||||||
|
| ✅ Canonical command | `scripts/verify-release.mjs` (stage table pinned to ci.yml by checked-in test). Merged with #1277; now also invokes the RI-N4 evaluator via its `quality-rails` stage (#1308). | `scripts/verify-release.mjs` |
|
||||||
|
|
||||||
|
## RI-N2 — Forge + MACP fail-closed (typed explicit simulation)
|
||||||
|
|
||||||
|
- ✅ Forge: PR **#1278** merged (head 4917df1f; CI 2477; review 184 fred at pinned head — prior review 172 dismissed by rebase, correctly re-taken).
|
||||||
|
- ✅ MACP: PR **#1293** merged (head 2097379e; CI 2465; review 173).
|
||||||
|
- ✅ Post-merge behavior docs: PR **#1299** merged (head 8a405b14; CI 2497; review 186 fargo at pinned head — legitimate independent seat; merged 2026-08-18 with --expect-head pin, content-verified on next @ ff45f7b).
|
||||||
|
|
||||||
|
## RI-N3 — PRD authority
|
||||||
|
|
||||||
|
- ✅ PR **#1294** merged (head 8d258e1d; CI 2466; review 174).
|
||||||
|
|
||||||
|
## RI-N4 — Quality-rails evaluator
|
||||||
|
|
||||||
|
- ✅ Probe inventory: PR **#1302** merged (head e06a47fac59; CI 2484; review 187 fargo at pinned head; 54 rows / 21 canonical checks / dispositions 43-2-9-0 row-count-verified; merged 2026-08-18, content-verified on next @ 6435089).
|
||||||
|
- ✅ TS evaluator absorbs shell probes: PR **#1308** merged (head 68279d61; CI 2506; review 188 fred at pinned head — seven targeted mutations, seven detections, incl. the vacuous-pass hole M1 and stage-removal M7). Evaluator: typed fail-closed verdicts, digested versioned definitions, per-subject sets; QC-19 absorbed (verbatim-list parity oracle), QC-20 as thin adapter (verify.sh unmodified); verify-release `quality-rails` stage wired (RI-N1 consumes the evaluator). Worker-produced, independently verified by the dispatching seat (quality-rails 40/40 incl. sabotage control 6-failed/34-passed restored sha-verified; root build 25/25; typecheck 45/45).
|
||||||
|
|
||||||
|
## RI-N5 — Consequence-aware stale UI
|
||||||
|
|
||||||
|
- ✅ PR **#1300** merged (head a337d787; CI 2481; review 179). Web suite 199 → 281 tests (failure matrix + negative controls), independently re-run by the dispatching seat before merge.
|
||||||
|
|
||||||
|
## Known-open infrastructure item (not a card)
|
||||||
|
|
||||||
|
Gateway/ci-base **image** pushes fail on registry credentials: Woodpecker repo
|
||||||
|
secrets `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` are rejected by the Gitea
|
||||||
|
container registry (explicit `UNAUTHORIZED` at `/v2/token`; pipeline 2494 after
|
||||||
|
PR #1306 corrected the secret references — previously masked as an ambiguous
|
||||||
|
push-permission error since at least 2439). Requires a package-scoped token
|
||||||
|
(Jason). The npm publish path is green and gated; this item tracks image pushes
|
||||||
|
only and predates the RI-050 floor.
|
||||||
|
|
||||||
|
**Update 2026-08-18 (fargo):** Jason set new secret values ~17:25Z; pipeline
|
||||||
|
**2507** (the #1308 merge push, first after the update, 18:0xZ) still fails
|
||||||
|
`build-gateway` with the identical `UNAUTHORIZED`. Read-only isolation (no
|
||||||
|
secrets read, no CI retries): the registry endpoint and auth mechanism are
|
||||||
|
HEALTHY — a valid Gitea token via basic-auth mints a JWT at `/v2/token` (200),
|
||||||
|
bad credentials 401 cleanly. Therefore the failure is isolated to the secret
|
||||||
|
VALUES, not the endpoint or pipeline. Most likely shape error (labeled guess):
|
||||||
|
the registry authenticates username + **API token with package scope**, not
|
||||||
|
username + login password; if REGISTRY_PASSWORD holds a login password rather
|
||||||
|
than a minted token value, `/v2/token` 401s exactly as observed. npm publishes
|
||||||
|
remained green in 2507; every publish step except the image push is gated and
|
||||||
|
green.
|
||||||
|
|
||||||
|
**Resolution 2026-08-18 23:47Z — FIXED on the Gitea server (jarvis, #1275
|
||||||
|
comment 23239).** Root cause was neither scope nor a missing token:
|
||||||
|
`REGISTRY_USERNAME` held `mosaic`, the **pre-rename org name**. Gitea's rename
|
||||||
|
redirect covers API/web paths but not Basic-auth username lookup, and
|
||||||
|
`mosaicstack` is an organization, which has no password — the pair could never
|
||||||
|
authenticate. Fix: `REGISTRY_USERNAME`=`woodpecker` (the existing service
|
||||||
|
account, Gitea user 41, already in `ci-publish`) and `REGISTRY_PASSWORD`= a
|
||||||
|
newly minted `write:package`-only token (`gitea admin user generate-access-token`
|
||||||
|
in the Gitea container; minting with a token is forbidden server-side). Events
|
||||||
|
`[push, tag]` preserved. Verified **without a pipeline run**:
|
||||||
|
`POST /v2/<pkg>/blobs/uploads/` opened then cancelled a session — **202** on
|
||||||
|
all four kaniko destinations (gateway, appservice, web, ci-base), anonymous
|
||||||
|
control **401**, wrong-owner control **401**. The earlier "Requires a
|
||||||
|
package-scoped token (Jason)" expectation is superseded: the defect was a
|
||||||
|
stale value from the org rename, not a scope grant Jason owed.
|
||||||
|
|
||||||
|
**Proof 2026-08-19 ~00:2xZ (fargo): pipeline 2517 green at build-gateway.**
|
||||||
|
Woodpecker retry of 2512 — identical commit d4d32a8, identical pipeline
|
||||||
|
config, only the server-side secret changed between runs — went green on
|
||||||
|
every step (clone, install, verify, build, publish-next-npm,
|
||||||
|
**build-gateway**). A/B at the same commit isolates the credential as the
|
||||||
|
variable; the stored value is byte-intact. Retry was serialized (sole run in
|
||||||
|
flight; merge-purpose CI queue guard had blocked on 2512's terminal failure
|
||||||
|
at the `next` head, which this retry also clears). The item is closed.
|
||||||
|
|
||||||
|
## Process record (audit trail)
|
||||||
|
|
||||||
|
- Merges executed under the jarvis principal (topher seat; identity provisioning
|
||||||
|
pending) via the Gitea API replicating `pr-merge.sh` semantics (head-pin +
|
||||||
|
squash + keep branch): `pr-merge.sh` hard-codes `main`-only targets and cannot
|
||||||
|
express this repo's `next` trunk — wrapper gap captured to OpenBrain
|
||||||
|
(id 9db7a95a) and to the framework queue.
|
||||||
|
- Reviews tonight: 175/178 (zane's #1298, both heads, by topher); 176/177/179/
|
||||||
|
180/181/182 (fred) — cross-review rule (producer ≠ reviewer) held on every
|
||||||
|
merge: producers were pi workers / zane; reviewers were the other seat.
|
||||||
|
- CI contention note: concurrent PR pipelines on the single CI agent can time
|
||||||
|
out the web SPA suite (measured 2470/2472 vs serialized 2475/2476/2477);
|
||||||
|
serialize retries when the queue is busy.
|
||||||
|
|
||||||
|
## Process record — 2026-08-18 day takeover (fargo)
|
||||||
|
|
||||||
|
- Takeover directive: Jason (via jarvis router + both seats' handoff documents,
|
||||||
|
relayed verbatim over comms). First-move conflict between the two handoffs
|
||||||
|
(zane: doctor PR first; topher: review-queue first) resolved on dependency
|
||||||
|
grounds per jarvis's read — topher's order won; zane's finding-2 doctor PR
|
||||||
|
(upgraded by fred's measurement) remains queued, nothing depends on it.
|
||||||
|
- Reviews 186 (#1299) + 187 (#1302): fargo, at pinned heads, as the legitimate
|
||||||
|
independent seat (topher dispatched both producers; cross-review rule held).
|
||||||
|
Both merged with --expect-head pinning via the REPO-COPY pr-merge.sh
|
||||||
|
(allows next; the installed copy still lags — zane's route, not the raw-API
|
||||||
|
break-glass), each preceded by ci-queue-wait -B next -R mosaicstack/stack.
|
||||||
|
CI green at both heads (2497, 2484). Merges content-verified on the shipping
|
||||||
|
ref (TASKS anchors at ff45f7b / 6435089).
|
||||||
|
- RI-3-002: one pi worker (zai/glm-5.3:high), independently verified by the
|
||||||
|
dispatching seat before push; PR #1308 reviewed by fred (188, seven
|
||||||
|
mutations incl. vacuous-pass and stage-removal) and merged head-pinned at
|
||||||
|
68279d61 → next @ 245e0c4.
|
||||||
|
- Registry-credential isolation measurement (above) performed read-only; no
|
||||||
|
secret values read, no retry-pushes against CI.
|
||||||
|
- One reviewer-scope disclosure (fred, review 188): fred's approval explicitly
|
||||||
|
did NOT re-run root build/typecheck/mosaic-vitest — those remain the
|
||||||
|
dispatching seat's numbers. The changed-package suites, verify-release
|
||||||
|
suite, and seven mutations were fred's own.
|
||||||
@@ -43,7 +43,7 @@ fleet commits, pushes, and opens PRs under one identity — with no cryptographi
|
|||||||
separation between an author and a reviewer.
|
separation between an author and a reviewer.
|
||||||
|
|
||||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||||
identity** before falling back to the shared account:
|
identity**:
|
||||||
|
|
||||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||||
@@ -51,12 +51,54 @@ identity** before falling back to the shared account:
|
|||||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||||
request.
|
request.
|
||||||
|
|
||||||
If the resolved identity has a token file at
|
### Which store a credential is read from
|
||||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
|
||||||
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
The store is chosen by what the identity **is**, not by which file happens to exist first:
|
||||||
token file present, both tools fall through to the existing shared-account path
|
|
||||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
| The identity | Its credential is read from |
|
||||||
tokens.
|
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||||
|
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
|
||||||
|
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
|
||||||
|
|
||||||
|
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
|
||||||
|
`packages/mosaic/src/fleet/brain-home.ts` performs.
|
||||||
|
|
||||||
|
**There is no precedence between the two stores and no fallback from one to the other.**
|
||||||
|
A seat whose slot is empty is refused even when a same-named token sits in the framework
|
||||||
|
store. One credential lives in exactly one location: a second copy is drift rather than
|
||||||
|
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
|
||||||
|
revoked token and sends whoever debugs it to the wrong place.
|
||||||
|
|
||||||
|
### What happens when nothing resolves
|
||||||
|
|
||||||
|
| identity resolves | token in its store | host runs a fleet | result |
|
||||||
|
| ----------------- | ------------------ | ----------------- | ------------------------- |
|
||||||
|
| yes | yes | — | that identity + token |
|
||||||
|
| yes | no | — | **fail closed** |
|
||||||
|
| no | — | yes | **fail closed** |
|
||||||
|
| no | — | no | shared account, unchanged |
|
||||||
|
|
||||||
|
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
|
||||||
|
uses to decide a brain is active.
|
||||||
|
|
||||||
|
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
|
||||||
|
names the identity, its source, the store it resolved to and the path that was expected,
|
||||||
|
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
|
||||||
|
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
|
||||||
|
The git operation fails; nothing is attributed to anyone.
|
||||||
|
|
||||||
|
The shared-account fallback that used to cover these two cases is why a PR could be
|
||||||
|
authored, commented and merged under an account whose owner did not open it — every seat
|
||||||
|
shared one identity, so the record could not be traced back afterwards. An
|
||||||
|
under-provisioned agent is refused rather than handed the most privileged account
|
||||||
|
available.
|
||||||
|
|
||||||
|
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
|
||||||
|
account still answers, so this is a no-op for an operator who has not provisioned per-slot
|
||||||
|
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
|
||||||
|
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
|
||||||
|
environment variable that restores the fallback; one would reintroduce exactly the
|
||||||
|
substitution this removes.
|
||||||
|
|
||||||
### Enabling it for a clone
|
### Enabling it for a clone
|
||||||
|
|
||||||
|
|||||||
@@ -507,37 +507,69 @@ get_gitea_token() {
|
|||||||
|
|
||||||
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||||
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||||
# stored per-slot token for this host, act AS that agent so API tooling
|
# stored credential for this host, act AS that agent so API tooling
|
||||||
# (pr-create, issue-create, …) authors under the right identity — matching the
|
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||||
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
# git credential helper, which this block deliberately mirrors.
|
||||||
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
local _ident_src="MOSAIC_GIT_IDENTITY"
|
local _ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
if [[ -z "$_ident" ]]; then
|
if [[ -z "$_ident" ]]; then
|
||||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" ]]; then
|
|
||||||
|
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
|
||||||
local _idpfx=""
|
local _idpfx=""
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||||
esac
|
esac
|
||||||
if [[ -n "$_idpfx" ]]; then
|
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||||
|
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||||
|
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
|
|
||||||
|
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
|
||||||
|
# Credential store selection: an identity is a SEAT or a SERVICE, and which
|
||||||
|
# one it is decides where its credential lives. No precedence between the
|
||||||
|
# two stores and no fallback from one to the other — a seat with an empty
|
||||||
|
# slot fails loud rather than reading a service credential of the same name.
|
||||||
|
# One credential, one location: two copies diverge, and the stale copy fails
|
||||||
|
# in a way that reads as a revoked token rather than as drift.
|
||||||
|
local _idtok _ident_kind
|
||||||
|
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
|
||||||
|
_ident_kind="seat"
|
||||||
|
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
|
||||||
|
else
|
||||||
|
_ident_kind="service identity"
|
||||||
|
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
|
fi
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||||
# but no per-slot token exists for THAT identity. Refuse to fall through to the
|
# but no credential exists for THAT identity. Refuse to fall through to the
|
||||||
# shared/default credential loader below — silently borrowing another slot's token
|
# shared/default credential loader below — silently borrowing another identity's
|
||||||
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
|
||||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
|
||||||
# caller aborts loudly rather than acting as the wrong identity.
|
# instead so the caller aborts loudly rather than acting as the wrong identity.
|
||||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||||
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
|
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
|
||||||
|
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
|
||||||
|
# every record must name the agent that made it, so an unattributable request is
|
||||||
|
# refused rather than handed the shared account. `fleet/agents` existing is the
|
||||||
|
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
|
||||||
|
# keeps the shared path below unchanged: there the shared account is the operator's
|
||||||
|
# own and there is no attribution to lose.
|
||||||
|
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
|
||||||
|
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
|
||||||
|
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
|
||||||
|
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||||
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||||
#
|
#
|
||||||
# Install (one-time, per clone or globally):
|
# Install (one-time, per clone or globally):
|
||||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
|
||||||
#
|
#
|
||||||
# Per-agent Gate-16 identity (author != reviewer separation):
|
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||||
#
|
#
|
||||||
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||||
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
# This helper used to end by emitting the shared account's token for any request
|
||||||
# (credential.username / URL). When the resolved identity has a matching
|
# it could not resolve to an identity. A seat with no identity, or with an
|
||||||
# per-agent token file, use it instead of the shared account. Backward
|
# identity whose token was never provisioned, therefore received the most
|
||||||
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
# privileged credential configured on the host — silently, and indistinguishably
|
||||||
|
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||||
|
# was attributed to that shared account, so author != reviewer separation was
|
||||||
|
# unenforceable and the true actor was unrecoverable after the fact.
|
||||||
|
#
|
||||||
|
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||||
|
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||||
|
# principal is not.
|
||||||
|
#
|
||||||
|
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||||
|
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||||
|
# username git supplies on stdin
|
||||||
|
# store : chosen by what the identity IS, with no precedence and no
|
||||||
|
# cross-store fallback (see "Credential store selection" below)
|
||||||
|
# hit : emit username + password, exit 0
|
||||||
|
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||||
|
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||||
|
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||||
|
# remotes handled by another helper)
|
||||||
|
#
|
||||||
|
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||||
|
# and no identity requested still gets the shared account, because on such a host
|
||||||
|
# the shared account is the operator's own and there is no attribution to lose.
|
||||||
|
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||||
|
# the shared fallback is refused there.
|
||||||
|
#
|
||||||
|
# A token is never written to stderr, to the escalation record, or to any log.
|
||||||
|
|
||||||
[ "$1" = "get" ] || exit 0
|
[ "$1" = "get" ] || exit 0
|
||||||
|
|
||||||
host=""; username_in=""
|
host=""; username_in=""
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[ -z "$line" ] && break
|
[ -z "$line" ] && break
|
||||||
@@ -24,46 +51,170 @@ while IFS= read -r line; do
|
|||||||
username=*) username_in=${line#username=};;
|
username=*) username_in=${line#username=};;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
|
||||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
# declined quietly — another helper owns it, and refusing would break it.
|
||||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
|
||||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
|
||||||
ident="$MOSAIC_GIT_IDENTITY"
|
|
||||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
|
||||||
[ -z "$ident" ] && ident="$username_in"
|
|
||||||
if [ -n "$ident" ]; then
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.uscllc.com) idpfx=gitea-usc;;
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
*) idpfx="";;
|
*) exit 0;;
|
||||||
esac
|
esac
|
||||||
if [ -n "$idpfx" ]; then
|
|
||||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
|
if [ -z "$ident" ]; then
|
||||||
|
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
|
ident_src="git config mosaic.gitIdentity"
|
||||||
|
fi
|
||||||
|
if [ -z "$ident" ]; then
|
||||||
|
ident="$username_in"
|
||||||
|
ident_src="the username git supplied"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Credential store selection ────────────────────────────────────────────────
|
||||||
|
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||||
|
# its credential lives. There is no precedence rule between the two stores and no
|
||||||
|
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||||
|
# than reading a service credential that happens to share its name.
|
||||||
|
#
|
||||||
|
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||||
|
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||||
|
# service — it does not
|
||||||
|
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||||
|
#
|
||||||
|
# One credential, one location. Two copies of one credential diverge, and the
|
||||||
|
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||||
|
#
|
||||||
|
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||||
|
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||||
|
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
|
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||||
|
|
||||||
|
idtok=""; ident_kind=""
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||||
|
ident_kind="seat"
|
||||||
|
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||||
|
else
|
||||||
|
ident_kind="service identity"
|
||||||
|
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||||
|
fi
|
||||||
if [ -r "$idtok" ]; then
|
if [ -r "$idtok" ]; then
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=$(cat "$idtok")"
|
echo "password=$(cat "$idtok")"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
case "$host" in
|
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||||
git.uscllc.com) svc=gitea-usc;;
|
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
# is active. Where there are seats, records must be attributable, so an
|
||||||
*) exit 0;;
|
# unresolvable request is refused instead of borrowing the shared account.
|
||||||
esac
|
fleet_present=0
|
||||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||||
# of where the framework installer places tools/ under $HOME — mirrors
|
|
||||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck source=../_lib/credentials.sh
|
# shellcheck source=../_lib/credentials.sh
|
||||||
source "$script_dir/../_lib/credentials.sh"
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||||
# GITEA_USER is not populated by load_credentials (it only exports
|
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
# the password field, not from the username string, so any non-empty
|
||||||
# not from the username string, so any non-empty placeholder works here — this
|
# placeholder works — deliberately NOT a real account name, since framework
|
||||||
# is deliberately NOT a real account name (framework files must stay
|
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||||
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
|
||||||
echo "username=${GITEA_USER:-git}"
|
echo "username=${GITEA_USER:-git}"
|
||||||
echo "password=$GITEA_TOKEN"
|
echo "password=$GITEA_TOKEN"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||||
|
if [ -z "$ident" ]; then
|
||||||
|
reason="no-identity"
|
||||||
|
else
|
||||||
|
reason="no-token-for-identity"
|
||||||
|
fi
|
||||||
|
|
||||||
|
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||||
|
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# The escalation RECORD is durable and unconditional; any notification built on
|
||||||
|
# top of it is best-effort. Record and alert are deduplicated separately — a cap
|
||||||
|
# on the alert alone lets the spool grow without bound exactly while the operator
|
||||||
|
# is being told nothing, so the louder the failure the quieter it gets.
|
||||||
|
#
|
||||||
|
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||||
|
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||||
|
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||||
|
# and a spool that silently stops parsing is worse than no spool, because the
|
||||||
|
# operator only discovers it while reading the record that explains an outage.
|
||||||
|
json_escape() {
|
||||||
|
local s=$1
|
||||||
|
s=${s//\\/\\\\}
|
||||||
|
s=${s//\"/\\\"}
|
||||||
|
s=${s//$'\t'/\\t}
|
||||||
|
s=${s//$'\r'/\\r}
|
||||||
|
s=${s//$'\n'/\\n}
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||||
|
spool_record=""
|
||||||
|
if mkdir -p "$spool" 2>/dev/null; then
|
||||||
|
chmod 700 "$spool" 2>/dev/null
|
||||||
|
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||||
|
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||||
|
if [ ! -e "$dedupe" ]; then
|
||||||
|
: > "$dedupe" 2>/dev/null
|
||||||
|
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||||
|
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||||
|
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||||
|
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||||
|
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||||
|
>> "$spoolfile" 2>/dev/null
|
||||||
|
chmod 600 "$spoolfile" 2>/dev/null
|
||||||
|
fi
|
||||||
|
# Name the record only if one is actually on disk. Printing the path
|
||||||
|
# unconditionally sends the operator to a file that does not exist on exactly
|
||||||
|
# the hosts where the spool could not be created.
|
||||||
|
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||||
|
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
git-credential-mosaic: REFUSED (fail-closed).
|
||||||
|
host : ${host}
|
||||||
|
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||||
|
reason : ${reason}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
expected : ${idtok}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||||
|
account: that fallback makes every record it creates attributable to one
|
||||||
|
principal, which is unrecoverable once a pull request has merged under it.
|
||||||
|
|
||||||
|
Fix (pick one):
|
||||||
|
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||||
|
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||||
|
Then provision that identity's credential at the path named above. An identity
|
||||||
|
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||||
|
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||||
|
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||||
|
|
||||||
|
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||||
|
to provision one.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$spool_record" ]; then
|
||||||
|
echo " record: ${spool_record}" >&2
|
||||||
|
else
|
||||||
|
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||||
# resolution (Gate-16 author≠reviewer separation).
|
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
|
||||||
#
|
#
|
||||||
# Covers:
|
# Covers:
|
||||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||||
# 2. Correct per-slot token file path chosen per host
|
# 2. Correct token file path chosen per host
|
||||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
# 3. Per-slot token present -> emits that identity + token.
|
# 3. Credential store selection: an identity with a directory under
|
||||||
# 4. Per-slot token absent -> falls back to the shared account
|
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
# slot; any other identity is a SERVICE and is read from the framework
|
||||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
# store. No precedence between them and NO fallback from one to the other.
|
||||||
|
# 4. Fail-closed: an identity that resolves but has no credential is REFUSED —
|
||||||
|
# no output, nonzero exit, a stderr diagnostic, and a durable spool record.
|
||||||
|
# The shared account is never emitted in its place.
|
||||||
|
# 5. Fail-closed: no identity resolvable on a host that runs a fleet is also
|
||||||
|
# REFUSED, because records made there must name the agent that made them.
|
||||||
|
# 6. Backward compatibility, the one surviving fallback: no identity AND no
|
||||||
|
# fleet -> shared account, unchanged. On such a host the shared account is
|
||||||
|
# the operator's own and there is no attribution to lose.
|
||||||
|
# 7. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||||
|
# 8. Non-"get" verb -> exits 0 with no output.
|
||||||
#
|
#
|
||||||
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
@@ -21,6 +31,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
FAKE_HOME="$WORK_DIR/home"
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BRAIN_DIR="$WORK_DIR/brain"
|
||||||
|
SPOOL_DIR="$WORK_DIR/spool"
|
||||||
|
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
||||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||||
@@ -28,10 +41,10 @@ REPO_DIR="$WORK_DIR/repo"
|
|||||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
mkdir -p "$SVC_STORE" \
|
||||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||||
"$REPO_DIR"
|
"$REPO_DIR" "$BRAIN_DIR"
|
||||||
|
|
||||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
chmod +x "$HELPER"
|
chmod +x "$HELPER"
|
||||||
@@ -68,7 +81,8 @@ run_helper() {
|
|||||||
local host="$1" username_in="$2"; shift 2
|
local host="$1" username_in="$2"; shift 2
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||||
|
bash "$HELPER" get <<EOF
|
||||||
host=$host
|
host=$host
|
||||||
username=$username_in
|
username=$username_in
|
||||||
|
|
||||||
@@ -76,20 +90,61 @@ EOF
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
||||||
|
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
||||||
|
# that actually catches a regression to the old behavior — NO shared token value
|
||||||
|
# anywhere in the output. Checking only the exit code would pass against a helper
|
||||||
|
# that emitted the shared credential and then exited 1.
|
||||||
|
assert_fail_closed() {
|
||||||
|
local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
|
||||||
|
local stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
local stdout
|
||||||
|
stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
local stderr
|
||||||
|
stderr=$(cat "$stderr_file")
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$stdout" ]]; then
|
||||||
|
echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
||||||
|
echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
|
||||||
|
echo "$stdout$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
|
||||||
|
echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
|
||||||
|
echo "$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$stderr" != *"$host"* ]]; then
|
||||||
|
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
||||||
|
echo "$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
|
||||||
# (backward-compat: unchanged behavior when nothing is configured).
|
# shared account, unchanged. This is the only surviving fallback.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
out=$(run_helper "git.mosaicstack.dev" "")
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
|
||||||
# that identity + token wins over the shared account.
|
# framework store -> that identity + token wins over the shared account.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
@@ -97,7 +152,7 @@ assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-to
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||||
@@ -106,54 +161,210 @@ assert_eq "git-config beats username: password" "password=agentB-mosaicstack-tok
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||||
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
# 5. Correct token PATH is chosen per host: same agent id, different host
|
||||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
# prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
|
||||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
|
||||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
|
||||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
|
||||||
out=$(run_helper "git.uscllc.com" "agentD")
|
out=$(run_helper "git.uscllc.com" "agentD")
|
||||||
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||||
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
|
||||||
# leak the usc token across hosts.
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
|
||||||
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
|
||||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
|
||||||
# remotes, e.g. github.com via a different credential helper).
|
# NOT borrow the shared account, and must NOT leak the same agent's token
|
||||||
|
# for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
|
||||||
|
"git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
|
||||||
|
# The agent's own usc token must not appear either.
|
||||||
|
: > "$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
|
||||||
|
echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_fail_closed "unknown identity refuses (shared account never substituted)" \
|
||||||
|
"git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. A refusal leaves a durable spool record, and that record contains no token.
|
||||||
|
# The stderr diagnostic is transient; the record is what an operator reads
|
||||||
|
# afterwards, so it must exist independently of anyone watching the terminal.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
|
||||||
|
if [[ -z "$spool_file" ]]; then
|
||||||
|
echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
spool_body=$(cat "$spool_file")
|
||||||
|
assert_eq "spool record names the refused identity" "1" \
|
||||||
|
"$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
|
||||||
|
if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
|
||||||
|
echo "FAIL: spool record contains a token value:" >&2
|
||||||
|
echo "$spool_body" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||||
|
# from its OWN secrets/ slot, not from the framework store.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||||
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
|
||||||
|
# exists for. seatF is a seat (it has a directory) with an EMPTY slot, while
|
||||||
|
# a framework-store token of the identical name is present and readable.
|
||||||
|
# The helper must refuse rather than read it: one credential, one location,
|
||||||
|
# and a seat that reads a same-named service credential is exactly the
|
||||||
|
# silent-substitution failure the fail-closed rule removes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||||
|
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
||||||
|
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
||||||
|
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
: > "$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||||
|
echo "FAIL: cross-store fallback — a seat read the framework store's same-named token" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
# Control: that framework-store token IS readable, so the refusal above is the
|
||||||
|
# store rule firing and not an unreadable file. A non-seat identity pointed at
|
||||||
|
# the same file gets it.
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||||
|
assert_eq "control — same file IS readable for a non-seat identity" \
|
||||||
|
"password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
|
||||||
|
# seats exist, an unattributable request is refused instead of receiving
|
||||||
|
# the shared account. Contrast with case 1, which is the same request on a
|
||||||
|
# host with no fleet and still returns the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
assert_fail_closed "no identity on a fleet host refuses" \
|
||||||
|
"git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||||
|
# the fleet gate must fire on the default path too, not only on an
|
||||||
|
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||||
|
# same call now refuses, which also proves case 1 was measuring the
|
||||||
|
# no-fleet branch rather than passing for an unrelated reason.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||||
|
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
|
||||||
|
"git.mosaicstack.dev" "" "no-identity"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||||
|
# remotes, e.g. github.com via a different credential helper). A fleet host
|
||||||
|
# must not refuse a host this helper does not own.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "github.com" "agentA")
|
out=$(run_helper "github.com" "agentA")
|
||||||
assert_eq "unknown host: no output" "" "$out"
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
|
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
# protocol: this helper only implements get).
|
# protocol: this helper only implements get).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
host=git.mosaicstack.dev
|
host=git.mosaicstack.dev
|
||||||
username=agentA
|
username=no-such-agent
|
||||||
password=whatever
|
password=whatever
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
assert_eq "store verb: no output" "" "$store_out"
|
assert_eq "store verb: no output" "" "$store_out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. The escalation record is machine-readable even when a field carries a
|
||||||
|
# quote or a backslash. A cwd is arbitrary operator text; an unescaped one
|
||||||
|
# silently turns the spool into unparseable JSONL, and the operator only
|
||||||
|
# finds out while reading the record that explains an outage.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
hostile_dir="$WORK_DIR/we\"ird\\dir"
|
||||||
|
mkdir -p "$hostile_dir"
|
||||||
|
hostile_spool="$WORK_DIR/spool-hostile"
|
||||||
|
(
|
||||||
|
cd "$hostile_dir"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
|
||||||
|
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||||
|
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=no-such-agent
|
||||||
|
|
||||||
|
EOF
|
||||||
|
) || true
|
||||||
|
# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob
|
||||||
|
# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with
|
||||||
|
# zero output — the same silent-nonzero failure this file exists to catch.
|
||||||
|
record_file=""
|
||||||
|
for candidate in "$hostile_spool"/*.jsonl; do
|
||||||
|
if [[ -e "$candidate" ]]; then
|
||||||
|
record_file="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -z "$record_file" ]]; then
|
||||||
|
echo "FAIL: hostile cwd — no escalation record was written at all" >&2
|
||||||
|
fail=1
|
||||||
|
elif ! python3 -c 'import json,sys
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
json.loads(line)' "$record_file" 2>/dev/null; then
|
||||||
|
echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2
|
||||||
|
cat "$record_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. When the spool cannot be created, the diagnostic must NOT name a record
|
||||||
|
# path. Naming a file that was never written sends the operator to an
|
||||||
|
# empty path on exactly the hosts where the escalation was lost.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
unwritable_spool="/proc/mosaic-credential-spool-cannot-exist"
|
||||||
|
nospool_err=$(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||||
|
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||||
|
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=no-such-agent
|
||||||
|
|
||||||
|
EOF
|
||||||
|
) || true
|
||||||
|
if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then
|
||||||
|
echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
|
||||||
|
echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2
|
||||||
|
echo "$nospool_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "git-credential-mosaic identity resolution regression passed"
|
echo "git-credential-mosaic identity resolution regression passed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -23,6 +23,17 @@
|
|||||||
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
||||||
# per-slot token scheme) -> Patch 2b does not apply; existing
|
# per-slot token scheme) -> Patch 2b does not apply; existing
|
||||||
# fall-through behavior is unchanged.
|
# fall-through behavior is unchanged.
|
||||||
|
# 7. Credential store selection: an identity with a directory under
|
||||||
|
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||||
|
# slot; any other identity is a SERVICE and is read from the framework
|
||||||
|
# store. No precedence between them and NO fallback from one to the
|
||||||
|
# other — a seat with an empty slot is REFUSED even when a same-named
|
||||||
|
# token sits in the framework store.
|
||||||
|
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
|
||||||
|
# seats exist, an unattributable API call is refused rather than made
|
||||||
|
# under the shared account. On a host with no fleet the same call still
|
||||||
|
# returns the shared token (case 1), which is what keeps this change a
|
||||||
|
# no-op for non-fleet operators of the framework.
|
||||||
#
|
#
|
||||||
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
@@ -210,6 +221,94 @@ if [[ "$err" == *"no per-slot token at"* ]]; then
|
|||||||
fi
|
fi
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||||
|
# from its OWN secrets/ slot, not from the framework store. The brain home
|
||||||
|
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
|
||||||
|
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BRAIN_DIR="$WORK_DIR/brain"
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||||
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
|
||||||
|
# EMPTY slot, while a framework-store token of the identical name is
|
||||||
|
# present and readable. It must be REFUSED rather than served that token:
|
||||||
|
# one credential, one location. A seat that silently reads a same-named
|
||||||
|
# service credential is the same substitution failure as the shared-account
|
||||||
|
# fallback, one store further down.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||||
|
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
|
||||||
|
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
|
||||||
|
"git.mosaicstack.dev" "seatF" \
|
||||||
|
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
|
||||||
|
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
# assert_failloud only screens stderr for the word "shared"; this store's token
|
||||||
|
# is not named that, so check for its value explicitly.
|
||||||
|
set +e
|
||||||
|
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||||
|
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
# Control: that framework-store token IS readable, so the refusal above is the
|
||||||
|
# store rule firing and not an unreadable file. The same id, resolved against a
|
||||||
|
# brain home where it is not a seat, gets it.
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||||
|
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
|
||||||
|
# with case 1: the identical call on a host with no fleet still returns the
|
||||||
|
# shared token.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
set +e
|
||||||
|
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
|
||||||
|
noid_rc=$?
|
||||||
|
set -e
|
||||||
|
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
|
||||||
|
if [[ "$noid_rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$noid_out" ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
|
||||||
|
echo "$noid_out$noid_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
|
||||||
|
echo "$noid_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||||
|
# the fleet gate must fire on the default path too, not only on an
|
||||||
|
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||||
|
# same call now refuses, which also proves case 1 was measuring the
|
||||||
|
# no-fleet branch rather than passing for an unrelated reason.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||||
|
set +e
|
||||||
|
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
|
||||||
|
dflt_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
|
||||||
|
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "get_gitea_token identity resolution regression passed"
|
echo "get_gitea_token identity resolution regression passed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# @mosaicstack/quality-rails
|
||||||
|
|
||||||
|
Quality-rails scaffolding and — since RI-3-002 (RI-N4) — the **typed
|
||||||
|
quality-rails evaluator**: the single authoritative producer of check verdicts
|
||||||
|
for the checks it owns.
|
||||||
|
|
||||||
|
## Evaluator (RI-N4)
|
||||||
|
|
||||||
|
Every verdict is typed and fail-closed:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ status: 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable',
|
||||||
|
checkId, checkVersion, subject, reason }
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing implementations, missing inputs, unknown check ids, process errors,
|
||||||
|
timeouts, and malformed probe output can never become `passed` or an
|
||||||
|
unqualified skip — they surface as `blocked`/`error` with a reason (vocabulary
|
||||||
|
mirrors MACP's `GateStatus` discipline).
|
||||||
|
|
||||||
|
- Check definitions live as **data** with a version and a sha256 content
|
||||||
|
digest (`definitionDigest`); every recorded verdict names the definition
|
||||||
|
version that produced it.
|
||||||
|
- Check sets are selected **per subject kind** (`node`, `python`, `rust`,
|
||||||
|
`monorepo`, `unknown`) via the versioned, digested check-set policy — this
|
||||||
|
repository (a `monorepo` subject) does not share the node template's file
|
||||||
|
list.
|
||||||
|
- Shell probes stay **thin adapters**: the TS evaluator invokes them and owns
|
||||||
|
the verdict parsing (e.g. QC-20's planted-commit probe).
|
||||||
|
|
||||||
|
### Owned checks
|
||||||
|
|
||||||
|
| check id | canonical check | mechanism |
|
||||||
|
| --------------------------- | --------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `qc-19-rails-files-present` | QC-19 | typed absorption of the former presence-only `check`/`doctor` loop |
|
||||||
|
| `qc-20-enforcement-verify` | QC-20 | thin shell adapter (framework `verify.sh`); verdict parsing owned here |
|
||||||
|
|
||||||
|
The canonical check ids QC-1..QC-21 and their dispositions are defined in
|
||||||
|
`docs/release-integrity/probe-inventory.md` (the RI-3-001 inventory — the
|
||||||
|
evaluator's input, not its output).
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# CLI (same typed report as the programmatic API)
|
||||||
|
node dist/cli.js quality-rails evaluate --project <path> [--probe-path <verify.sh>] [--json]
|
||||||
|
node dist/cli.js quality-rails check --project <path> [--json] # QC-19 only, fail-closed exit
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { evaluateSubject } from '@mosaicstack/quality-rails';
|
||||||
|
|
||||||
|
const report = await evaluateSubject({ subjectPath: '/path/to/project' });
|
||||||
|
// report.state: 'passed' | 'failed' | 'blocked' | 'error'
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm verify:release` invokes this evaluator as its `quality-rails` stage
|
||||||
|
(canonical-only stage, QC-19 on the monorepo subject).
|
||||||
|
|
||||||
|
## Scaffolding (pre-existing)
|
||||||
|
|
||||||
|
`init` scaffolds rails files per detected kind/profile; `doctor` is advisory
|
||||||
|
and reports typed states.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { mkdir, mkdtemp, writeFile, chmod } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createQualityRailsCli } from './cli.js';
|
||||||
|
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
|
||||||
|
import { evaluateSubject } from './evaluator/runner.js';
|
||||||
|
import type { EvaluationReport } from './evaluator/types.js';
|
||||||
|
|
||||||
|
// CLI ↔ programmatic contract (RI-3-002): the same subject must produce the
|
||||||
|
// same typed verdicts through every entry point the card adds — the
|
||||||
|
// `evaluate`/`check` CLI surfaces and the `evaluateSubject` API.
|
||||||
|
|
||||||
|
async function makeTempDir(): Promise<string> {
|
||||||
|
return mkdtemp(join(tmpdir(), 'qr-cli-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scaffoldNodeFixture(skip: string[] = []): Promise<string> {
|
||||||
|
const dir = await makeTempDir();
|
||||||
|
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
|
||||||
|
for (const relativePath of [
|
||||||
|
'.eslintrc',
|
||||||
|
'biome.json',
|
||||||
|
'.githooks/pre-commit',
|
||||||
|
'PR-CHECKLIST.md',
|
||||||
|
]) {
|
||||||
|
if (skip.includes(relativePath)) continue;
|
||||||
|
await mkdir(join(dir, relativePath, '..'), { recursive: true });
|
||||||
|
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePassingProbe(dir: string): Promise<string> {
|
||||||
|
const scriptPath = join(dir, 'probe-pass.sh');
|
||||||
|
await writeFile(
|
||||||
|
scriptPath,
|
||||||
|
[
|
||||||
|
'#!/bin/bash',
|
||||||
|
'echo "✅ PASS: Type errors blocked"',
|
||||||
|
'echo "✅ PASS: Lint errors blocked"',
|
||||||
|
'echo "Verification Summary"',
|
||||||
|
'exit 0',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await chmod(scriptPath, 0o755);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CLI entry points vs the programmatic evaluator', () => {
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let previousExitCode: string | number | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
previousExitCode = process.exitCode ?? undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluate --json produces the SAME typed report as evaluateSubject (full check set + probe)', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture();
|
||||||
|
const probePath = await makePassingProbe(dir);
|
||||||
|
|
||||||
|
const programmatic = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'cli.js',
|
||||||
|
'quality-rails',
|
||||||
|
'evaluate',
|
||||||
|
'--project',
|
||||||
|
dir,
|
||||||
|
'--probe-path',
|
||||||
|
probePath,
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||||
|
expect(cliReport).toEqual(programmatic);
|
||||||
|
expect(cliReport.state).toBe('passed');
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check --json produces the SAME QC-19 verdict as evaluateSubject (absorbed loop)', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture(['biome.json', '.githooks/pre-commit']);
|
||||||
|
|
||||||
|
const programmatic = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||||
|
});
|
||||||
|
expect(programmatic.state).toBe('failed');
|
||||||
|
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'cli.js',
|
||||||
|
'quality-rails',
|
||||||
|
'check',
|
||||||
|
'--project',
|
||||||
|
dir,
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||||
|
expect(cliReport).toEqual(programmatic);
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check on a complete subject exits 0 with a passed verdict', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture();
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'cli.js',
|
||||||
|
'quality-rails',
|
||||||
|
'check',
|
||||||
|
'--project',
|
||||||
|
dir,
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||||
|
expect(cliReport.state).toBe('passed');
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluate with an unknown check id exits 1 and reports error, never passed', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture();
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'cli.js',
|
||||||
|
'quality-rails',
|
||||||
|
'evaluate',
|
||||||
|
'--project',
|
||||||
|
dir,
|
||||||
|
'--check',
|
||||||
|
'qc-99-bogus',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||||
|
expect(cliReport.results).toHaveLength(1);
|
||||||
|
const first = cliReport.results[0];
|
||||||
|
expect(first?.status).toBe('error');
|
||||||
|
expect(first?.reason).toContain('unknown check id');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluate on a scaffold subject without --probe-path stays fail-closed (blocked, exit 1)', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture();
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'cli.js',
|
||||||
|
'quality-rails',
|
||||||
|
'evaluate',
|
||||||
|
'--project',
|
||||||
|
dir,
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||||
|
const qc20 = cliReport.results.find((r) => r.checkId === 'qc-20-enforcement-verify');
|
||||||
|
expect(qc20).toBeDefined();
|
||||||
|
expect(qc20?.status).toBe('blocked');
|
||||||
|
expect(qc20?.reason).toContain('probePath');
|
||||||
|
expect(cliReport.state).toBe('blocked');
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doctor stays advisory (no nonzero exit) but reports TYPED states, including blocked', async () => {
|
||||||
|
const dir = await scaffoldNodeFixture();
|
||||||
|
const program = createQualityRailsCli();
|
||||||
|
await program.parseAsync(['node', 'cli.js', 'quality-rails', 'doctor', '--project', dir]);
|
||||||
|
|
||||||
|
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(printed).toContain('blocked: qc-20-enforcement-verify');
|
||||||
|
expect(process.exitCode ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { constants } from 'node:fs';
|
|
||||||
import { access } from 'node:fs/promises';
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -8,18 +6,12 @@ import { Command } from 'commander';
|
|||||||
import { detectProjectKind } from './detect.js';
|
import { detectProjectKind } from './detect.js';
|
||||||
import { scaffoldQualityRails } from './scaffolder.js';
|
import { scaffoldQualityRails } from './scaffolder.js';
|
||||||
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
|
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
|
||||||
|
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
|
||||||
|
import { evaluateSubject } from './evaluator/runner.js';
|
||||||
|
import type { EvaluationReport } from './evaluator/types.js';
|
||||||
|
|
||||||
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
|
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
|
||||||
|
|
||||||
async function fileExists(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await access(filePath, constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProfile(rawProfile: string): QualityProfile {
|
function parseProfile(rawProfile: string): QualityProfile {
|
||||||
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
|
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
|
||||||
return rawProfile as QualityProfile;
|
return rawProfile as QualityProfile;
|
||||||
@@ -59,22 +51,6 @@ function defaultFormatters(kind: ProjectKind): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectedFilesForKind(kind: ProjectKind): string[] {
|
|
||||||
if (kind === 'node') {
|
|
||||||
return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'python') {
|
|
||||||
return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'rust') {
|
|
||||||
return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['.githooks/pre-commit', 'PR-CHECKLIST.md'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function printScaffoldResult(
|
function printScaffoldResult(
|
||||||
config: RailsConfig,
|
config: RailsConfig,
|
||||||
filesWritten: string[],
|
filesWritten: string[],
|
||||||
@@ -106,6 +82,20 @@ function printScaffoldResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printEvaluationReport(report: EvaluationReport): void {
|
||||||
|
console.log(
|
||||||
|
`[quality-rails] evaluation for ${report.subject.path} (kind=${report.subject.kind}, check-set v${report.checkSetVersion})`,
|
||||||
|
);
|
||||||
|
for (const result of report.results) {
|
||||||
|
const reason = result.reason === undefined ? '' : ` — ${result.reason}`;
|
||||||
|
const digest = report.definitionDigests[result.checkId] ?? 'no digest';
|
||||||
|
console.log(
|
||||||
|
` - ${result.status}: ${result.checkId} (v${result.checkVersion} [${digest}])${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[quality-rails] aggregate: ${report.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register quality-rails subcommands on an existing Commander program.
|
* Register quality-rails subcommands on an existing Commander program.
|
||||||
* This avoids cross-package Commander version mismatches by using the
|
* This avoids cross-package Commander version mismatches by using the
|
||||||
@@ -148,56 +138,93 @@ function buildQualityRailsCommand(qualityRails: Command): void {
|
|||||||
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
|
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `check` (QC-19) is ABSORBED by the RI-N4 evaluator: the presence loop
|
||||||
|
// that lived here is now the versioned, digested, typed check definition
|
||||||
|
// `qc-19-rails-files-present`. The CLI keeps its human surface (missing
|
||||||
|
// files listed, exit 1) and gains `--json` for the typed verdicts. Exit
|
||||||
|
// code is fail-closed: any non-green aggregate (failed/blocked/error) is 1.
|
||||||
qualityRails
|
qualityRails
|
||||||
.command('check')
|
.command('check')
|
||||||
.requiredOption('--project <path>', 'Project path')
|
.requiredOption('--project <path>', 'Project path')
|
||||||
.action(async (options: { project: string }) => {
|
.option('--json', 'print the typed evaluation report as JSON')
|
||||||
|
.action(async (options: { project: string; json?: boolean }) => {
|
||||||
const projectPath = resolve(options.project);
|
const projectPath = resolve(options.project);
|
||||||
const kind = await detectProjectKind(projectPath);
|
const report = await evaluateSubject({
|
||||||
const expected = expectedFilesForKind(kind);
|
subjectPath: projectPath,
|
||||||
const missing: string[] = [];
|
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||||
|
|
||||||
for (const relativePath of expected) {
|
|
||||||
const exists = await fileExists(resolve(projectPath, relativePath));
|
|
||||||
if (!exists) {
|
|
||||||
missing.push(relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
console.error('[quality-rails] missing files:');
|
|
||||||
for (const relativePath of missing) {
|
|
||||||
console.error(` - ${relativePath}`);
|
|
||||||
}
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[quality-rails] all expected files present for ${kind} project`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(report));
|
||||||
|
} else {
|
||||||
|
printEvaluationReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = report.state === 'passed' ? 0 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// `doctor` (QC-19) stays advisory (documented contract: a doctor that
|
||||||
|
// cannot fail), but now reports TYPED states — a blocked or failing rail is
|
||||||
|
// visible instead of silently printed as `ok`/`missing`.
|
||||||
qualityRails
|
qualityRails
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.requiredOption('--project <path>', 'Project path')
|
.requiredOption('--project <path>', 'Project path')
|
||||||
.action(async (options: { project: string }) => {
|
.action(async (options: { project: string }) => {
|
||||||
const projectPath = resolve(options.project);
|
const projectPath = resolve(options.project);
|
||||||
const kind = await detectProjectKind(projectPath);
|
const report = await evaluateSubject({ subjectPath: projectPath });
|
||||||
const expected = expectedFilesForKind(kind);
|
|
||||||
|
|
||||||
console.log(`[quality-rails] doctor for ${projectPath}`);
|
console.log(`[quality-rails] doctor for ${projectPath}`);
|
||||||
console.log(`detected project kind: ${kind}`);
|
console.log(`detected project kind: ${report.subject.kind}`);
|
||||||
|
for (const result of report.results) {
|
||||||
for (const relativePath of expected) {
|
const reason = result.reason === undefined ? '' : ` — ${result.reason}`;
|
||||||
const exists = await fileExists(resolve(projectPath, relativePath));
|
console.log(` - ${result.status}: ${result.checkId}${reason}`);
|
||||||
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'unknown') {
|
if (report.subject.kind === 'unknown') {
|
||||||
console.log(
|
console.log(
|
||||||
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.',
|
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `evaluate` is the canonical RI-N4 evaluator entry point: typed verdicts
|
||||||
|
// for the subject's full per-kind check set, same results as the
|
||||||
|
// programmatic API (evaluateSubject).
|
||||||
|
qualityRails
|
||||||
|
.command('evaluate')
|
||||||
|
.description('Run the typed quality-rails evaluator against a subject project')
|
||||||
|
.requiredOption('--project <path>', 'Project path')
|
||||||
|
.option('--check <id...>', 'restrict evaluation to these check ids')
|
||||||
|
.option(
|
||||||
|
'--probe-path <path>',
|
||||||
|
'path to the QC-20 behavioral probe script (framework verify.sh)',
|
||||||
|
)
|
||||||
|
.option('--json', 'print the typed evaluation report as JSON')
|
||||||
|
.action(
|
||||||
|
async (options: {
|
||||||
|
project: string;
|
||||||
|
check?: string[];
|
||||||
|
probePath?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}) => {
|
||||||
|
const projectPath = resolve(options.project);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: projectPath,
|
||||||
|
checkIds: options.check,
|
||||||
|
inputs: options.probePath
|
||||||
|
? { 'qc-20-enforcement-verify': { probePath: options.probePath } }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(report));
|
||||||
|
} else {
|
||||||
|
printEvaluationReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = report.state === 'passed' ? 0 : 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
|
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
import type { AdapterOutcome, AdapterRequest, ProcessAdapter } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default thin process adapter (spawn-based). Runs a command to completion with
|
||||||
|
* a hard timeout and reports exit code + captured output — it owns NO verdict
|
||||||
|
* logic. Interpreting the outcome is always the check implementation's job.
|
||||||
|
*/
|
||||||
|
export function createSpawnProcessAdapter(): ProcessAdapter {
|
||||||
|
return {
|
||||||
|
run(request: AdapterRequest): Promise<AdapterOutcome> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = spawn(request.file, request.args, {
|
||||||
|
cwd: request.cwd,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
kind: 'spawn-error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
kind: 'timeout',
|
||||||
|
message: `process timed out after ${request.timeoutMs}ms: ${request.file}`,
|
||||||
|
});
|
||||||
|
}, request.timeoutMs);
|
||||||
|
|
||||||
|
const settle = (outcome: AdapterOutcome): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(outcome);
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.on('error', (error: Error) => {
|
||||||
|
settle({ ok: false, kind: 'spawn-error', message: error.message });
|
||||||
|
});
|
||||||
|
child.on('close', (code: number | null) => {
|
||||||
|
settle({ ok: true, exitCode: code, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { access } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { digestOfPolicy, digestOfSpec } from './digest.js';
|
||||||
|
import type {
|
||||||
|
CheckContext,
|
||||||
|
CheckDefinition,
|
||||||
|
CheckDefinitionSpec,
|
||||||
|
CheckOutcome,
|
||||||
|
CheckSetPolicy,
|
||||||
|
CheckSetPolicySpec,
|
||||||
|
SubjectKind,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// Check definitions for the RI-N4 evaluator (card RI-3-002). Each definition is
|
||||||
|
// DATA with a version and a content digest (see digest.ts); the executable
|
||||||
|
// half is attached via defineCheck. Check-set SELECTION is per subject kind
|
||||||
|
// (probe-inventory gap 7): this monorepo does not match the node template's
|
||||||
|
// file list, so the QC-19 definition carries a distinct file set for the
|
||||||
|
// `monorepo` subject kind and the policy selects checks per kind.
|
||||||
|
|
||||||
|
export function defineCheck(
|
||||||
|
spec: CheckDefinitionSpec,
|
||||||
|
evaluate: (ctx: CheckContext) => Promise<CheckOutcome>,
|
||||||
|
): CheckDefinition {
|
||||||
|
return { ...spec, definitionDigest: digestOfSpec(spec), evaluate };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath, constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── QC-19: downstream rails presence ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Typed absorption of the former presence-only `quality-rails check` loop in
|
||||||
|
// cli.ts. The scaffold-kind file lists below are carried over VERBATIM so the
|
||||||
|
// evaluator's typed verdicts are parity-equivalent with the presence loop on
|
||||||
|
// the same fixture; the `monorepo` list is new (per-subject check sets).
|
||||||
|
|
||||||
|
const qc19Spec: CheckDefinitionSpec = {
|
||||||
|
id: 'qc-19-rails-files-present',
|
||||||
|
version: '1.0.0',
|
||||||
|
canonicalCheck: 'QC-19',
|
||||||
|
description:
|
||||||
|
'The subject still carries its quality-rails files. Typed absorption of the former presence-only check loop; presence is necessary, not sufficient (RI-N4).',
|
||||||
|
appliesTo: ['node', 'python', 'rust', 'monorepo', 'unknown'],
|
||||||
|
params: {
|
||||||
|
expectedFilesByKind: {
|
||||||
|
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
python: ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
rust: ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
monorepo: [
|
||||||
|
'.husky/pre-commit',
|
||||||
|
'.husky/pre-push',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
'.prettierrc',
|
||||||
|
'.lintstagedrc',
|
||||||
|
],
|
||||||
|
unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function evaluateQc19(ctx: CheckContext): Promise<CheckOutcome> {
|
||||||
|
const byKind = ctx.params['expectedFilesByKind'] as Record<string, readonly string[]> | undefined;
|
||||||
|
if (byKind === undefined) {
|
||||||
|
return { status: 'error', reason: 'definition params missing expectedFilesByKind' };
|
||||||
|
}
|
||||||
|
const expected = byKind[ctx.subject.kind];
|
||||||
|
if (expected === undefined) {
|
||||||
|
// Fail-closed: an undefined file set for a declared subject kind is a
|
||||||
|
// definition gap, never a green outcome.
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
reason: `no expected-file set defined for subject kind '${ctx.subject.kind}'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const relativePath of expected) {
|
||||||
|
if (!(await fileExists(resolve(ctx.subject.path, relativePath)))) {
|
||||||
|
missing.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reason: `missing rails files (${ctx.subject.kind}): ${missing.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'passed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── QC-20: downstream enforcement verification (behavioral probe) ──────────
|
||||||
|
//
|
||||||
|
// The planted-commit behavioral probe (framework tools/quality/scripts/verify.sh)
|
||||||
|
// stays a THIN SHELL ADAPTER: the TS evaluator invokes it and OWNS the verdict
|
||||||
|
// parsing (RI-N4: grep-on-output verdict logic moves into the typed evaluator).
|
||||||
|
// Probe contract (verify.sh): exit 0 ⇔ every sub-probe passed, exit 1 ⇔ at
|
||||||
|
// least one sub-probe failed; sub-probe verdicts appear as `PASS:` / `FAIL:`
|
||||||
|
// marker lines and the script always prints a `Verification Summary` section.
|
||||||
|
// Any deviation from that contract (other exit codes, unparseable output,
|
||||||
|
// missing probe, process failure, timeout) is `error`/`blocked` — never
|
||||||
|
// `passed`.
|
||||||
|
|
||||||
|
const qc20Spec: CheckDefinitionSpec = {
|
||||||
|
id: 'qc-20-enforcement-verify',
|
||||||
|
version: '1.0.0',
|
||||||
|
canonicalCheck: 'QC-20',
|
||||||
|
description:
|
||||||
|
'The behavioral planted-commit probe runs against the subject and every sub-probe blocks as intended. The shell probe is a thin adapter; verdict parsing is owned by this evaluator.',
|
||||||
|
appliesTo: ['node', 'python', 'rust', 'unknown'],
|
||||||
|
params: {
|
||||||
|
command: 'bash',
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
passMarker: 'PASS:',
|
||||||
|
failMarker: 'FAIL:',
|
||||||
|
summaryMarker: 'Verification Summary',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function linesWith(text: string, marker: string): string[] {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function evaluateQc20(ctx: CheckContext): Promise<CheckOutcome> {
|
||||||
|
const rawProbePath = ctx.inputs['probePath'];
|
||||||
|
if (typeof rawProbePath !== 'string' || rawProbePath.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
reason:
|
||||||
|
'missing input: probePath — the behavioral probe script must be provided (e.g. the framework verify.sh)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const probePath = isAbsolute(rawProbePath)
|
||||||
|
? rawProbePath
|
||||||
|
: resolve(ctx.subject.path, rawProbePath);
|
||||||
|
if (!(await fileExists(probePath))) {
|
||||||
|
return { status: 'blocked', reason: `probe script not found: ${probePath}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = typeof ctx.params['command'] === 'string' ? ctx.params['command'] : 'bash';
|
||||||
|
const timeoutMs = typeof ctx.params['timeoutMs'] === 'number' ? ctx.params['timeoutMs'] : 120_000;
|
||||||
|
const passMarker =
|
||||||
|
typeof ctx.params['passMarker'] === 'string' ? ctx.params['passMarker'] : 'PASS:';
|
||||||
|
const failMarker =
|
||||||
|
typeof ctx.params['failMarker'] === 'string' ? ctx.params['failMarker'] : 'FAIL:';
|
||||||
|
const summaryMarker =
|
||||||
|
typeof ctx.params['summaryMarker'] === 'string'
|
||||||
|
? ctx.params['summaryMarker']
|
||||||
|
: 'Verification Summary';
|
||||||
|
|
||||||
|
const outcome = await ctx.adapter.run({
|
||||||
|
file: command,
|
||||||
|
args: [probePath],
|
||||||
|
cwd: ctx.subject.path,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.ok) {
|
||||||
|
// Process error or timeout: the probe never produced a trustworthy result.
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
reason: `probe process ${outcome.kind}: ${outcome.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${outcome.stdout}\n${outcome.stderr}`;
|
||||||
|
const failLines = linesWith(output, failMarker);
|
||||||
|
const passLines = linesWith(output, passMarker);
|
||||||
|
|
||||||
|
if (outcome.exitCode === 0) {
|
||||||
|
// A green exit must be corroborated by a parseable green transcript:
|
||||||
|
// at least one pass marker, no fail markers, and the summary section.
|
||||||
|
if (passLines.length > 0 && failLines.length === 0 && output.includes(summaryMarker)) {
|
||||||
|
return { status: 'passed' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
reason: `malformed probe output: exit 0 without a parseable pass transcript (${passLines.length} pass markers, ${failLines.length} fail markers, summary ${output.includes(summaryMarker) ? 'present' : 'absent'})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.exitCode === 1) {
|
||||||
|
if (failLines.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
reason: 'malformed probe output: exit 1 without parseable FAIL markers',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
reason: `enforcement probe reported ${failLines.length} failing sub-probe(s): ${failLines.join(' | ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
reason: `probe exited with unexpected code ${String(outcome.exitCode)} — outcome not interpretable`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-subject check-set policy ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Gap 7 of the probe inventory: check sets must be selected per subject, not
|
||||||
|
// one global list. Downstream scaffold kinds get the presence check plus the
|
||||||
|
// behavioral probe (QC-20 blocks until a probePath input is provided — an
|
||||||
|
// unverified subject can never evaluate green). The monorepo subject is this
|
||||||
|
// repository itself: its rails are the husky hooks + shared lint/format
|
||||||
|
// configs, covered by QC-19; the downstream planted-commit probe does not
|
||||||
|
// apply to it (this repo's own commit gates are QC-13/QC-14, outside this
|
||||||
|
// evaluator's owned checks).
|
||||||
|
|
||||||
|
const checkSetPolicySpec: CheckSetPolicySpec = {
|
||||||
|
version: '1.0.0',
|
||||||
|
byKind: {
|
||||||
|
node: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
|
||||||
|
python: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
|
||||||
|
rust: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
|
||||||
|
unknown: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'],
|
||||||
|
monorepo: ['qc-19-rails-files-present'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHECK_SET_POLICY: CheckSetPolicy = {
|
||||||
|
...checkSetPolicySpec,
|
||||||
|
policyDigest: digestOfPolicy(checkSetPolicySpec),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QC_19_RAILS_FILES_PRESENT = defineCheck(qc19Spec, evaluateQc19);
|
||||||
|
export const QC_20_ENFORCEMENT_VERIFY = defineCheck(qc20Spec, evaluateQc20);
|
||||||
|
|
||||||
|
/** Built-in check definitions, keyed by id. */
|
||||||
|
export function builtInDefinitions(): CheckDefinition[] {
|
||||||
|
return [QC_19_RAILS_FILES_PRESENT, QC_20_ENFORCEMENT_VERIFY];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkSetForKind(
|
||||||
|
kind: SubjectKind,
|
||||||
|
policy: CheckSetPolicy = CHECK_SET_POLICY,
|
||||||
|
): readonly string[] {
|
||||||
|
const selected = policy.byKind[kind];
|
||||||
|
if (selected === undefined) {
|
||||||
|
// Fail-closed selection: an unknown kind yields an EMPTY set only to the
|
||||||
|
// caller; the runner treats an empty result list as `blocked`, never green.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { CheckDefinitionSpec, CheckSetPolicySpec } from './types.js';
|
||||||
|
|
||||||
|
// Deterministic JSON: object keys sorted at every level so two specs with the
|
||||||
|
// same content always produce the same bytes (and thus the same digest).
|
||||||
|
export function canonicalJson(value: unknown): string {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(record).sort();
|
||||||
|
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sha256 over the canonical JSON of `value`. */
|
||||||
|
export function digestContent(value: unknown): string {
|
||||||
|
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content digest of a check definition: covers the declarative spec (id,
|
||||||
|
* version, canonical check, description, applicability, params) — everything a
|
||||||
|
* reviewer reasons about — while excluding the executable function object.
|
||||||
|
* Changing any covered field changes the digest, so a recorded digest always
|
||||||
|
* identifies exactly which definition content produced a verdict.
|
||||||
|
*/
|
||||||
|
export function digestOfSpec(spec: CheckDefinitionSpec): string {
|
||||||
|
return digestContent({
|
||||||
|
id: spec.id,
|
||||||
|
version: spec.version,
|
||||||
|
canonicalCheck: spec.canonicalCheck,
|
||||||
|
description: spec.description,
|
||||||
|
appliesTo: spec.appliesTo,
|
||||||
|
params: spec.params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content digest of the per-subject check-set policy. */
|
||||||
|
export function digestOfPolicy(spec: CheckSetPolicySpec): string {
|
||||||
|
return digestContent(spec);
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { mkdtemp, mkdir, writeFile, chmod } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createSpawnProcessAdapter } from './adapter.js';
|
||||||
|
import {
|
||||||
|
builtInDefinitions,
|
||||||
|
CHECK_SET_POLICY,
|
||||||
|
checkSetForKind,
|
||||||
|
defineCheck,
|
||||||
|
QC_19_RAILS_FILES_PRESENT,
|
||||||
|
QC_20_ENFORCEMENT_VERIFY,
|
||||||
|
} from './definitions.js';
|
||||||
|
import { digestOfSpec } from './digest.js';
|
||||||
|
import { aggregateState, evaluateSubject } from './runner.js';
|
||||||
|
import type {
|
||||||
|
AdapterOutcome,
|
||||||
|
CheckDefinitionSpec,
|
||||||
|
CheckResult,
|
||||||
|
EvaluationReport,
|
||||||
|
ProcessAdapter,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function firstResult(report: EvaluationReport): CheckResult {
|
||||||
|
const result = report.results[0];
|
||||||
|
if (result === undefined) {
|
||||||
|
throw new Error('expected the report to contain at least one result');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTempDir(): Promise<string> {
|
||||||
|
return mkdtemp(join(tmpdir(), 'qr-evaluator-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProbeScript(dir: string, name: string, body: string): Promise<string> {
|
||||||
|
const scriptPath = join(dir, name);
|
||||||
|
await writeFile(scriptPath, `${body}\n`, 'utf8');
|
||||||
|
await chmod(scriptPath, 0o755);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adapter stub that always returns the given outcome (no real process). */
|
||||||
|
function stubAdapter(outcome: AdapterOutcome): ProcessAdapter {
|
||||||
|
return {
|
||||||
|
run: async () => outcome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// VERBATIM copy of the pre-absorption presence loop (former cli.ts
|
||||||
|
// expectedFilesForKind + fileExists loop). This is the PARITY ORACLE: the
|
||||||
|
// evaluator's typed QC-19 verdict must agree with what the absorbed check
|
||||||
|
// concluded on the same fixture.
|
||||||
|
const LEGACY_EXPECTED: Record<'node' | 'python' | 'rust' | 'unknown', string[]> = {
|
||||||
|
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
python: ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
rust: ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function legacyPresenceLoop(projectPath: string, kind: keyof typeof LEGACY_EXPECTED) {
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const relativePath of LEGACY_EXPECTED[kind]) {
|
||||||
|
const fs = await import('node:fs/promises');
|
||||||
|
try {
|
||||||
|
await fs.access(join(projectPath, relativePath));
|
||||||
|
} catch {
|
||||||
|
missing.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scaffoldFixture(kind: keyof typeof LEGACY_EXPECTED, skip: string[] = []) {
|
||||||
|
const dir = await makeTempDir();
|
||||||
|
if (kind === 'node') {
|
||||||
|
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
|
||||||
|
}
|
||||||
|
if (kind === 'python') {
|
||||||
|
await writeFile(join(dir, 'pyproject.toml'), '[project]\n', 'utf8');
|
||||||
|
}
|
||||||
|
if (kind === 'rust') {
|
||||||
|
await writeFile(join(dir, 'Cargo.toml'), '[package]\n', 'utf8');
|
||||||
|
}
|
||||||
|
for (const relativePath of LEGACY_EXPECTED[kind]) {
|
||||||
|
if (skip.includes(relativePath)) continue;
|
||||||
|
await mkdir(join(dir, relativePath, '..'), { recursive: true });
|
||||||
|
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── QC-19 parity: typed verdict == absorbed presence loop ──────────────────
|
||||||
|
|
||||||
|
describe('QC-19 parity with the absorbed presence loop', () => {
|
||||||
|
const kinds: Array<keyof typeof LEGACY_EXPECTED> = ['node', 'python', 'rust', 'unknown'];
|
||||||
|
|
||||||
|
it.each(kinds)('positive fixture (%s): loop said ok ⇒ evaluator passed', async (kind) => {
|
||||||
|
const dir = await scaffoldFixture(kind);
|
||||||
|
const oracleMissing = await legacyPresenceLoop(dir, kind);
|
||||||
|
expect(oracleMissing).toEqual([]);
|
||||||
|
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||||
|
});
|
||||||
|
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||||
|
expect(result?.status).toBe('passed');
|
||||||
|
expect(result?.reason).toBeUndefined();
|
||||||
|
expect(report.state).toBe('passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(kinds)(
|
||||||
|
'negative fixture (%s): loop listed missing ⇒ evaluator failed with them',
|
||||||
|
async (kind) => {
|
||||||
|
const all = LEGACY_EXPECTED[kind];
|
||||||
|
const skip = all.slice(0, Math.max(1, all.length - 1)); // leave exactly 1 present
|
||||||
|
const dir = await scaffoldFixture(kind, skip);
|
||||||
|
const oracleMissing = await legacyPresenceLoop(dir, kind);
|
||||||
|
expect(oracleMissing.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||||
|
});
|
||||||
|
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||||
|
expect(result?.status).toBe('failed');
|
||||||
|
expect(report.state).toBe('failed');
|
||||||
|
for (const missingFile of oracleMissing) {
|
||||||
|
expect(result?.reason).toContain(missingFile);
|
||||||
|
}
|
||||||
|
// No false attribution: a present file must not be named in the reason.
|
||||||
|
const presentFile = all.find((file) => !skip.includes(file));
|
||||||
|
if (presentFile !== undefined) {
|
||||||
|
expect(result?.reason).not.toContain(` ${presentFile},`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── per-subject check sets (inventory gap 7) ────────────────────────────────
|
||||||
|
|
||||||
|
describe('per-subject check sets', () => {
|
||||||
|
it('monorepo subject selects only QC-19 with the monorepo file set', async () => {
|
||||||
|
const dir = await makeTempDir();
|
||||||
|
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8');
|
||||||
|
for (const file of [
|
||||||
|
'.husky/pre-commit',
|
||||||
|
'.husky/pre-push',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
'.prettierrc',
|
||||||
|
'.lintstagedrc',
|
||||||
|
]) {
|
||||||
|
await mkdir(join(dir, file, '..'), { recursive: true });
|
||||||
|
await writeFile(join(dir, file), 'fixture\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await evaluateSubject({ subjectPath: dir });
|
||||||
|
expect(report.subject.kind).toBe('monorepo');
|
||||||
|
expect(report.results.map((r) => r.checkId)).toEqual(['qc-19-rails-files-present']);
|
||||||
|
expect(report.state).toBe('passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a monorepo missing one of its rails files fails QC-19 (not the node list)', async () => {
|
||||||
|
const dir = await makeTempDir();
|
||||||
|
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n', 'utf8');
|
||||||
|
const report = await evaluateSubject({ subjectPath: dir });
|
||||||
|
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||||
|
expect(result?.status).toBe('failed');
|
||||||
|
expect(result?.reason).toContain('.husky/pre-commit');
|
||||||
|
// The node-template list must NOT be applied to a monorepo subject.
|
||||||
|
expect(result?.reason).not.toContain('biome.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the policy selects the behavioral probe for scaffold kinds but not monorepo', () => {
|
||||||
|
expect(checkSetForKind('node')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||||
|
expect(checkSetForKind('unknown')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||||
|
expect(checkSetForKind('monorepo')).not.toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||||
|
expect(CHECK_SET_POLICY.version).toBe('1.0.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── negative controls (the point of the card) ───────────────────────────────
|
||||||
|
|
||||||
|
describe('negative controls', () => {
|
||||||
|
it('unknown check id ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: ['qc-99-does-not-exist'],
|
||||||
|
});
|
||||||
|
expect(report.results).toHaveLength(1);
|
||||||
|
const result = firstResult(report);
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.reason).toContain("unknown check id 'qc-99-does-not-exist'");
|
||||||
|
expect(result.status === 'passed').toBe(false);
|
||||||
|
expect(report.state).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing subject (directory absent) ⇒ blocked for every check, never passed', async () => {
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: join(tmpdir(), `qr-evaluator-absent-${Date.now()}`),
|
||||||
|
});
|
||||||
|
expect(report.results.length).toBeGreaterThan(0);
|
||||||
|
for (const result of report.results) {
|
||||||
|
expect(result.status).toBe('blocked');
|
||||||
|
expect(result.reason).toContain('subject directory does not exist');
|
||||||
|
}
|
||||||
|
expect(report.state).toBe('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QC-20 without probePath input ⇒ blocked, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
});
|
||||||
|
const result = firstResult(report);
|
||||||
|
expect(result.status).toBe('blocked');
|
||||||
|
expect(result.reason).toContain('missing input: probePath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QC-20 with a nonexistent probe script ⇒ blocked, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'no-such-probe.sh') } },
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('blocked');
|
||||||
|
expect(firstResult(report).reason).toContain('probe script not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adapter process error (spawn failure) ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
|
||||||
|
adapter: stubAdapter({ ok: false, kind: 'spawn-error', message: 'ENOENT bash' }),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('probe process spawn-error');
|
||||||
|
expect(firstResult(report).status === 'passed').toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adapter timeout ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
|
||||||
|
adapter: stubAdapter({ ok: false, kind: 'timeout', message: 'timed out after 120000ms' }),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('probe process timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe exit 1 with parseable FAIL markers ⇒ failed (interpretably red), never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(
|
||||||
|
dir,
|
||||||
|
'probe-fail.sh',
|
||||||
|
`echo "Test 1: ..."\necho "❌ FAIL: Type errors NOT blocked"\necho "Verification Summary"\nexit 1`,
|
||||||
|
);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('failed');
|
||||||
|
expect(firstResult(report).reason).toContain('FAIL: Type errors NOT blocked');
|
||||||
|
expect(report.state).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe exit 1 WITHOUT parseable FAIL markers ⇒ malformed ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(dir, 'probe-mute.sh', `echo "nothing to see"\nexit 1`);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('malformed probe output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe exit 0 without a parseable pass transcript ⇒ malformed ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(dir, 'probe-lie.sh', `echo "all good"\nexit 0`);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('malformed probe output');
|
||||||
|
expect(firstResult(report).reason).toContain('exit 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe exit 0 WITH fail markers ⇒ contradictory transcript ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(
|
||||||
|
dir,
|
||||||
|
'probe-contradict.sh',
|
||||||
|
`echo "✅ PASS: one"\necho "❌ FAIL: two"\necho "Verification Summary"\nexit 0`,
|
||||||
|
);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probe unexpected exit code (7) ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(dir, 'probe-crash.sh', `echo "boom"\nexit 7`);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('unexpected code 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('check implementation throwing ⇒ error, never passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const spec: CheckDefinitionSpec = {
|
||||||
|
id: 'test-throws',
|
||||||
|
version: '1.0.0',
|
||||||
|
canonicalCheck: 'QC-TEST',
|
||||||
|
description: 'sabotage-shaped definition that always throws',
|
||||||
|
appliesTo: ['node'],
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
const throwing = defineCheck(spec, async () => {
|
||||||
|
throw new Error('kaboom');
|
||||||
|
});
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: ['test-throws'],
|
||||||
|
definitions: [throwing],
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('kaboom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-passed verdict without a reason ⇒ upgraded to error, never an unqualified skip', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const spec: CheckDefinitionSpec = {
|
||||||
|
id: 'test-silent-fail',
|
||||||
|
version: '1.0.0',
|
||||||
|
canonicalCheck: 'QC-TEST',
|
||||||
|
description: 'returns failed without a reason',
|
||||||
|
appliesTo: ['node'],
|
||||||
|
params: {},
|
||||||
|
};
|
||||||
|
const silent = defineCheck(spec, async () => ({ status: 'failed' }));
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: ['test-silent-fail'],
|
||||||
|
definitions: [silent],
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('error');
|
||||||
|
expect(firstResult(report).reason).toContain('without a reason');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty result list aggregates to blocked, never passed', () => {
|
||||||
|
expect(aggregateState([])).toBe('blocked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── QC-20 parity: typed verdict == shell probe's own conclusion ────────────
|
||||||
|
|
||||||
|
describe('QC-20 parity with the shell probe contract', () => {
|
||||||
|
it('green transcript (exit 0) ⇒ evaluator passed', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const probe = await writeProbeScript(
|
||||||
|
dir,
|
||||||
|
'probe-pass.sh',
|
||||||
|
[
|
||||||
|
'echo "✅ PASS: Type errors blocked"',
|
||||||
|
'echo "✅ PASS: any types blocked"',
|
||||||
|
'echo "✅ PASS: Lint errors blocked"',
|
||||||
|
'echo "Verification Summary"',
|
||||||
|
'echo "✅ Passed: 3"',
|
||||||
|
'exit 0',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
expect(firstResult(report).status).toBe('passed');
|
||||||
|
expect(report.state).toBe('passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the REAL framework verify.sh on a non-git subject concludes failed (exit 1) ⇒ evaluator failed', async () => {
|
||||||
|
// Real-probe parity: verify.sh without a git repo cannot block planted
|
||||||
|
// commits, exits 1 with FAIL markers — the evaluator must record exactly
|
||||||
|
// `failed` with those markers, matching the probe's own conclusion.
|
||||||
|
const realProbe = fileURLToPath(
|
||||||
|
new URL('../../../mosaic/framework/tools/quality/scripts/verify.sh', import.meta.url),
|
||||||
|
);
|
||||||
|
const dir = await makeTempDir(); // not a git repository, no hooks
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||||
|
inputs: { 'qc-20-enforcement-verify': { probePath: realProbe } },
|
||||||
|
adapter: createSpawnProcessAdapter(),
|
||||||
|
});
|
||||||
|
const result = firstResult(report);
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result.reason).toMatch(/FAIL:/);
|
||||||
|
expect(report.state).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── version / digest discipline ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('versioned, digested check definitions', () => {
|
||||||
|
it('every verdict records the definition version that produced it', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({ subjectPath: dir });
|
||||||
|
for (const result of report.results) {
|
||||||
|
expect(result.checkVersion).toBe('1.0.0');
|
||||||
|
}
|
||||||
|
expect(report.checkSetVersion).toBe(CHECK_SET_POLICY.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the report records each definition’s content digest', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const report = await evaluateSubject({ subjectPath: dir });
|
||||||
|
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(
|
||||||
|
QC_19_RAILS_FILES_PRESENT.definitionDigest,
|
||||||
|
);
|
||||||
|
expect(report.definitionDigests[QC_20_ENFORCEMENT_VERIFY.id]).toBe(
|
||||||
|
QC_20_ENFORCEMENT_VERIFY.definitionDigest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('digests are stable for identical content', () => {
|
||||||
|
const spec = QC_19_RAILS_FILES_PRESENT;
|
||||||
|
expect(digestOfSpec(spec)).toBe(digestOfSpec(spec));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing a definition’s content changes its digest', () => {
|
||||||
|
const base = { ...QC_19_RAILS_FILES_PRESENT } as CheckDefinitionSpec;
|
||||||
|
const baseDigest = digestOfSpec(base);
|
||||||
|
|
||||||
|
const changedParams: CheckDefinitionSpec = {
|
||||||
|
...base,
|
||||||
|
params: {
|
||||||
|
expectedFilesByKind: {
|
||||||
|
...(base.params['expectedFilesByKind'] as Record<string, string[]>),
|
||||||
|
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md', 'NEW.md'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(digestOfSpec(changedParams)).not.toBe(baseDigest);
|
||||||
|
|
||||||
|
const changedVersion: CheckDefinitionSpec = { ...base, version: '1.1.0' };
|
||||||
|
expect(digestOfSpec(changedVersion)).not.toBe(baseDigest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a definition with changed content produces a different recorded digest and version', async () => {
|
||||||
|
const dir = await scaffoldFixture('node');
|
||||||
|
const modified = defineCheck(
|
||||||
|
{ ...QC_19_RAILS_FILES_PRESENT, version: '2.0.0' } as unknown as CheckDefinitionSpec,
|
||||||
|
async () => ({ status: 'passed' }),
|
||||||
|
);
|
||||||
|
const report = await evaluateSubject({
|
||||||
|
subjectPath: dir,
|
||||||
|
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||||
|
definitions: [
|
||||||
|
modified,
|
||||||
|
...builtInDefinitions().filter((d) => d.id !== QC_19_RAILS_FILES_PRESENT.id),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = firstResult(report);
|
||||||
|
expect(result.checkVersion).toBe('2.0.0');
|
||||||
|
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(modified.definitionDigest);
|
||||||
|
expect(modified.definitionDigest).not.toBe(QC_19_RAILS_FILES_PRESENT.definitionDigest);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── aggregate state ordering (MACP-style discipline) ───────────────────────
|
||||||
|
|
||||||
|
describe('aggregate state precedence', () => {
|
||||||
|
const result = (status: 'passed' | 'failed' | 'blocked' | 'error') => ({
|
||||||
|
status,
|
||||||
|
checkId: 'x',
|
||||||
|
checkVersion: '1.0.0',
|
||||||
|
subject: '/tmp/x',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all passed (with not-applicable) ⇒ passed', () => {
|
||||||
|
expect(
|
||||||
|
aggregateState([
|
||||||
|
result('passed'),
|
||||||
|
{ ...result('passed'), status: 'not-applicable' as const },
|
||||||
|
]),
|
||||||
|
).toBe('passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error outranks blocked and failed; blocked outranks failed', () => {
|
||||||
|
expect(aggregateState([result('blocked'), result('error')])).toBe('error');
|
||||||
|
expect(aggregateState([result('failed'), result('blocked')])).toBe('blocked');
|
||||||
|
expect(aggregateState([result('passed'), result('failed')])).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { access, stat } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { createSpawnProcessAdapter } from './adapter.js';
|
||||||
|
import { builtInDefinitions, CHECK_SET_POLICY, checkSetForKind } from './definitions.js';
|
||||||
|
import type {
|
||||||
|
AggregateState,
|
||||||
|
CheckResult,
|
||||||
|
CheckStatus,
|
||||||
|
EvaluateOptions,
|
||||||
|
EvaluationReport,
|
||||||
|
ProcessAdapter,
|
||||||
|
Subject,
|
||||||
|
SubjectKind,
|
||||||
|
} from './types.js';
|
||||||
|
import { detectProjectKind } from '../detect.js';
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(targetPath, constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDirectory(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(targetPath)).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject-kind detection for the evaluator. Extends the scaffold detection
|
||||||
|
* (detect.ts) with the `monorepo` kind: a pnpm workspace is this repository's
|
||||||
|
* own subject shape and carries a different rails file set (probe-inventory
|
||||||
|
* gap 7 — check sets are per subject, not one global file list).
|
||||||
|
*/
|
||||||
|
export async function detectSubjectKind(subjectPath: string): Promise<SubjectKind> {
|
||||||
|
if (await pathExists(join(subjectPath, 'pnpm-workspace.yaml'))) {
|
||||||
|
return 'monorepo';
|
||||||
|
}
|
||||||
|
const kind = await detectProjectKind(subjectPath);
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate state, MACP-style discipline: `passed` only when at least one
|
||||||
|
* check produced a verdict AND every verdict is `passed` or an explicitly
|
||||||
|
* qualified `not-applicable`. Precedence is fail-closed: error > blocked >
|
||||||
|
* failed > passed; an empty result list aggregates to `blocked`.
|
||||||
|
*/
|
||||||
|
export function aggregateState(results: readonly CheckResult[]): AggregateState {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
const has = (status: CheckStatus): boolean => results.some((result) => result.status === status);
|
||||||
|
if (has('error')) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
if (has('blocked')) {
|
||||||
|
return 'blocked';
|
||||||
|
}
|
||||||
|
if (has('failed')) {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
return 'passed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonFrom(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate one subject against a set of checks, producing typed verdicts.
|
||||||
|
*
|
||||||
|
* Fail-closed invariants (RI-N4):
|
||||||
|
* - unknown check id → `error` (never passed)
|
||||||
|
* - subject directory absent → every verdict `blocked`
|
||||||
|
* - check implementation threw → `error`
|
||||||
|
* - non-passed without a reason → `error` (no unqualified skips)
|
||||||
|
* - check not applicable → `not-applicable` WITH a reason
|
||||||
|
*/
|
||||||
|
export async function evaluateSubject(options: EvaluateOptions): Promise<EvaluationReport> {
|
||||||
|
const subjectPath = resolve(options.subjectPath);
|
||||||
|
const subject: Subject = {
|
||||||
|
path: subjectPath,
|
||||||
|
kind: await detectSubjectKind(subjectPath),
|
||||||
|
};
|
||||||
|
|
||||||
|
const definitions = options.definitions ?? builtInDefinitions();
|
||||||
|
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
||||||
|
const requested = options.checkIds ?? checkSetForKind(subject.kind);
|
||||||
|
const adapter: ProcessAdapter = options.adapter ?? createSpawnProcessAdapter();
|
||||||
|
|
||||||
|
const results: CheckResult[] = [];
|
||||||
|
const definitionDigests: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const checkId of requested) {
|
||||||
|
const definition = byId.get(checkId);
|
||||||
|
if (definition === undefined) {
|
||||||
|
const known = definitions.map((entry) => entry.id).join(', ');
|
||||||
|
results.push({
|
||||||
|
status: 'error',
|
||||||
|
checkId,
|
||||||
|
checkVersion: 'unknown',
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: `unknown check id '${checkId}' — no registered definition (known: ${known})`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
definitionDigests[checkId] = definition.definitionDigest;
|
||||||
|
|
||||||
|
if (!(await isDirectory(subjectPath))) {
|
||||||
|
results.push({
|
||||||
|
status: 'blocked',
|
||||||
|
checkId,
|
||||||
|
checkVersion: definition.version,
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: `subject directory does not exist: ${subjectPath}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!definition.appliesTo.includes(subject.kind)) {
|
||||||
|
results.push({
|
||||||
|
status: 'not-applicable',
|
||||||
|
checkId,
|
||||||
|
checkVersion: definition.version,
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: `check '${checkId}' does not apply to subject kind '${subject.kind}'`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inputs = options.inputs?.[checkId] ?? {};
|
||||||
|
const outcome = await definition.evaluate({
|
||||||
|
subject,
|
||||||
|
params: definition.params,
|
||||||
|
inputs,
|
||||||
|
adapter,
|
||||||
|
});
|
||||||
|
if (outcome.status !== 'passed' && (outcome.reason === undefined || outcome.reason === '')) {
|
||||||
|
results.push({
|
||||||
|
status: 'error',
|
||||||
|
checkId,
|
||||||
|
checkVersion: definition.version,
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: `check returned status '${outcome.status}' without a reason — treated as error`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
status: outcome.status,
|
||||||
|
checkId,
|
||||||
|
checkVersion: definition.version,
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: outcome.reason,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
status: 'error',
|
||||||
|
checkId,
|
||||||
|
checkVersion: definition.version,
|
||||||
|
subject: subjectPath,
|
||||||
|
reason: `check implementation threw: ${reasonFrom(error)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
results,
|
||||||
|
definitionDigests,
|
||||||
|
checkSetVersion: CHECK_SET_POLICY.version,
|
||||||
|
state: aggregateState(results),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Evaluator core types — RI-N4 (card RI-3-002, SDLC-D-037 second half).
|
||||||
|
//
|
||||||
|
// The quality-rails evaluator is the SOLE authoritative producer of check
|
||||||
|
// verdicts for the checks it owns. Every verdict is typed and fail-closed:
|
||||||
|
// missing implementations, missing inputs, unknown check ids, process errors,
|
||||||
|
// timeouts, and malformed adapter output can never become `passed` or an
|
||||||
|
// unqualified skip — they surface as `blocked` or `error` with a reason.
|
||||||
|
// (Vocabulary mirrors MACP's GateStatus discipline from packages/macp.)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed verdict for a single check execution.
|
||||||
|
*
|
||||||
|
* - `passed` — the check really ran and its condition held.
|
||||||
|
* - `failed` — the check really ran and its condition did NOT hold.
|
||||||
|
* - `blocked` — the check could not run at all (missing subject, missing
|
||||||
|
* input). Never a green outcome.
|
||||||
|
* - `error` — the check attempted to run but its outcome cannot be trusted
|
||||||
|
* (unknown check id, implementation threw, process error, timeout, malformed
|
||||||
|
* adapter output). Never a green outcome.
|
||||||
|
* - `not-applicable` — the check definition explicitly declares it does not
|
||||||
|
* apply to this subject (a qualified skip, always with a reason).
|
||||||
|
*/
|
||||||
|
export type CheckStatus = 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable';
|
||||||
|
|
||||||
|
/** Aggregate outcome, MACP-style: `passed` only when every result is green. */
|
||||||
|
export type AggregateState = 'passed' | 'failed' | 'blocked' | 'error';
|
||||||
|
|
||||||
|
/** Kinds of subjects the evaluator can assess. */
|
||||||
|
export type SubjectKind = 'node' | 'python' | 'rust' | 'monorepo' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single check verdict. This is the canonical result shape: `status`,
|
||||||
|
* `checkId`, `checkVersion`, `subject`, `reason`. `reason` is REQUIRED
|
||||||
|
* (enforced by the runner) for every status other than `passed`.
|
||||||
|
*/
|
||||||
|
export interface CheckResult {
|
||||||
|
status: CheckStatus;
|
||||||
|
checkId: string;
|
||||||
|
checkVersion: string;
|
||||||
|
subject: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project being evaluated. */
|
||||||
|
export interface Subject {
|
||||||
|
/** Absolute path. */
|
||||||
|
path: string;
|
||||||
|
kind: SubjectKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data half of a check definition. Definitions live as DATA with a version
|
||||||
|
* and a content digest (see `digestOfSpec`); the executable half is attached
|
||||||
|
* separately so the digest covers only reviewable, declarative content.
|
||||||
|
*/
|
||||||
|
export interface CheckDefinitionSpec {
|
||||||
|
/** Stable id, e.g. `qc-19-rails-files-present`. */
|
||||||
|
id: string;
|
||||||
|
/** Semver of this definition's data+semantics. */
|
||||||
|
version: string;
|
||||||
|
/** Canonical check id from docs/release-integrity/probe-inventory.md (QC-n). */
|
||||||
|
canonicalCheck: string;
|
||||||
|
description: string;
|
||||||
|
/** Subject kinds this check can assess (others yield `not-applicable`). */
|
||||||
|
appliesTo: readonly SubjectKind[];
|
||||||
|
/** Declarative parameters (file lists, markers, timeouts) — digest-covered. */
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully assembled check definition: spec + digest + implementation. */
|
||||||
|
export interface CheckDefinition extends CheckDefinitionSpec {
|
||||||
|
/** sha256 content digest of the spec (canonical JSON projection). */
|
||||||
|
definitionDigest: string;
|
||||||
|
evaluate(ctx: CheckContext): Promise<CheckOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a check implementation returns; the runner stamps id/version/subject. */
|
||||||
|
export interface CheckOutcome {
|
||||||
|
status: CheckStatus;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caller-provided inputs for one check invocation (e.g. the QC-20 probe path). */
|
||||||
|
export type CheckInputs = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Everything a check implementation may use. */
|
||||||
|
export interface CheckContext {
|
||||||
|
subject: Subject;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
inputs: CheckInputs;
|
||||||
|
adapter: ProcessAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of running a shell probe through the thin process adapter. */
|
||||||
|
export type AdapterOutcome =
|
||||||
|
| { ok: true; exitCode: number | null; stdout: string; stderr: string }
|
||||||
|
| { ok: false; kind: 'spawn-error' | 'timeout'; message: string };
|
||||||
|
|
||||||
|
/** Request for the process adapter. */
|
||||||
|
export interface AdapterRequest {
|
||||||
|
file: string;
|
||||||
|
args: string[];
|
||||||
|
cwd: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin process adapter: runs a command, owns NO verdict logic. Verdict parsing
|
||||||
|
* always lives in the check implementation (TS), never in the shell probe.
|
||||||
|
*/
|
||||||
|
export interface ProcessAdapter {
|
||||||
|
run(request: AdapterRequest): Promise<AdapterOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-subject-kind check-set selection policy (versioned and digested). */
|
||||||
|
export interface CheckSetPolicySpec {
|
||||||
|
version: string;
|
||||||
|
byKind: Record<SubjectKind, readonly string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckSetPolicy extends CheckSetPolicySpec {
|
||||||
|
/** sha256 content digest of the policy spec. */
|
||||||
|
policyDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full typed evaluation report for one subject. */
|
||||||
|
export interface EvaluationReport {
|
||||||
|
subject: Subject;
|
||||||
|
results: CheckResult[];
|
||||||
|
/** checkId → content digest of the definition that produced the verdicts. */
|
||||||
|
definitionDigests: Record<string, string>;
|
||||||
|
/** Version of the check-set policy used for subject selection. */
|
||||||
|
checkSetVersion: string;
|
||||||
|
state: AggregateState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `evaluateSubject`. */
|
||||||
|
export interface EvaluateOptions {
|
||||||
|
subjectPath: string;
|
||||||
|
/** Restrict to these check ids; defaults to the subject kind's check set. */
|
||||||
|
checkIds?: string[];
|
||||||
|
/** Per-check inputs, keyed by check id (e.g. `{ 'qc-20-enforcement-verify': { probePath } }`). */
|
||||||
|
inputs?: Record<string, CheckInputs>;
|
||||||
|
/** Replace the built-in definitions (tests / future batches). */
|
||||||
|
definitions?: CheckDefinition[];
|
||||||
|
/** Inject a process adapter (tests / instrumentation). */
|
||||||
|
adapter?: ProcessAdapter;
|
||||||
|
}
|
||||||
@@ -3,3 +3,35 @@ export * from './detect.js';
|
|||||||
export * from './scaffolder.js';
|
export * from './scaffolder.js';
|
||||||
export * from './templates.js';
|
export * from './templates.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|
||||||
|
// RI-N4 evaluator (card RI-3-002): the public, programmatic entry points.
|
||||||
|
export {
|
||||||
|
builtInDefinitions,
|
||||||
|
CHECK_SET_POLICY,
|
||||||
|
checkSetForKind,
|
||||||
|
defineCheck,
|
||||||
|
QC_19_RAILS_FILES_PRESENT,
|
||||||
|
QC_20_ENFORCEMENT_VERIFY,
|
||||||
|
} from './evaluator/definitions.js';
|
||||||
|
export { canonicalJson, digestContent, digestOfPolicy, digestOfSpec } from './evaluator/digest.js';
|
||||||
|
export { createSpawnProcessAdapter } from './evaluator/adapter.js';
|
||||||
|
export { aggregateState, detectSubjectKind, evaluateSubject } from './evaluator/runner.js';
|
||||||
|
export type {
|
||||||
|
AdapterOutcome,
|
||||||
|
AdapterRequest,
|
||||||
|
AggregateState,
|
||||||
|
CheckContext,
|
||||||
|
CheckDefinition,
|
||||||
|
CheckDefinitionSpec,
|
||||||
|
CheckInputs,
|
||||||
|
CheckOutcome,
|
||||||
|
CheckResult,
|
||||||
|
CheckSetPolicy,
|
||||||
|
CheckSetPolicySpec,
|
||||||
|
CheckStatus,
|
||||||
|
EvaluateOptions,
|
||||||
|
EvaluationReport,
|
||||||
|
ProcessAdapter,
|
||||||
|
Subject,
|
||||||
|
SubjectKind,
|
||||||
|
} from './evaluator/types.js';
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
// format | format | pnpm format:check
|
// format | format | pnpm format:check
|
||||||
// test | test | pnpm test
|
// test | test | pnpm test
|
||||||
// build | publish.yml build | pnpm build
|
// build | publish.yml build | pnpm build
|
||||||
|
// quality-rails | (canonical-only) | the TS quality-rails evaluator
|
||||||
|
// | | (RI-N4, QC-19 monorepo subject). Like
|
||||||
|
// | | `build`, this stage has no ci.yml
|
||||||
|
// | | mirror; it is implemented by
|
||||||
|
// | | importing the evaluator CLI rather
|
||||||
|
// | | than duplicating its presence logic.
|
||||||
//
|
//
|
||||||
// Caller-provided prerequisites (kept at the pipeline level — see the comments
|
// Caller-provided prerequisites (kept at the pipeline level — see the comments
|
||||||
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
|
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
|
||||||
@@ -90,6 +96,15 @@ export const STAGES = [
|
|||||||
name: 'build',
|
name: 'build',
|
||||||
commands: ['pnpm build'],
|
commands: ['pnpm build'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// RI-N4 (QC-19, card RI-3-002): the typed quality-rails evaluator, invoked
|
||||||
|
// as the implementation of the check it owns instead of a duplicated
|
||||||
|
// presence loop here. Canonical-only stage (no ci.yml mirror — same shape
|
||||||
|
// as `build`); runs AFTER build so the evaluator's dist/ exists. Subject
|
||||||
|
// is this repository (`.` → monorepo subject kind, per-subject check set).
|
||||||
|
name: 'quality-rails',
|
||||||
|
commands: ['node packages/quality-rails/dist/cli.js quality-rails evaluate --project .'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function stageByName(name) {
|
export function stageByName(name) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { STAGES } from './verify-release.mjs';
|
import { STAGES, stageByName } from './verify-release.mjs';
|
||||||
|
|
||||||
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
|
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
|
||||||
// bound to exact-commit terminal verification. This suite parses the real
|
// bound to exact-commit terminal verification. This suite parses the real
|
||||||
@@ -229,10 +229,21 @@ steps:
|
|||||||
function assertStagesMirrorCi(stages, ci) {
|
function assertStagesMirrorCi(stages, ci) {
|
||||||
const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));
|
const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));
|
||||||
|
|
||||||
// The complete mandatory set, in gate order.
|
// The complete mandatory set, in gate order. `quality-rails` is a
|
||||||
|
// canonical-only stage (RI-N4, QC-19): like `build`, it has no ci.yml
|
||||||
|
// mirror to match — its contract is asserted separately below.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
stages.map((stage) => stage.name),
|
stages.map((stage) => stage.name),
|
||||||
['sanitization', 'upgrade-guard', 'typecheck', 'lint', 'format', 'test', 'build'],
|
[
|
||||||
|
'sanitization',
|
||||||
|
'upgrade-guard',
|
||||||
|
'typecheck',
|
||||||
|
'lint',
|
||||||
|
'format',
|
||||||
|
'test',
|
||||||
|
'build',
|
||||||
|
'quality-rails',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
|
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
|
||||||
@@ -301,3 +312,38 @@ test('the root package.json exposes verify:release as the canonical command', as
|
|||||||
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
|
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
|
||||||
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
|
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// RI-N4 (card RI-3-002): the `quality-rails` stage must route through the TS
|
||||||
|
// evaluator instead of duplicating its presence logic inline. The evaluator
|
||||||
|
// owns QC-19; this file keeps that delegation honest.
|
||||||
|
function assertEvaluatorStage(stage) {
|
||||||
|
assert.ok(stage, 'canonical stages must include a quality-rails stage');
|
||||||
|
assert.ok(Array.isArray(stage.commands) && stage.commands.length > 0);
|
||||||
|
for (const command of stage.commands) {
|
||||||
|
assert.match(
|
||||||
|
command,
|
||||||
|
/packages\/quality-rails\/dist\/cli\.js.*quality-rails evaluate/,
|
||||||
|
`quality-rails stage command must invoke the evaluator CLI, got: '${command}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the quality-rails stage invokes the evaluator rather than duplicating its logic', () => {
|
||||||
|
assertEvaluatorStage(stageByName('quality-rails'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quality-rails stage that re-implements presence logic inline fails the checker', () => {
|
||||||
|
// Negative control: replacing the evaluator invocation with an inline
|
||||||
|
// `test -f` presence loop is exactly the duplication RI-N4 forbids — the
|
||||||
|
// checker must go red on it.
|
||||||
|
const duplicated = {
|
||||||
|
name: 'quality-rails',
|
||||||
|
commands: ['test -f .husky/pre-commit && test -f .husky/pre-push'],
|
||||||
|
};
|
||||||
|
assert.throws(() => assertEvaluatorStage(duplicated), /must invoke the evaluator CLI/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quality-rails stage that silently drops the evaluator command fails the checker', () => {
|
||||||
|
const empty = { name: 'quality-rails', commands: [] };
|
||||||
|
assert.throws(() => assertEvaluatorStage(empty), /commands/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user