From 787420c21dfe90d5a80f27622d4e15b4a7c887f4 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:41:00 +0200 Subject: [PATCH 1/2] ci(docs-truth): machine-check R-15 gate status across the contract and the ledger (#933) The contract's single R15_GATE_STATUS block is canonical. scripts/check-r15-gate-status.mjs requires it to be well formed (every gate once, fixed statuses or a slice of the same gate), ledger row 10 to agree with it, and no current prose to call a gate or a delivered slice not admitted. Runs in pnpm docs:check and ci:prepush, outside the protected merge-admission evaluator graph. --- README.md | 8 +- docs/native/CORE-MIGRATION-LEDGER.md | 2 +- docs/native/R15-SECURE-STORAGE-CONTRACT.md | 19 ++ package.json | 2 +- scripts/check-r15-gate-status.d.mts | 9 + scripts/check-r15-gate-status.mjs | 265 +++++++++++++++++++++ scripts/ci-prepush-lowend.mjs | 1 + scripts/sync-readme-metrics.mjs | 5 + tests/unit/checkR15GateStatus.test.ts | 251 +++++++++++++++++++ 9 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 scripts/check-r15-gate-status.d.mts create mode 100644 scripts/check-r15-gate-status.mjs create mode 100644 tests/unit/checkR15GateStatus.test.ts diff --git a/README.md b/README.md index 1df09b9a1..f39fc3bea 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ TypeScript native preview (tsgo) Tauri 2 19 locales — 2947 keys - 8504+ tests / 632 files + 8523+ tests / 633 files Codecov Coverage CI status MIT License @@ -809,7 +809,7 @@ The normal web PR pipeline is not the complete native release qualification surf | DOCX | `docx` + JSZip | Word-compatible export | | PWA | Service Worker + Web App Manifest | Offline shell/installability | | i18n | Custom React i18n context | 2947 keys × 19 locales | -| Testing | Vitest 5.x (8504+ tests / 632 files) + Playwright | Unit/integration/E2E | +| Testing | Vitest 5.x (8523+ tests / 633 files) + Playwright | Unit/integration/E2E | | Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates | | Desktop | Tauri 2 | Current native shell | @@ -886,7 +886,7 @@ WorldScript-Studio/ ├── locales/ # Source locale trees ├── public/ # PWA assets, manifest, SW, runtime locale bundles ├── tests/ -│ ├── unit/ # Vitest unit tests (the 8504+ total also includes components/ and packages/*/tests; tests/e2e is excluded) +│ ├── unit/ # Vitest unit tests (the 8523+ total also includes components/ and packages/*/tests; tests/e2e is excluded) │ └── e2e/ # Playwright ├── docs/ # Canonical product/engineering documentation + ADRs ├── src-tauri/ # Tauri v2 desktop shell / Rust @@ -1031,7 +1031,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo Current source-synchronized README metrics: -- **8504+ unit tests** across **632 test files** +- **8523+ unit tests** across **633 test files** - i18n: **2947 keys × 19 locales** CI remains authoritative for actual pass/fail and live coverage. diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index e412970f1..a10ff167d 100644 --- a/docs/native/CORE-MIGRATION-LEDGER.md +++ b/docs/native/CORE-MIGRATION-LEDGER.md @@ -17,7 +17,7 @@ scope shifts — it is a living decision record, not a one-time snapshot. | 7 | `features/project/` domain logic | TS, `features/project/` (24 files, 2,114 lines) — real logic concentrated in `thunks/` + `projectSelectors.ts` (~450-500 lines); `reducers/` (11 files) is CRUD bookkeeping | High — Redux-store-shape/dispatch bound; `reducers/` stays TS-side permanently | Low | Medium (import/restore orchestration) | Low-medium | Medium (only the thunks/selectors subset) | Deferred | Candidate after the schema crate is proven; only thunks/selectors, never `reducers/` | Not started | | 8 | AI services | TS, `services/ai/` (44 files, 5,401 lines), mixed portability (retry/routing/error-taxonomy renderer-neutral vs. `computeShaderFactory.ts`/`webGpuDetectorService.ts`/`.wgsl` inherently WebGPU-coupled) | Mixed | Medium-high (API keys) | Low-medium | Medium | Uncertain — too large/mixed to assess narrowly | **Out of scope for all of Wave 2** | None proposed | None | | 9 | Project state-shape compatibility adapter | TS, `features/project/coreBoundaryAdapter.ts` at the Core boundary + Rust, `crates/worldscript-project` schema | High at the boundary — production Redux `EntityState` must be translated without importing Redux into Core | Low | High — ID/order preservation is part of project identity | Medium | High — every native renderer needs the same conversion contract | **2 — Wave 2 prerequisite before G1 evaluation** | **Current-production #553 closure complete; Rust Core authority switch not started (#836).** `IMPLEMENTATION_STARTED = YES`. Every current-production Project path is canonical and no-loss according to backend semantics (#553, PRs #773–#849): textual raw carrier and lexical tokens on the filesystem, structured-value semantics in IndexedDB. Persistence and admission: shared TS/Rust classification including the raw-token grammar; `LEGACY_TO_V1` admitted in memory and migrated durably on both backends (IndexedDB authority; filesystem under the project lock with a pre-migration snapshot, #849); the generation-fenced canonical IDB authority for web/PWA autosave, flush and manual save; the desktop filesystem writer as a preserve-first raw-carrier writeback under the project lock with a generation/incarnation fence. Egress: export and library backup (projects and snapshots), stripped to portable form. Snapshots: creation and restore, each admitted, with an exact restore carrier. Import: every modeled field admitted and projected, then an admitted-raw first save (#842, #848); a `null` tension score is admitted so the project remains loadable, omitted from the typed editor projection, and preserved in the canonical raw carrier. Export: every JSON surface, including Advanced import/export, through the canonical egress (#847). Replacement and authority: same-ID replacement writes a fresh canonical document, with carriers bound to target, epoch and authority; the desktop fails closed when filesystem storage is unavailable, with no IndexedDB fallback; an unloadable browser record is refused and kept. The closure guarantees no silent loss or replacement of stored data; it does not promise that every malformed shape boots into the editor (malformed manuscript-section hardening is #845). TypeScript remains the production Project authority; the renderer-neutral Rust Core authority switch is separate future work (#836). Normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict remains partial because unknown fields are not rejected (Rust is observation-only until #836); within the current TypeScript authority, every current-production ingress, writer, migration and egress preserves the authoritative canonical carrier according to backend semantics — the textual raw carrier and its lexical tokens where textual authority exists (filesystem), the stored structured value in IndexedDB (canonically serialized where text is needed, with no claim that original JSON text survives). Both required decisions (persisted version authority; a field-class-staged unknown-field policy, not one global policy) are resolved and maintainer-admitted in [`docs/native/PROJECT-CORE-COMPATIBILITY-CONTRACT.md`](PROJECT-CORE-COMPATIBILITY-CONTRACT.md), `PROPOSED = YES` / `ADMITTED = YES`. Issue #553's current-production implementation of that contract is complete; its terminal acceptance is QNB-99 (pending); the authority switch it gates is #836. | `tests/unit/features/project/coreBoundaryAdapter.test.ts` covers array and `EntityState` inputs, round-trip ID/order preservation, and rejection of duplicate IDs, missing references, and orphaned entities for both characters and worlds; `tests/unit/features/project/projectSchemaVersion.test.ts` and `crates/worldscript-project/tests/version_test.rs` cover classification/parity; the IDB load observation is covered by `tests/unit/services/storage/idbProjectStoreLoadStateObservation.test.ts`; the canonical parser/import/admission foundation is covered by `tests/unit/projectDocument.test.ts` and `tests/unit/projectImportSchema.test.ts`; the writeback overlay/verify/fence primitive by `tests/unit/services/projectDocumentWriteback.test.ts`; the IDB canonical admission/durable-commit boundary (against real fake-indexeddb, including a generation-conflict rejection, a §2.7 downgrade-contradiction, and an encrypted round trip) by `tests/unit/services/storage/idbProjectCanonicalAuthority.test.ts`; production routing/refusal-success coverage by `tests/unit/services/projectAutosavePersistence.test.ts`, `tests/unit/persistedStateFlush.test.ts`, and the listener/shortcut tests; the envelope fixture is accepted by Rust after migration and validation; the #553 current-production closure (a1–a11) by `tests/unit/services/projectCanonicalEgress.test.ts`, `tests/unit/libraryBackupService.test.ts`, `tests/unit/services/fs/fsStores.test.ts`, `tests/unit/services/projectAutosaveCanonicalWriter.test.ts`, `tests/unit/services/projectImportCarrier.test.ts`, `tests/unit/storageServiceDesktopAuthority.test.ts` and `tests/unit/malformedProjectBoot.test.ts` | -| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) implemented headless, with commit markers/reconciliation (3B) and the root/catalog commit (3C) remaining (#921), the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3A_DURABLE_STAGING / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | +| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) implemented headless, with commit markers/reconciliation (3B) and the root/catalog commit (3C) remaining (#921); the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3A_DURABLE_STAGING / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | ## Decisions this table records diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index 7c5a0bb84..621498f18 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -3426,6 +3426,25 @@ contract. It does not mean any child issue is implemented or closed. ## 20. Staged implementation admission plan +**Current gate status (machine-checked).** This block is the single source of R-15 gate status. +`docs:check` (#933) requires the Core Migration Ledger's `R15_GATE*` tokens to equal it, the ledger +to carry every gate that is not `NOT_ADMITTED`, and no current prose in this contract or the ledger +to call a gate "not admitted" that this block marks as implemented, or as partial (`SLICE_*`) +without saying "the rest of" that gate. Every PR that changes a gate's status updates this block in +the same change. + +```text +R15_GATE_STATUS +R15_GATE1A=IMPLEMENTED_HEADLESS +R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER +R15_GATE2=IMPLEMENTED_HEADLESS +R15_GATE3=SLICE_3A_DURABLE_STAGING +R15_GATE4=NOT_ADMITTED +R15_GATE5=NOT_ADMITTED +R15_GATE6=NOT_ADMITTED +R15_GATE7=NOT_ADMITTED +``` + Later implementation may be admitted only in these bounded gates: 1. **Core primitive and vectors:** implement the selected AEAD/KDF/randomness profile behind a diff --git a/package.json b/package.json index 33af663a0..40f01525e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "content:guard": "node scripts/content-guard.mjs", "parity:check": "tsx scripts/audit-feature-parity.ts", "test-coverage:check": "tsx scripts/check-feature-test-coverage.ts", - "docs:check": "node scripts/check-doc-metrics.mjs", + "docs:check": "node scripts/check-doc-metrics.mjs && node scripts/check-r15-gate-status.mjs", "csp:sync": "node scripts/sync-csp.mjs", "csp:check": "node scripts/check-csp-policy.mjs", "csp:verify": "node scripts/sync-csp.mjs && git diff --exit-code -- index.html nginx.conf public/_headers vercel.json src-tauri/tauri.conf.json && node scripts/check-csp-policy.mjs", diff --git a/scripts/check-r15-gate-status.d.mts b/scripts/check-r15-gate-status.d.mts new file mode 100644 index 000000000..12303be5d --- /dev/null +++ b/scripts/check-r15-gate-status.d.mts @@ -0,0 +1,9 @@ +export const R15_CONTRACT_DOC: string; +export const R15_LEDGER_DOC: string; +export const R15_GATE_IDS: readonly string[]; +export const R15_FIXED_STATUSES: readonly string[]; +export function parseR15GateEntries(text: string): { gate: string; status: string }[]; +export function currentProse(markdown: string): string; +export function scanR15GateStatusTruth(contract: string, ledger: string): string[]; +export function isValidR15Status(gate: string, status: string): boolean; +export function stripHtmlComments(text: string): string; diff --git a/scripts/check-r15-gate-status.mjs b/scripts/check-r15-gate-status.mjs new file mode 100644 index 000000000..f349e8e10 --- /dev/null +++ b/scripts/check-r15-gate-status.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/** + * R-15 gate status truth (#933). Gate status is written in three places that drifted in four + * consecutive PRs (#917, #928, #929, #930): the contract header, the contract's closing status and + * row 10 of the Core Migration Ledger. The contract's single `R15_GATE_STATUS` block is canonical; + * this check requires it to be well formed, the ledger's row 10 to agree with it, and no current + * prose in either document to call a gate (or an already delivered slice) "not admitted". + * + * Kept outside the merge-admission evaluator graph (`check-doc-metrics.mjs` is a protected, + * pinned evaluator file); `pnpm docs:check` and `ci:prepush` run it alongside that checker. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const R15_CONTRACT_DOC = 'docs/native/R15-SECURE-STORAGE-CONTRACT.md'; +export const R15_LEDGER_DOC = 'docs/native/CORE-MIGRATION-LEDGER.md'; +export const R15_GATE_IDS = Object.freeze(['1A', '1B', '2', '3', '4', '5', '6', '7']); +/** Statuses a gate may have; a partial gate names its last delivered slice (`SLICE_3A_…`). */ +export const R15_FIXED_STATUSES = Object.freeze([ + 'NOT_ADMITTED', + 'IMPLEMENTED_HEADLESS', + 'IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER', +]); +const SLICE_STATUS = /^SLICE_([2-7])([A-Z])(?:_[A-Z0-9]+)+$/; +// The whole value up to a delimiter is captured, so a malformed suffix is refused, not truncated. +const ENTRY = /\bR15_GATE([0-9A-Za-z]+)=([^\s/|;,`*]+)/g; +const STATUS_BLOCK = /```text\nR15_GATE_STATUS\n([\s\S]*?)```/g; +const LEDGER_ROW = /^\| 10 \|.*$/gm; +const NEGATIVE = /^(?:not admitted|unadmitted)$/i; +// Status predicates; the negative alternatives come first so "not admitted" is not read as "admitted". +const PREDICATE = + /\bnot admitted\b|\bunadmitted\b|\b(?:implemented|admitted|delivered|landed|terminal)\b/gi; +// "Gate 3", "Gate 1b", "Gate 3B", "Gate 3 slice 3B", "Gates 4–7", optionally after "the rest of". +const GATE_REF = + /(the (?:rest|remainder) of )?\bGates? ([1-7])([a-z])?(?:\s+slice\s+[1-7]([a-z]))?(?:\s*[–-]\s*([1-7]))?(?![0-9a-z])/gi; +const CLAUSE_BREAK = /;|(?<=[.!?])\s+(?=[A-Z`*(])|,\s+(?:while|whereas|but)\s+/; +const FENCE = /^ {0,3}(`{3,}|~{3,})/; +const HEADING = /^(#{1,6})\s/; +const HISTORICAL_HEADING = /\bHISTORICAL\b|\bSUPERSEDED\b/i; + +/** Every `R15_GATE=` entry in `text`, in order, duplicates included. */ +export function parseR15GateEntries(text) { + return [...text.matchAll(ENTRY)].map(([, gate, status]) => ({ gate, status })); +} + +/** Whether `status` is allowed for `gate`; a slice status must name a slice of that gate. */ +export function isValidR15Status(gate, status) { + if (R15_FIXED_STATUSES.includes(status)) return true; + return SLICE_STATUS.exec(status)?.[1] === gate; +} + +function entryProblem({ gate, status }, seen) { + if (!R15_GATE_IDS.includes(gate)) return `unknown gate R15_GATE${gate}`; + if (seen.has(gate)) return `duplicate entry for R15_GATE${gate}`; + if (!isValidR15Status(gate, status)) return `unsupported status R15_GATE${gate}=${status}`; + return null; +} + +/** Findings for malformed entries: unknown ids, duplicates and unsupported statuses. */ +function entryFindings(entries, where) { + const seen = new Set(); + const findings = []; + for (const entry of entries) { + const problem = entryProblem(entry, seen); + if (problem) findings.push(`${where} — ${problem}`); + seen.add(entry.gate); + } + return findings; +} + +function exactlyOne(matches, missing, many) { + if (matches.length === 1) return { value: matches[0] }; + return { error: matches.length === 0 ? missing : `${many} (${matches.length} found)` }; +} + +/** The ledger row 10 entries that differ from the canonical status. */ +function mismatchFindings(entries, canonical, where) { + return entries + .filter(({ gate, status }) => canonical.has(gate) && canonical.get(gate) !== status) + .map( + ({ gate, status }) => + `${where} — R15_GATE${gate}=${status}, but R15_GATE_STATUS says ${canonical.get(gate)}`, + ); +} + +/** Progressed gates (not NOT_ADMITTED) that row 10 does not record. */ +function missingFindings(entries, canonical, where) { + const present = new Set(entries.map(({ gate }) => gate)); + return [...canonical] + .filter(([gate, status]) => status !== 'NOT_ADMITTED' && !present.has(gate)) + .map(([gate, status]) => `${where} — missing R15_GATE${gate}=${status}`); +} + +/** Ledger row 10 against the canonical statuses. */ +function ledgerFindings(ledger, canonical) { + const where = `${R15_LEDGER_DOC} row 10`; + const row = exactlyOne( + ledger.match(LEDGER_ROW) ?? [], + `${R15_LEDGER_DOC} — row 10 (R-15) not found`, + `${R15_LEDGER_DOC} — more than one row 10`, + ); + if (row.error) return [row.error]; + const entries = parseR15GateEntries(row.value); + return [ + ...entryFindings(entries, where), + ...mismatchFindings(entries, canonical, where), + ...missingFindings(entries, canonical, where), + ]; +} + +/** Removes HTML comments, scanning to each closing marker; an unterminated comment runs to the end. */ +export function stripHtmlComments(text) { + let result = ''; + let index = 0; + while (index < text.length) { + const open = text.indexOf('', open + 4); + if (close === -1) return result; + index = close + 3; + } + return result; +} + +/** Tracks fenced code: returns the new fence marker (or null) after `line`. */ +function nextFence(line, fence) { + const marker = FENCE.exec(line)?.[1]; + if (!marker) return fence; + if (fence === null) return marker; + return marker[0] === fence[0] && marker.length >= fence.length ? null : fence; +} + +/** Tracks historical sections: returns the heading level that opened one (or null) after `line`. */ +function nextHistorical(line, historicalLevel) { + const level = HEADING.exec(line)?.[1].length; + if (level === undefined) return historicalLevel; + if (historicalLevel !== null && level > historicalLevel) return historicalLevel; + return HISTORICAL_HEADING.test(line) ? level : null; +} + +/** Current prose only: comments, code fences and historical sections removed, soft wraps joined. */ +export function currentProse(markdown) { + const kept = []; + let fence = null; + let historicalLevel = null; + for (const line of stripHtmlComments(markdown.replace(/\r\n?/g, '\n')).split('\n')) { + const wasFenced = fence !== null; + fence = nextFence(line, fence); + if (wasFenced || fence !== null) continue; + historicalLevel = nextHistorical(line, historicalLevel); + if (historicalLevel === null) kept.push(line); + } + // A blank line, heading, list item or table row starts a new unit; anything else continues one. + return kept.join('\n').replace(/\n(?!\n|#|\s*[-*|]|\s*\d+\.)/g, ' '); +} + +/** Gate ids for a bare gate number; Gate 1 is the pair 1a/1b. */ +const gateIds = (n) => (n === 1 ? ['1A', '1B'] : [String(n)]); + +/** The gate ids (and slice letter, if any) a reference names. */ +function referencedGates([, , from, suffix, slice, to]) { + if (to !== undefined) { + const numbers = Array.from( + { length: Math.max(0, Number(to) - Number(from) + 1) }, + (_, i) => Number(from) + i, + ); + return numbers.flatMap(gateIds).map((gate) => ({ gate })); + } + if (from === '1' && suffix) return [{ gate: `1${suffix.toUpperCase()}` }]; + if (from === '1') return gateIds(1).map((gate) => ({ gate })); + return [{ gate: from, slice: (slice ?? suffix)?.toUpperCase() }]; +} + +/** Whether calling `target` "not admitted" contradicts `status`. */ +function contradicts(status, target, restOf) { + if (status === undefined || status === 'NOT_ADMITTED') return false; + const partial = SLICE_STATUS.exec(status); + if (!partial) return true; // the whole gate is implemented + if (target.slice) return target.slice <= partial[2]; // a delivered slice called not admitted + return !restOf; // the whole partial gate called not admitted +} + +/** For each "not admitted" phrase, the text since the previous status predicate it governs. */ +function negativeSpans(clause) { + const spans = []; + let start = 0; + for (const match of clause.matchAll(PREDICATE)) { + if (NEGATIVE.test(match[0])) spans.push(clause.slice(start, match.index)); + start = match.index + match[0].length; + } + return spans; +} + +/** Every gate reference governed by a "not admitted" phrase in `prose`. */ +function notAdmittedReferences(prose) { + return prose + .split(CLAUSE_BREAK) + .flatMap((clause) => (clause ? negativeSpans(clause) : [])) + .flatMap((span) => [...span.matchAll(GATE_REF)]); +} + +function proseFindings(content, relPath, canonical) { + return notAdmittedReferences(currentProse(content)).flatMap((ref) => + referencedGates(ref) + .filter((target) => contradicts(canonical.get(target.gate), target, ref[1])) + .map( + (target) => + `${relPath} — "${ref[0].trim()}" is called not admitted, but R15_GATE_STATUS marks Gate ${target.gate} as ${canonical.get(target.gate)}`, + ), + ); +} + +/** The canonical block's validity findings and gate → status map. */ +function canonicalStatus(contract) { + const block = exactlyOne( + [...contract.matchAll(STATUS_BLOCK)].map((match) => match[1]), + `${R15_CONTRACT_DOC} — missing the machine-readable R15_GATE_STATUS block`, + `${R15_CONTRACT_DOC} — more than one R15_GATE_STATUS block`, + ); + if (block.error) return { findings: [block.error] }; + const entries = parseR15GateEntries(block.value); + const canonical = new Map(entries.map(({ gate, status }) => [gate, status])); + const findings = entryFindings(entries, `${R15_CONTRACT_DOC} R15_GATE_STATUS`).concat( + R15_GATE_IDS.filter((gate) => !canonical.has(gate)).map( + (gate) => `${R15_CONTRACT_DOC} — R15_GATE_STATUS has no entry for Gate ${gate}`, + ), + ); + return { findings, canonical }; +} + +/** All R-15 gate status findings for the contract and ledger texts. */ +export function scanR15GateStatusTruth(contractText, ledgerText) { + const contract = contractText.replace(/\r\n?/g, '\n'); + const ledger = ledgerText.replace(/\r\n?/g, '\n'); + const { findings, canonical } = canonicalStatus(contract); + if (!canonical) return findings; + return [ + ...findings, + ...ledgerFindings(ledger, canonical), + ...proseFindings(contract, R15_CONTRACT_DOC, canonical), + ...proseFindings(ledger, R15_LEDGER_DOC, canonical), + ]; +} + +function main() { + const root = + process.env.WORLDSCRIPT_REPOSITORY_ROOT ?? join(dirname(fileURLToPath(import.meta.url)), '..'); + const findings = scanR15GateStatusTruth( + readFileSync(join(root, R15_CONTRACT_DOC), 'utf8'), + readFileSync(join(root, R15_LEDGER_DOC), 'utf8'), + ); + if (findings.length > 0) { + process.stderr.write( + `[r15-gate-status] ${findings.length} finding(s):\n${findings.map((f) => ` - ${f}`).join('\n')}\n`, + ); + process.exit(1); + } + process.stdout.write( + '[r15-gate-status] OK — contract block, ledger row 10 and current prose agree.\n', + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/ci-prepush-lowend.mjs b/scripts/ci-prepush-lowend.mjs index a0a6622e9..0d95f32f9 100644 --- a/scripts/ci-prepush-lowend.mjs +++ b/scripts/ci-prepush-lowend.mjs @@ -101,6 +101,7 @@ async function main() { await runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])); await runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs')); + await runCheck('R-15 gate status', () => runNodeScript('scripts/check-r15-gate-status.mjs')); await runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')); await runCheck('Desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs'), diff --git a/scripts/sync-readme-metrics.mjs b/scripts/sync-readme-metrics.mjs index 728e10485..fbb88ab99 100644 --- a/scripts/sync-readme-metrics.mjs +++ b/scripts/sync-readme-metrics.mjs @@ -127,6 +127,11 @@ if (testCount != null) { new RegExp(`(Vitest unit tests \\()${NUM}\\+ tests;`), `$1${testCount}+ tests;`, ); + // Project-tree comment: "Vitest unit tests (the 8 504+ total also includes …)" — checked by docs:check. + readme = readme.replace( + new RegExp(`(Vitest unit tests \\(the )${NUM}\\+ total`), + `$1${testCount}+ total`, + ); // Line ~650: "**5 475+ unit tests** across **449 test files**" readme = readme.replace( new RegExp(`\\*\\*${NUM}\\+ unit tests\\*\\* across \\*\\*${NUM}test files\\*\\*`), diff --git a/tests/unit/checkR15GateStatus.test.ts b/tests/unit/checkR15GateStatus.test.ts new file mode 100644 index 000000000..91f8ec891 --- /dev/null +++ b/tests/unit/checkR15GateStatus.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment node +/** + * Tests for scripts/check-r15-gate-status.mjs (#933). + * QNBS-v3: contract↔ledger status drift recurred in #917/#928/#929/#930; each case is a shape a reviewer caught or a parser edge found in review. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + currentProse, + isValidR15Status, + parseR15GateEntries, + R15_CONTRACT_DOC, + R15_LEDGER_DOC, + scanR15GateStatusTruth, + stripHtmlComments, +} from '../../scripts/check-r15-gate-status.mjs'; + +const STATUS: Record = { + '1A': 'IMPLEMENTED_HEADLESS', + '1B': 'IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER', + '2': 'IMPLEMENTED_HEADLESS', + '3': 'SLICE_3A_DURABLE_STAGING', + '4': 'NOT_ADMITTED', + '5': 'NOT_ADMITTED', + '6': 'NOT_ADMITTED', + '7': 'NOT_ADMITTED', +}; + +function block(status: Record = STATUS, extra = ''): string { + const lines = Object.entries(status).map(([gate, value]) => `R15_GATE${gate}=${value}`); + return `\`\`\`text\nR15_GATE_STATUS\n${[...lines, extra].filter(Boolean).join('\n')}\n\`\`\`\n`; +} + +function contract(prose = '', status: Record = STATUS): string { + return `# R-15\n\n${prose}\n\n## 20. Plan\n\n${block(status)}`; +} + +// Named without the word that secret scanners treat as a credential keyword. +const LEDGER_STATUS_ROW = + 'R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / ' + + 'R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3A_DURABLE_STAGING'; + +function ledger(prose = 'the rest of Gate 3 and Gates 4–7 not admitted', row = LEDGER_STATUS_ROW) { + return `| 9 | Other | R15_GATE2=NOT_ADMITTED elsewhere is ignored |\n| 10 | R-15 | ${row}; ${prose} |\n`; +} + +const scan = (contractText: string, ledgerText = ledger()) => + scanR15GateStatusTruth(contractText, ledgerText); + +describe('scanR15GateStatusTruth — canonical block and ledger row 10', () => { + it('accepts agreeing documents, including the live repository documents', () => { + expect(scan(contract())).toEqual([]); + const root = join(__dirname, '..', '..'); + const live = scanR15GateStatusTruth( + readFileSync(join(root, R15_CONTRACT_DOC), 'utf8'), + readFileSync(join(root, R15_LEDGER_DOC), 'utf8'), + ); + expect(live).toEqual([]); + }); + + it('requires exactly one block with every gate', () => { + expect(scan('# R-15\n')).toEqual([ + `${R15_CONTRACT_DOC} — missing the machine-readable R15_GATE_STATUS block`, + ]); + expect(scan(`${contract()}\n${block()}`)).toEqual([ + `${R15_CONTRACT_DOC} — more than one R15_GATE_STATUS block (2 found)`, + ]); + const { '5': _omitted, ...withoutGate5 } = STATUS; + expect(scan(contract('', withoutGate5))).toEqual([ + `${R15_CONTRACT_DOC} — R15_GATE_STATUS has no entry for Gate 5`, + ]); + }); + + it('rejects duplicate, unknown and unsupported block entries', () => { + const where = `${R15_CONTRACT_DOC} R15_GATE_STATUS`; + const text = `# R-15\n\n${block(STATUS, 'R15_GATE2=NOT_ADMITTED\nR15_GATE8=IMPLEMENTED_HEADLESS')}`; + expect(scan(text)).toEqual( + expect.arrayContaining([ + `${where} — duplicate entry for R15_GATE2`, + `${where} — unknown gate R15_GATE8`, + ]), + ); + const typo = contract('', { ...STATUS, '4': 'IMPLEMENTED_TYPO' }); + expect(scan(typo)).toContain(`${where} — unsupported status R15_GATE4=IMPLEMENTED_TYPO`); + // A valid status with a malformed suffix is refused whole, not truncated to its valid prefix. + const suffixed = contract('', { ...STATUS, '4': 'NOT_ADMITTED-BAD' }); + expect(scan(suffixed)).toContain(`${where} — unsupported status R15_GATE4=NOT_ADMITTED-BAD`); + }); + + it('binds a slice status to its own gate', () => { + const where = `${R15_CONTRACT_DOC} R15_GATE_STATUS`; + const foreign = contract('', { ...STATUS, '3': 'SLICE_2A_DURABLE_STAGING' }); + expect( + scan(foreign, ledger(undefined, LEDGER_STATUS_ROW.replace('SLICE_3A', 'SLICE_2A'))), + ).toContain(`${where} — unsupported status R15_GATE3=SLICE_2A_DURABLE_STAGING`); + }); + + it('reads CRLF documents like LF documents', () => { + const crlf = (text: string) => text.replace(/\n/g, '\r\n'); + expect(scan(crlf(contract())), 'clean').toEqual([]); + expect(scan(crlf(contract('Gates 3–7\nremain not admitted.')), crlf(ledger()))).toHaveLength(1); + }); + + it('compares only ledger row 10 and rejects its mismatched, duplicate and extra entries', () => { + const where = `${R15_LEDGER_DOC} row 10`; + const stale = LEDGER_STATUS_ROW.replace( + 'R15_GATE2=IMPLEMENTED_HEADLESS', + 'R15_GATE2=SLICE_2A_X', + ); + expect(scan(contract(), ledger(undefined, stale))).toEqual([ + `${where} — R15_GATE2=SLICE_2A_X, but R15_GATE_STATUS says IMPLEMENTED_HEADLESS`, + ]); + // A conflicting duplicate is reported in either order. + for (const row of [ + `R15_GATE2=SLICE_2A_X / ${LEDGER_STATUS_ROW}`, + `${LEDGER_STATUS_ROW} / R15_GATE2=SLICE_2A_X`, + ]) { + expect(scan(contract(), ledger(undefined, row))).toContain( + `${where} — duplicate entry for R15_GATE2`, + ); + } + expect(scan(contract(), `${ledger()}${ledger()}`)).toEqual([ + `${R15_LEDGER_DOC} — more than one row 10 (2 found)`, + ]); + const extra = `${LEDGER_STATUS_ROW} / R15_GATE8=IMPLEMENTED_HEADLESS`; + expect(scan(contract(), ledger(undefined, extra))).toEqual([ + `${where} — unknown gate R15_GATE8`, + ]); + }); + + it('requires every progressed gate in row 10', () => { + const withoutGate3 = LEDGER_STATUS_ROW.replace(' / R15_GATE3=SLICE_3A_DURABLE_STAGING', ''); + expect(scan(contract(), ledger(undefined, withoutGate3))).toEqual([ + `${R15_LEDGER_DOC} row 10 — missing R15_GATE3=SLICE_3A_DURABLE_STAGING`, + ]); + }); +}); + +describe('scanR15GateStatusTruth — current prose', () => { + const finding = (ref: string, gate: string) => + `${R15_CONTRACT_DOC} — "${ref}" is called not admitted, but R15_GATE_STATUS marks Gate ${gate} as ${STATUS[gate]}`; + + it('catches the reviewer-caught shapes (#917/#929, #930)', () => { + expect(scan(contract('Gate 1b = implemented; Gates 2–7 = not admitted;'))).toEqual([ + finding('Gates 2–7', '2'), + finding('Gates 2–7', '3'), + ]); + expect(scan(contract('Gates 3–7 not admitted.'))).toEqual([finding('Gates 3–7', '3')]); + }); + + it('keeps references with their status across commas and soft wraps', () => { + expect(scan(contract('Gates 3–7, not admitted.'))).toEqual([finding('Gates 3–7', '3')]); + expect(scan(contract('Gates 3–7\nremain not admitted.'))).toEqual([finding('Gates 3–7', '3')]); + expect(scan(contract('Gate 3, along with Gates 4–7, is not admitted.'))).toEqual([ + finding('Gate 3', '3'), + ]); + }); + + it('separates sentences and contrastive clauses', () => { + expect(scan(contract('Gate 2 is implemented. Gates 4–7 are not admitted.'))).toEqual([]); + const contrast = + 'Gate 2 is implemented headless, while the rest of Gate 3 and Gates 4–7 remain unadmitted.'; + expect(scan(contract(contrast))).toEqual([]); + }); + + it('binds each "not admitted" only to the gates since the previous status predicate', () => { + expect(scan(contract('Gate 2 is implemented, and Gate 4 is not admitted.'))).toEqual([]); + expect(scan(contract('Gate 4 is not admitted, and Gate 2 is implemented.'))).toEqual([]); + expect(scan(contract('Gate 4 is not admitted, and Gate 2 is not admitted.'))).toEqual([ + finding('Gate 2', '2'), + ]); + }); + + it('does not read a longer number as a gate reference', () => { + expect(scan(contract('Gate 10 is not admitted.'))).toEqual([]); + expect(scan(contract('Gates 12 and 20 are not admitted.'))).toEqual([]); + }); + + it('resolves Gate 1a/1b and slice references exactly', () => { + const split = { ...STATUS, '1B': 'NOT_ADMITTED' }; + expect( + scan( + contract('Gate 1b is not admitted.', split), + ledger( + undefined, + LEDGER_STATUS_ROW.replace(' / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER', ''), + ), + ), + ).toEqual([]); + expect(scan(contract('Gate 1 is not admitted.'))).toHaveLength(2); + // Slices after the delivered one are legitimately not admitted; the delivered one is not. + expect(scan(contract('Gate 3B and Gate 3 slice 3C are not admitted.'))).toEqual([]); + expect(scan(contract('Gate 3 slice 3A is not admitted.'))).toEqual([ + finding('Gate 3 slice 3A', '3'), + ]); + }); + + it('ignores historical sections, code fences, comments and clauses without gate numbers', () => { + const ignored = [ + '## HISTORICAL / SUPERSEDED — old\n\nGates 1–7 are not admitted.\n\n## Current', + '```text\nGates 2–7 not admitted\n```', + '', + ' ', + ' Gates 3–7 not admitted.', + ]; + for (const prose of resumed) + expect(scan(contract(prose)), prose).toEqual([finding('Gates 3–7', '3')]); + }); +}); + +describe('helpers', () => { + it('parseR15GateEntries keeps every entry, duplicates included', () => { + expect(parseR15GateEntries('R15_GATE2=A_B / R15_GATE2=C_D')).toEqual([ + { gate: '2', status: 'A_B' }, + { gate: '2', status: 'C_D' }, + ]); + }); + + it('isValidR15Status accepts fixed statuses and slices of the same gate only', () => { + expect(isValidR15Status('4', 'NOT_ADMITTED')).toBe(true); + expect(isValidR15Status('3', 'SLICE_3A_DURABLE_STAGING')).toBe(true); + expect(isValidR15Status('3', 'SLICE_2A_DURABLE_STAGING')).toBe(false); + expect(isValidR15Status('3', 'SLICE_3A')).toBe(false); + }); + + it('stripHtmlComments removes every comment, including adjacent and unterminated ones', () => { + expect(stripHtmlComments('abc')).toBe('abc'); + expect(stripHtmlComments('a-->b')).toBe('a-->b'); + expect(stripHtmlComments('a