From ebad136a350caff3038487f000a8de55d093b9f3 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 11:46:08 +0300 Subject: [PATCH 1/6] =?UTF-8?q?Track=20=E2=80=94=20mark=20W78-W83=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slices W78-W83 all merged (issues #326-#331, PRs #340-#348) but the tracker still showed them not started. --- design/30-slices.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/design/30-slices.md b/design/30-slices.md index 3d23a4b..9279c72 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -3024,7 +3024,7 @@ rather than paper over. `export-campaigns.ts` onto the shared catalogue; adding, tightening or relaxing any validation rule — this unit surfaces the checks that exist and authors none. -### [ ] W78 — Localization Coverage and String Extraction {#w78} +### [x] W78 — Localization Coverage and String Extraction {#w78} **Delivers:** A translator can be handed the exact list of lines a campaign needs, and a maintainer can see which campaigns are translated, which are half-translated and which are not @@ -3045,7 +3045,7 @@ of nine has a second locale. `src/engine/src/core/localization/` and `src/engine/src/core/registry/` are **read, not modified**. - **Depends on:** [W77](#w77), for the shared campaign catalogue. -- **Status:** Not started. +- **Status:** Done — [issue #326](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/326). - **Done when:** - W78.1 For a named campaign the tool emits the complete, sorted set of `LocKey`s its built campaign requires — the keys `BuiltCampaign.strings` carries — in a form a translator @@ -3070,7 +3070,7 @@ of nine has a second locale. [09 §9](09-clients.md#9-deferred) leaves to a client; making coverage a CI gate, which is the same policy decision W77 declines to take unilaterally. -### [ ] W79 — What Changed Between Two Resolutions {#w79} +### [x] W79 — What Changed Between Two Resolutions {#w79} **Delivers:** When two saves say they were played against different content, someone can find out what was actually different. The platform can already prove two content resolutions are not @@ -3091,7 +3091,7 @@ it rather than a widening of it. `src/engine/src/core/registry/packs.ts` and `src/engine/src/campaigns/stable-life-packs.ts` are **read, not modified**. - **Depends on:** [W58](#w58), [W71](#w71) — both done. -- **Status:** Not started. +- **Status:** Done — [issue #327](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/327). - **Done when:** - W79.1 Given two ordered pack sets the tool reports both `ResolutionId`s and, when they differ, an itemized difference following [11 §3](11-content-packs.md#3-resolution)'s two @@ -3118,7 +3118,7 @@ it rather than a widening of it. Adventures.Content ([W74](#w74)); pack discovery and loading from disk ([11 §8](11-content-packs.md#8-what-is-deferred)). -### [ ] W80 — Seeing a Story Graph {#w80} +### [x] W80 — Seeing a Story Graph {#w80} **Delivers:** Anyone looking at an authored adventure can see its shape — where it starts, where it branches, which endings hang off which route — instead of reconstructing it from several @@ -3134,7 +3134,7 @@ any of it. - **Touches:** a new script under `src/engine/scripts/`; `src/engine/package.json` scripts. - **Depends on:** [W77](#w77), for the shared campaign catalogue and for the Tier 2 warnings W80.4 cross-checks against. -- **Status:** Not started. +- **Status:** Done — [issue #328](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/328). - **Done when:** - W80.1 For a named story-graph campaign the tool emits a text graph — Mermaid, which needs no dependency to write — with one vertex per node, edges labelled by choice id, and @@ -3218,7 +3218,7 @@ but extracting a shared runner from a pipeline where a quarter of the systems ar extract the shape of the stubs. It becomes sliceable once W85 lands, and it is better for the wait: five more real systems is five more constraints on what the abstraction has to carry. -### [ ] W81 — Construction Finishes What `build` Starts {#w81} +### [x] W81 — Construction Finishes What `build` Starts {#w81} **Delivers:** Someone laying out a resort can put up a structure that takes time to build, watch their staff work on it, and then use it. Today starting one is a dead end — the site appears, no @@ -3242,7 +3242,7 @@ generated, never given effort, and never applied. `src/engine/fixtures/replay/`. - **Depends on:** [W45](#w45), [W46](#w46), [W47](#w47) — all done; they shipped the reducer, the construction-site state and the comparator registry. -- **Status:** Not started. +- **Status:** Done — [PR #345](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/345). - **Done when:** - W81.1 A building definition declaring construction work, placed through `build`, opens a site whose remaining work falls by the assigned staff member's `build` effort per tick on every @@ -3286,7 +3286,7 @@ generated, never given effort, and never applied. has no construction path at all; the shared `SystemPipeline` ([issue #270](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/270)). -### [ ] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} +### [x] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} **Delivers:** A stall that sells a limited number of things can be restocked by staff, so running out is a setback rather than the permanent end of that building's usefulness. Today the running-out @@ -3304,7 +3304,7 @@ and system 13 also owns the non-wear operational status changes that no other sy `src/engine/src/kinds/world-graph/kind.ts`; `src/engine/src/campaigns/world-graph-mvp.ts`; a new fixture pair under `src/engine/fixtures/replay/`. - **Depends on:** [W81](#w81), for the systems 9 and 11 task-kind branches it establishes. -- **Status:** Not started. +- **Status:** Done — [PR #347](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/347). - **Done when:** - W82.1 A service product declaring initial units and a capacity is served down to zero, a restock candidate is generated with effort equal to the missing units and the priority the @@ -3333,7 +3333,7 @@ and system 13 also owns the non-wear operational status changes that no other sy name; supply chains, warehouses or delivery; pricing behaviour, which `set_price` already owns; cleanliness and wear ([W83](#w83)). -### [ ] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} +### [x] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} **Delivers:** A resort that is left unattended degrades, and one that is looked after does not. Buildings get dirty from use and from litter, wear down over time, and eventually break and stop @@ -3355,7 +3355,7 @@ system is half named after. a new fixture pair under `src/engine/fixtures/replay/`. - **Depends on:** nothing — it shares no code path with [W81](#w81) or [W82](#w82) and may be taken before either. It is placed here because [W84](#w84) depends on it. -- **Status:** Not started. +- **Status:** Done — [PR #348](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/348). - **Done when:** - W83.1 For one building receiving deltas from every source in the same tick, the five sources are applied in the contract's stated order — service, litter, incident, staff, policy — From cee1f9af853b2a0654169fa39a07880250399668 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 11:52:31 +0300 Subject: [PATCH 2/6] Retire the qodo-code-review reference in the required-checks note Qodo is retired; automated PR review now runs via the anthropics/claude-code-action GitHub Action. --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4670d68..56b078c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -549,10 +549,12 @@ is `0`, so it *would* work — this is a deliberate workflow choice, not a limit `Verify Documentation Build`. The deploy job is *not* required (it runs only on `main`, so requiring it would leave every PR pending). -**`required_review_thread_resolution` is on**, and `qodo-code-review` leaves conversation -threads that do **not** appear in `gh pr view --json reviewRequests,latestReviews`. Query -threads directly via the GraphQL `reviewThreads` field. Resolve a thread only when a -validated fix satisfies it; leave ambiguous findings open and report them. +**`required_review_thread_resolution` is on.** Automated PR review runs via the +`anthropics/claude-code-action` GitHub Action (Qodo is retired and no longer in use). +Automated review comments can leave conversation threads that do **not** appear in +`gh pr view --json reviewRequests,latestReviews`; query threads directly via the GraphQL +`reviewThreads` field. Resolve a thread only when a validated fix satisfies it; leave +ambiguous findings open and report them. ### Validation From 693fa1698876d7a590b8ed26eb17fb4150502fbf Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 11:52:43 +0300 Subject: [PATCH 3/6] Kit sync: fast-forward ~/.agent-kit and reconcile commands/tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from af610a6 to e9e21bf (9 commits): adds /install-code-review-agent, the codex profile launcher and its tests, the design-state read/check/projection scripts and their tests, and updates the existing command cores and Sync-Kit/Test-Companion tooling. verify.md's core was Unmigrated-Blocked (this repo's gate table had no companion) — moved the table into verify-local.md under the gate-commands category the core declares, so the sync could take the core outright. Recorded in design/90-decisions.md. --- .claude/COMPANIONS.md | 7 + .claude/commands/brief-check.md | 4 +- .claude/commands/contract.md | 18 +- .claude/commands/design.md | 6 +- .claude/commands/done.md | 4 +- .claude/commands/fix.md | 10 +- .claude/commands/freeze.md | 4 +- .claude/commands/install-all.md | 15 +- .claude/commands/install-code-review-agent.md | 71 + .claude/commands/install.md | 4 +- .claude/commands/kit-help.md | 4 +- .claude/commands/kit-sync.md | 4 +- .claude/commands/make-human-docs.md | 4 +- .claude/commands/pr.md | 12 +- .claude/commands/reconcile.md | 6 +- .claude/commands/redteam.md | 4 +- .claude/commands/refine.md | 4 +- .claude/commands/resolve.md | 22 +- .claude/commands/slice.md | 8 +- .claude/commands/slices.md | 4 +- .claude/commands/track.md | 21 +- .claude/commands/unfreeze.md | 4 +- .claude/commands/verify-local.md | 31 + .claude/commands/verify.md | 48 +- .claude/kit.json | 9 +- design/90-decisions.md | 14 + tools/Invoke-CodexCommand.ps1 | 137 ++ tools/Invoke-DoneHousekeeping.Tests.ps1 | 83 + tools/Invoke-DoneHousekeeping.ps1 | 29 +- tools/Read-DesignState.Tests.ps1 | 255 ++++ tools/Read-DesignState.ps1 | 368 +++++ tools/Sync-Kit.Tests.ps1 | 94 ++ tools/Sync-Kit.ps1 | 70 +- tools/Test-CIWorkflow.Tests.ps1 | 34 + tools/Test-Companion.Tests.ps1 | 28 +- tools/Test-Companion.ps1 | 10 +- tools/Test-DesignState.Tests.ps1 | 1330 +++++++++++++++++ tools/Test-DesignState.ps1 | 1274 ++++++++++++++++ tools/Update-DesignProjection.Tests.ps1 | 387 +++++ tools/Update-DesignProjection.ps1 | 371 +++++ tools/Update-WorkMirror.Tests.ps1 | 217 +++ tools/Update-WorkMirror.ps1 | 275 ++++ 42 files changed, 5203 insertions(+), 101 deletions(-) create mode 100644 .claude/commands/install-code-review-agent.md create mode 100644 .claude/commands/verify-local.md create mode 100644 tools/Invoke-CodexCommand.ps1 create mode 100644 tools/Invoke-DoneHousekeeping.Tests.ps1 create mode 100644 tools/Read-DesignState.Tests.ps1 create mode 100644 tools/Read-DesignState.ps1 create mode 100644 tools/Test-CIWorkflow.Tests.ps1 create mode 100644 tools/Test-DesignState.Tests.ps1 create mode 100644 tools/Test-DesignState.ps1 create mode 100644 tools/Update-DesignProjection.Tests.ps1 create mode 100644 tools/Update-DesignProjection.ps1 create mode 100644 tools/Update-WorkMirror.Tests.ps1 create mode 100644 tools/Update-WorkMirror.ps1 diff --git a/.claude/COMPANIONS.md b/.claude/COMPANIONS.md index 6a8e2c6..d67564c 100644 --- a/.claude/COMPANIONS.md +++ b/.claude/COMPANIONS.md @@ -9,6 +9,13 @@ This file is the mechanism. It is kit-owned and installed into every target, and single home for the category vocabulary, the never-list, and the absence rule. A core command file names *which categories* apply to it; it does not restate any of what is below. +## The block is a declared region + +A core's fenced companion block is a **declared** marked region, id `companion` — `AGENTS.md` +(*Marked regions*) owns what declared means and what the marker looks like, and neither is +restated here. In short: hand-authored, never written by a generator, checked for presence and +well-formedness the same as any other marked region. + ## Why the split exists `/install`, `INSTALL.md` and `/kit-sync` used to spend real effort reconciling a target's local diff --git a/.claude/commands/brief-check.md b/.claude/commands/brief-check.md index 0465073..56f686d 100644 --- a/.claude/commands/brief-check.md +++ b/.claude/commands/brief-check.md @@ -2,12 +2,12 @@ description: Interrogate the concept brief before any design work --- - + **Per-repo companion:** `.claude/commands/brief-check-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Read `design/00-brief.md`. diff --git a/.claude/commands/contract.md b/.claude/commands/contract.md index c9fe062..ddce796 100644 --- a/.claude/commands/contract.md +++ b/.claude/commands/contract.md @@ -2,12 +2,12 @@ description: Derive the interface contract from the design doc --- - + **Per-repo companion:** `.claude/commands/contract-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + ## Stop if `design/` is frozen @@ -46,12 +46,16 @@ Rules: - If the design doc does not determine a signature, do not invent it. List it under `## Unresolved` and stop. - **Do not restate a declaration the tree already carries.** Point at it and state what it cannot say. - No implementation. No comments explaining intent — the design doc carries intent. File paths are permitted **only** as the pointers this section requires. -- Anything you add here that was not implied by the design doc gets a decision-log entry. +- Anything you add here that was not implied by the design doc gets a decision-log entry. Where this repository's own `design/state/` exists, writing it also follows the record-writing sequence in `design/10-design.md` § *Record* — not restated here. ## Re-run -Rewrites `design/20-contract.md` in full from the current `design/10-design.md` — there is no -partial regeneration. A scaffold already replaced by a pointer to a materialised declaration +`design/20-contract.md` is **repository-scoped**: a landed path's standing contract — the +invariants and surface for work already shipped — stands as written and is never rewritten +away because a later run covers a different path. A re-run regenerates the section(s) that +correspond to the `design/10-design.md` pass that invoked it, and leaves every other landed +path's content untouched — there is no whole-document rewrite that starts from a blank +document. A scaffold already replaced by a pointer to a materialised declaration (*Semantics, not shape*, above) must stay a pointer; a re-run never turns it back into a -scaffold. `## Unresolved` only ever shrinks between runs, as signatures get resolved — an -entry a previous run resolved must never reappear. +scaffold, landed path or not. `## Unresolved` only ever shrinks between runs, as signatures +get resolved — an entry a previous run resolved must never reappear. diff --git a/.claude/commands/design.md b/.claude/commands/design.md index 0f73ee8..0541432 100644 --- a/.claude/commands/design.md +++ b/.claude/commands/design.md @@ -2,12 +2,12 @@ description: Produce or revise the design doc from the brief --- - + **Per-repo companion:** `.claude/commands/design-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + ## Stop if `design/` is frozen @@ -42,7 +42,7 @@ Things that cannot be resolved without information I have not given you. Ask the Rules: - No code. No file layouts. No package names beyond what a decision required. -- Every decision that survives goes into `design/90-decisions.md` in the logged format. +- Every decision that survives goes into `design/90-decisions.md` in the logged format. Where this repository's own `design/state/` exists, writing it also follows the record-writing sequence in `design/10-design.md` § *Record* — not restated here. - If the brief is too thin to design against, stop and say what is missing rather than inventing requirements. ## Re-run diff --git a/.claude/commands/done.md b/.claude/commands/done.md index c4c432d..3207a6b 100644 --- a/.claude/commands/done.md +++ b/.claude/commands/done.md @@ -2,12 +2,12 @@ description: Switch back to the default branch, delete local branches already merged into it, and prune stale remote-tracking refs --- - + **Per-repo companion:** `.claude/commands/done-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `extra-steps`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Housekeeping for the end of a piece of work: get back to the default branch, remove the local branches that are done, and drop remote-tracking refs for branches deleted on the remote. diff --git a/.claude/commands/fix.md b/.claude/commands/fix.md index fd25568..8bf07e5 100644 --- a/.claude/commands/fix.md +++ b/.claude/commands/fix.md @@ -3,12 +3,12 @@ description: Reproduce and fix a defect that has no slice — from a bug issue n argument-hint: [issue number, a description, or leave blank to auto-pick the highest-value open bug] --- - + **Per-repo companion:** `.claude/commands/fix-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`, `gate-commands`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Fix the defect named by **$1** — an issue number, a description, a failing test already in this session's context, or (with no argument and none of those) the highest-value open bug issue, picked automatically. `/slice` needs a slice id and a contract signature; a bug has neither, which is why this command exists as its own front door rather than an extension of `/slice`. @@ -28,6 +28,10 @@ Before filing anything, branching, or editing: **reproduce the defect.** Write o Filing happens **after** reproducing, never before — filing first would put an unreproduced report into the tracker as a bug. +## Orienting on the code you're fixing + +**Where this repository's own `design/state/` exists**, establishing what is currently true about the command or script the defect lives in reads that unit's closure (`design/10-design.md` § *Orient*) rather than the corpus, and `design/90-decisions.md` is not opened to establish it. Reading a closure is not writing to `design/`, so this does not relax the *Never* list below (I6). **Where `design/state/` is absent**, behaviour is today's: read the source directly (I27). + ## Branch Derive `fix/-` from the issue number and title, **after the issue exists** — never before, since the branch name needs a real number. @@ -38,7 +42,7 @@ Derive `fix/-` from the issue number and title, **after the issue e Implement against the issue's agent block. When it is satisfied: -- **Push, then open the pull request. Never as a draft.** Carved out of the authorization rule the same as pushing the branch (`AGENTS.md`, *Git and delivery*). +- **Push, then open the pull request. Never as a draft.** Carved out of the authorization rule the same as pushing the branch (`AGENTS.md`, *Git and delivery*). **Write the real description as you open it**, in the shape `.claude/commands/pr.md` § *Phase 1* fixes; a body deferring to a later `/pr` run leaves a reviewer who arrives first with nothing to read. `Verified` is the one section that says the gates have not run yet, and `/pr` replaces it verbatim. - **`/pr`** — same session. Writes the real description, runs this repository's gates and writes their three lists into the `Verified` section verbatim, and works the review threads once review lands. Fixing and resolving are delegated there — no ask required. This command does not carry a second copy of `/pr`'s rules, or of the gate and thread procedures it delegates to — it references them by name and hands off. diff --git a/.claude/commands/freeze.md b/.claude/commands/freeze.md index 87b33f3..dcaa402 100644 --- a/.claude/commands/freeze.md +++ b/.claude/commands/freeze.md @@ -2,12 +2,12 @@ description: Create design/FROZEN.md so design/ stops drifting while implementation is the bottleneck --- - + **Per-repo companion:** `.claude/commands/freeze-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Write `design/FROZEN.md`. Its existence is the whole mechanism — the rule, what it gates, and the marker's format are owned by `AGENTS.md`, *The design freeze*; this command only produces the file correctly and does not restate the rule. diff --git a/.claude/commands/install-all.md b/.claude/commands/install-all.md index 4e251b4..9d18ab4 100644 --- a/.claude/commands/install-all.md +++ b/.claude/commands/install-all.md @@ -3,12 +3,12 @@ description: Reconcile the kit into every SubZeroDev.* repository, unattended. U argument-hint: [repo name[,repo name...]] --- - + **Per-repo companion:** `.claude/commands/install-all-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `extra-steps`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Run `INSTALL.md`'s reconciliation against every sibling repository, in one unattended pass. **$1**, if given, is an explicit ordered list of repo names (comma-separated) — only those run, in that order. Bare, it discovers every `SubZeroDev.*` sibling of this kit and runs them alphabetically. @@ -17,12 +17,19 @@ This command does not replace `/install`; it orchestrates it. Discovery, orderin ## Phase 0 — Discover ```powershell -Get-ChildItem (Split-Path -Parent) -Directory -Filter 'SubZeroDev.*' +$kitRoot = git rev-parse --show-toplevel +Get-ChildItem (Split-Path $kitRoot -Parent) -Directory -Filter 'SubZeroDev.*' ``` +Run this from the repository that holds the installed kit; `$kitRoot` is that repository's +resolved Git root. + - Drop the kit itself. - **Resolve every candidate's real root** with `git -C rev-parse --show-toplevel` before adding it to the list. Two paths resolving to the same root — a junction, a symlink, a Dropbox-synced duplicate — are one repository; keep the first, report the rest as skipped duplicates. A stray `SubZeroDev.Platform;C` sitting next to `SubZeroDev.Platform` is exactly this case: check before assuming two candidates differ. -- **Not a git repository** — report and skip it. Do not stop the run for one bad candidate. +- **Not a git repository** — report and skip it. Do not stop the run for one bad candidate, and **do not + initialize one here.** `INSTALL.md` phase 4 creates an absent repository under a sign-off this command + never collects; unattended, across a directory of candidates, that turns a stray or mistyped sibling into + a repository holding a copy of the kit. Attended `/install` is where an absent repository is created. - Order: the explicit list in `$1` if given, else alphabetical. ## Phase 1 — Per target, run `INSTALL.md` phases 0 through 2 unmodified diff --git a/.claude/commands/install-code-review-agent.md b/.claude/commands/install-code-review-agent.md new file mode 100644 index 0000000..1d95661 --- /dev/null +++ b/.claude/commands/install-code-review-agent.md @@ -0,0 +1,71 @@ +--- +description: Install Anthropic's Claude Code GitHub Action into a repository so pull requests get automated Claude review. Usage - /install-code-review-agent, or /install-code-review-agent D:\Projects\Some.Repo +argument-hint: [target repo path] +--- + + +**Per-repo companion:** `.claude/commands/install-code-review-agent-local.md`. Read it now, if it exists — an absent, +empty, or frontmatter-only file is no companion, and this file then stands alone. +It may override: `extra-steps`, `tightened-authorization`. It may never override anything in +[`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. + + +Install Anthropic's official `claude-code-action` into a GitHub repository, so pull requests get an automated Claude review. **$1** is the target repository path; default to the current repository if not given. + +This installs three things, and only one of them is this command's to do: + +1. **The workflow file** — `.github/workflows/claude-code-review.yml`. This command writes it. +2. **The GitHub App** (`https://github.com/apps/claude`) — grants Claude's GitHub identity access to the target repository. This is a browser consent flow on the user's GitHub account; it cannot be scripted or driven headlessly, and this command does not attempt it (`AGENTS.md`, top-level: *bypassing or completing CAPTCHAs or other bot-detection* and *granting OAuth/SSO permissions* are never automated). +3. **The `ANTHROPIC_API_KEY` repository secret** (or `CLAUDE_CODE_OAUTH_TOKEN` for a Claude Pro/Max/Team subscription, via `claude setup-token`) — this command never enters the value itself, under any circumstance, even if it is pasted into chat. Entering an API key into a field is a prohibited action at the top level of this session, and no per-repo authorization changes that. + +## Resolve the target + +`$1` if given, else the current repository. Confirm it is a GitHub repository (`gh repo view` succeeds) before doing anything else. + +## Check prerequisites + +```powershell +gh auth status +``` + +Not authenticated, or `gh` missing entirely — stop and report. This command does not attempt to install or authenticate `gh` on the user's behalf. + +## Classify the workflow file + +```powershell +Test-Path /.github/workflows/claude-code-review.yml +``` + +- **Absent** — proceed to *Choose a mode*. +- **Present** — read it. If its `uses:` line already pins `anthropics/claude-code-action`, report **Identical** (or note a version difference) and stop; re-running this command is not how an existing installation is upgraded. If it is a different workflow that happens to occupy this filename, this is **Occupied** — stop and ask, the same as `INSTALL.md` phase 1 treats any other occupied artifact. Never overwrite a workflow file this command did not write. + +## Choose a mode + +Ask which mode, recommending automatic review first: + +- **Automatic review (recommended)** — triggers on `pull_request` (`opened`, `synchronize`, `ready_for_review`, `reopened`) and runs `/code-review:code-review --comment` against the PR, posting findings as inline comments. This is what "install the code review agent" means in the common case. +- **Mention-only** — triggers on `issue_comment` and `pull_request_review_comment` containing `@claude`, and only acts when someone asks. + +Both need `contents: write`, `pull-requests: write`, `issues: write`, `id-token: write`, `actions: read` permissions on the job, and an `actions/checkout` step ahead of the `anthropics/claude-code-action@v1` step. + +## Write the workflow file + +Write `/.github/workflows/claude-code-review.yml` with the chosen trigger and `anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}` (or `claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` if the user said they use a subscription token instead of an API key — ask which, do not guess). + +## Report — and stop + +- Whether the workflow file was created, and its path +- Whether the GitHub App is already installed on the target (`gh api /repos/{owner}/{repo}/installation` if reachable; otherwise say it could not be checked) — if not, give the user the install URL and ask them to do it and confirm back +- Whether `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) already exists as a repository secret (`gh secret list` — this shows names only, never values, so checking existence is safe) — if not, give the user the exact `gh secret set ` command to run **themselves**, in their own terminal +- **Do not commit, push, or open a pull request.** This command stops at the report for sign-off, the same as `/install` and `/kit-sync` — a workflow file that grants CI a repository secret is not something this command lands unattended. + +## Never + +- Enter, echo, or infer an API key or OAuth token value, in any field, under any authorization. +- Attempt to drive the GitHub App installation's browser consent screen. +- Overwrite a workflow file this command did not write. +- Commit, push, or open a pull request. That is the user's call once the report is reviewed. + +## Re-run + +Re-running against a target that already has the workflow file reports **Identical** and stops rather than rewriting it — upgrading the action version or changing the mode is a manual edit to the workflow file, not this command's job. diff --git a/.claude/commands/install.md b/.claude/commands/install.md index 5bd7473..3a75733 100644 --- a/.claude/commands/install.md +++ b/.claude/commands/install.md @@ -3,12 +3,12 @@ description: Install or upgrade the agent kit in a repository. Usage - /install argument-hint: --- - + **Per-repo companion:** `.claude/commands/install-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `extra-steps`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Install the agent kit. **$1** is the other end — the target if you are running this from the kit, the kit if you are running this from a target. diff --git a/.claude/commands/kit-help.md b/.claude/commands/kit-help.md index 63c7ac2..a41c73b 100644 --- a/.claude/commands/kit-help.md +++ b/.claude/commands/kit-help.md @@ -3,12 +3,12 @@ description: Where this repository is in the pipeline, and what to run next. Usa argument-hint: [all, or a stage or command name] --- - + **Per-repo companion:** `.claude/commands/kit-help-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Orient the user in this repository's pipeline. **$1** narrows it — `all` shows the whole flow, a stage or command name shows that step. With nothing, work out where the repository actually is and show the current step and the next one. diff --git a/.claude/commands/kit-sync.md b/.claude/commands/kit-sync.md index ca58fd1..b7049a0 100644 --- a/.claude/commands/kit-sync.md +++ b/.claude/commands/kit-sync.md @@ -3,12 +3,12 @@ description: Clone or update the shared kit checkout at ~/.agent-kit, then run I argument-hint: [branch] --- - + **Per-repo companion:** `.claude/commands/kit-sync-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `extra-steps`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Get the kit itself onto disk, then reconcile it into this repository — the two steps `/install` needs, done back to back, without requiring the kit to already be checked out somewhere you point at. diff --git a/.claude/commands/make-human-docs.md b/.claude/commands/make-human-docs.md index c0ec402..95cb2f4 100644 --- a/.claude/commands/make-human-docs.md +++ b/.claude/commands/make-human-docs.md @@ -2,12 +2,12 @@ description: Generate the human-facing guide from the design docs --- - + **Per-repo companion:** `.claude/commands/make-human-docs-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Read `design/10-design.md`, `design/20-contract.md`, and `design/00-brief.md`. Write `docs/docs/guide.md`. diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md index d4423d5..db0235c 100644 --- a/.claude/commands/pr.md +++ b/.claude/commands/pr.md @@ -2,12 +2,12 @@ description: Take the current branch's pull request to merge-ready — description, gates, then review threads --- - + **Per-repo companion:** `.claude/commands/pr-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `extra-steps`, `gate-commands`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Take the work on the current branch to merge-ready, in three phases, in order. @@ -21,12 +21,16 @@ Take the work on the current branch to merge-ready, in three phases, in order. ```powershell git status --short --branch -git log --oneline @{u}..HEAD +if (git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>$null) { + git log --oneline '@{u}..HEAD' +} else { + Write-Host "No upstream configured for this branch — push first: git push -u origin HEAD" +} git diff --check gh pr view --json number,isDraft,url,title 2>$null ``` -- **Every commit must be pushed first.** Announcing a PR invites an immediate merge, and a commit pushed after that lands on a branch nobody merges. Check `@{u}..HEAD` is empty before you announce anything. +- **Every commit must be pushed first.** Announcing a PR invites an immediate merge, and a commit pushed after that lands on a branch nobody merges. **Check for an upstream before running the ahead/behind comparison** — `@{u}..HEAD` on a branch with no upstream configured errors rather than reporting anything, so guard it as above and, where none is configured, state the next step (`git push -u origin HEAD`) rather than pushing on your own initiative; pushing still follows this repository's own authorization rule. Once an upstream exists, `@{u}..HEAD` must be empty before you announce anything. - **Never open a PR from the default branch.** If that is where the work is, stop and say so — moving commits to a branch is the user's call. - Stage by explicit named path. Never `git add -A`, `git add .`, or a bare directory. diff --git a/.claude/commands/reconcile.md b/.claude/commands/reconcile.md index d1eb87c..59c4314 100644 --- a/.claude/commands/reconcile.md +++ b/.claude/commands/reconcile.md @@ -2,12 +2,12 @@ description: Check the design docs against the tree, and decide the differences that are decisions --- - + **Per-repo companion:** `.claude/commands/reconcile-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + ## Stop if `design/` is frozen @@ -56,7 +56,7 @@ For each: which direction you recommend — the code changing to match the doc, If a section found nothing, say "none" and move on. Do not manufacture a fork to have something to ask about. -Once I have decided, apply the edits and append the decision-log entries. Nothing else. +Once I have decided, apply the edits and append the decision-log entries — following the full record-writing sequence in `design/10-design.md` § *Record* where this repository's own `design/state/` exists. Nothing else beyond that sequence. ## Re-run diff --git a/.claude/commands/redteam.md b/.claude/commands/redteam.md index ed5e6b7..9e582d2 100644 --- a/.claude/commands/redteam.md +++ b/.claude/commands/redteam.md @@ -2,12 +2,12 @@ description: Adversarial review of the design doc. Run in a fresh session, ideally on a different vendor's model. --- - + **Per-repo companion:** `.claude/commands/redteam-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Read `design/00-brief.md` and `design/10-design.md`. diff --git a/.claude/commands/refine.md b/.claude/commands/refine.md index a026af8..1d30db7 100644 --- a/.claude/commands/refine.md +++ b/.claude/commands/refine.md @@ -3,12 +3,12 @@ description: Turn a rough ask into a prompt carrying this repository's binding c argument-hint: --- - + **Per-repo companion:** `.claude/commands/refine-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Turn **$ARGUMENTS** into a prompt that carries the constraints this repository actually binds an agent to. diff --git a/.claude/commands/resolve.md b/.claude/commands/resolve.md index 290a82e..b9edfb2 100644 --- a/.claude/commands/resolve.md +++ b/.claude/commands/resolve.md @@ -3,18 +3,18 @@ description: Triage a pull request's review comments, fix what is valid, and res argument-hint: [pr number] --- - + **Per-repo companion:** `.claude/commands/resolve-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Work the review comments on pull request **$1** — the current branch's PR if no number is given. **`/pr` runs this as its final phase**, once review has landed on the pull request it took to merge-ready. This file owns the procedure — the query, the classes, the order of operations; `/pr` owns only where the sequence sits. Invoked on its own, it does exactly the same thing against any pull request named. -**Resolving a thread is an external write, but this repository delegates it** (`AGENTS.md`, *Git and delivery*): once a thread is classified `Defect` and the fix satisfying it is pushed, resolve it without asking first. This delegation covers execution only — classification itself still runs on the merit of the claim, and `Ambiguous` threads are still brought individually. This delegation is unavailable in a repository this account does not own; there, ask before resolving anything, per that same section. +**Resolving a thread, and replying to one, are both external writes.** Whether either needs a prompt is set by the target repository's own `AGENTS.md`, not by this file — this repository delegates both together (`AGENTS.md`, *Git and delivery*): once a thread is classified and its fix or reply is ready, post the reply and resolve without asking first. This delegation covers execution only — classification, and drafting the fix or reply, always run on the merit of the claim with no prompt either way, and `Ambiguous` threads are still brought individually regardless of delegation. Where the target repository's own `AGENTS.md` does not delegate replying and resolving — including any repository this account does not own — ask before posting a reply or resolving anything, per that same section. ### Gates for automatic resolution @@ -34,10 +34,14 @@ A thread is resolved without asking only if **all four** named gates pass. Uncer `gh pr view --json reviewRequests,latestReviews` **does not show conversation threads.** An automated reviewer can leave threads that block merge and appear nowhere in that listing — this has cost real time, and it is why the query is written out here: ```bash +OWNER="$(gh repo view --json owner --jq .owner.login)" +REPO="$(gh repo view --json name --jq .name)" +PR_NUMBER="${1:-$(gh pr view --json number --jq .number)}" + gh api graphql --paginate -f query=' -query($endCursor: String) { - repository(owner:"OWNER", name:"REPO") { - pullRequest(number:N) { +query($endCursor: String, $owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { reviewThreads(first:100, after:$endCursor) { pageInfo { hasNextPage endCursor } nodes { @@ -47,7 +51,7 @@ query($endCursor: String) { } } } -}' +}' -f owner="$OWNER" -f repo="$REPO" -F number="$PR_NUMBER" ``` `--paginate` walks `reviewThreads`' own `pageInfo` to exhaustion — a PR with more than 100 threads is not silently truncated. Each thread's nested `comments` connection paginates separately and `--paginate` does not reach it: if a thread's `comments.pageInfo.hasNextPage` comes back `true`, its first 10 comments are not the whole conversation, and it needs its own follow-up query, looped on that thread's `comments.pageInfo.endCursor` until `hasNextPage` is `false`, before it can be classified: @@ -82,7 +86,7 @@ Produce one scannable table — every thread, one row, its `PRRT_…` node id in | **Already decided** | Contradicts a recorded decision | Reply, link the decision-log entry or ADR. Do not relitigate | | **Ambiguous** | Two readings are both defensible | **Bring to me individually.** Do not guess | -Act on the four clear classes without further prompting. **Bring only the ambiguous ones for sign-off, one at a time** — that is proportionate: a twenty-comment automated review must not become twenty round trips, but nothing debatable gets resolved on your judgement alone. +Classify and draft the fix or reply for the four clear classes without further prompting — that work runs on the merit of the claim, not on delegation. **Posting the reply, like resolving, follows the delegation named above:** where the target repository delegates it, post and resolve without asking; where it is silent or unavailable, bring the prepared reply and resolution for sign-off before posting either. **Bring only the ambiguous ones for sign-off, one at a time** — that is proportionate: a twenty-comment automated review must not become twenty round trips, but nothing debatable gets resolved on your judgement alone. ## Order of operations @@ -95,7 +99,7 @@ This sequence is the safeguard. Do not reorder it. **Never resolve a thread you did not address.** Resolving is how a blocking finding becomes invisible — it is the one action here that cannot be noticed afterwards. Leave anything ambiguous, contested, or merely replied-to **open**, and say so in your report. -In a repository this account does not own, the delegation above is unavailable: fix and push, then ask before resolving anything, per `AGENTS.md`, *Git and delivery*. +In a repository this account does not own, or one whose `AGENTS.md` does not delegate replying and resolving, the delegation above is unavailable: fix and push, then ask before posting a reply or resolving anything, per `AGENTS.md`, *Git and delivery*. ## Report diff --git a/.claude/commands/slice.md b/.claude/commands/slice.md index 45a38d1..d90156b 100644 --- a/.claude/commands/slice.md +++ b/.claude/commands/slice.md @@ -3,12 +3,12 @@ description: Implement one slice. Usage - /slice S3, or /slice for the next one argument-hint: [slice id, omit for the next] --- - + **Per-repo companion:** `.claude/commands/slice-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`, `gate-commands`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Implement one slice from `design/30-slices.md`. The slice is **$1**, where that names one. Where it is empty — or, invoked outside Claude Code, still the literal `$1` — select it as below first. @@ -41,6 +41,8 @@ Stop and ask rather than choosing when: Before writing code, read `design/20-contract.md` for every signature you will touch. The contract is authoritative — if what you need is not in it, stop. +**Where this repository's own `design/state/` exists**, establishing what is currently true about a unit you are about to touch reads that unit's closure (`design/10-design.md` § *Orient*) rather than the corpus, and `design/90-decisions.md` is not opened to establish it. **Where it is absent** — every installed target, and this repository before the mechanism existed — behaviour is today's: read the files and the contract as this section already describes (I27). + Sequence: 1. **Branch.** `git status --short` must be clean and on the default branch before you touch anything; uncommitted work that is not this slice's is not yours to stash or discard (`AGENTS.md`, *Safe start*) — stop and say so instead. Create and check out `slice/S` from the default branch's latest. **Refuse to implement on the default branch** — `/pr` refuses to open a PR from it, and the branch is one command away. @@ -49,7 +51,7 @@ Sequence: 4. Implement against the contract signatures exactly. No signature drift, no added parameters, no widened return types. 5. Run the tests. Run the full suite, not just the new tests. 6. **Commit, then push.** Stage by named path — never `git add -A`, `git add .`, or a bare directory (`AGENTS.md`, *Git and delivery*). -7. **Open the pull request. Never as a draft.** Carved out of the authorization rule the same as pushing the branch (`AGENTS.md`, *Git and delivery*). Title it from the slice name; the body can be minimal, since `/pr` writes the real description in this same session and runs the gates and the review threads after it. Check for an existing open PR on this branch first and do not open a second one. +7. **Open the pull request. Never as a draft.** Carved out of the authorization rule the same as pushing the branch (`AGENTS.md`, *Git and delivery*). Title it from the slice name and **write the real description now**, in the shape `.claude/commands/pr.md` § *Phase 1* fixes — not a placeholder telling the reader to run `/pr` for the real one. A PR body that describes nothing is the same split the no-draft rule exists to prevent: "opened" and "actually reviewable" become two states someone has to reconcile by hand, and a reviewer who arrives in between has nothing to read. `Verified` is the one section that legitimately says the gates have not run yet, because they have not; `/pr` replaces it verbatim in this same session and then works the review threads. Check for an existing open PR on this branch first and do not open a second one. 8. **Tick the `Done when` boxes** on the matching issue for every id this run confirms met. Carved out the same way (`AGENTS.md`, *Tracking work*) — the report in step 9 and the tick are the same claim now, not two. 9. Report **by criterion id**: which are met, which are not and why, anything you had to decide that the contract did not determine, and the branch name and PR URL. diff --git a/.claude/commands/slices.md b/.claude/commands/slices.md index e101f70..405eb49 100644 --- a/.claude/commands/slices.md +++ b/.claude/commands/slices.md @@ -2,12 +2,12 @@ description: Break the contract into vertical slices with acceptance criteria --- - + **Per-repo companion:** `.claude/commands/slices-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + ## Stop if `design/` is frozen diff --git a/.claude/commands/track.md b/.claude/commands/track.md index 2ebf283..85e32bb 100644 --- a/.claude/commands/track.md +++ b/.claude/commands/track.md @@ -3,12 +3,12 @@ description: Sync design/ into GitHub issues and milestones. Idempotent - safe t argument-hint: [milestone name] --- - + **Per-repo companion:** `.claude/commands/track-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `tightened-authorization`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + ## Stop if `design/` is frozen @@ -125,6 +125,22 @@ For each bullet under `## Open` in `design/90-decisions.md`: **Creating a milestone is carved out of the authorization rule**, the same as an issue (`AGENTS.md`, *Tracking work*). Create it and attach the issues named, and say what you did. Deleting one is not carved out. +## Refresh the work mirror + +`/track` is the sole writer of a `WorkRef` (`AGENTS.md`, *Tracking work*; I28). Run it after the issue and milestone sync above, in the same invocation: + +```powershell +pwsh ./tools/Update-WorkMirror.ps1 +``` + +It writes `design/state/work/.md` records and nothing else — never an issue, a label, a milestone, or git. Report its outcome the same way you would any other gate: + +- Exit 0 — say how many `WorkRef` records were written. +- Exit 2 — say which issues could not be read and why (`gh` missing or unauthenticated is the ordinary case); no mirror is written on this path, and none of the existing ones are touched. +- While `design/FROZEN.md` exists it does not run at all, which is expected — `/track` does not run during a freeze either, per *Stop if `design/` is frozen* above. + +Where the script is unavailable, say so and name the mirror refresh as a step that **did not run**, the same convention `Test-DesignDrift.ps1`'s unavailability already follows above. + ## Bugs and stories are not synced `/track` only syncs *from* `design/`. A **bug** has no upstream document — the issue is its origin — and a **story** that is not a slice of an existing design has none either. Both are filed by hand from `.github/ISSUE_TEMPLATE/`, which carries the same narrative-then-agent-block shape pre-filled. @@ -152,6 +168,7 @@ GitHub Projects v2 needs the `project` token scope, which `repo` does not includ - Issues closed, with numbers and titles - Slices whose criteria drifted from their issue - Open items removed from `90-decisions.md` +- The work mirror refresh: how many `WorkRef` records were written, or why it did not run - Whether a matching project was found or created, and what was added to it - Anything skipped, and why diff --git a/.claude/commands/unfreeze.md b/.claude/commands/unfreeze.md index 43dcab8..cd1a425 100644 --- a/.claude/commands/unfreeze.md +++ b/.claude/commands/unfreeze.md @@ -2,12 +2,12 @@ description: Lift the design freeze — delete design/FROZEN.md, then run one reconciliation pass --- - + **Per-repo companion:** `.claude/commands/unfreeze-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `document-map`, `extra-steps`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Lift the freeze `/freeze` set. This command runs unattended, without a confirmation prompt — that is a deliberate policy in this repository (`AGENTS.md`, *The design freeze*), not an oversight, so do not add one back. diff --git a/.claude/commands/verify-local.md b/.claude/commands/verify-local.md new file mode 100644 index 0000000..b405403 --- /dev/null +++ b/.claude/commands/verify-local.md @@ -0,0 +1,31 @@ +## gate-commands +| Flagged step | Workflow | Run locally | +|---|---|---| +| `Typecheck` | `ci.yml` | `npm --prefix src/engine run typecheck` | +| `Lint` | `ci.yml` | `npm --prefix src/engine run lint` | +| `Test` | `ci.yml` | `npm --prefix src/engine test` | +| `Pack package` | `ci.yml` | `cd src/engine; npm pack --silent` | +| `Inspect tarball` | `ci.yml` | `tar -tzf` the packed tarball; assert no `src/`, no `tsconfig*.json`, no `.test.*` artifacts, `package/dist/` present | +| `Consumer smoke` | `ci.yml` | Clear `consumer-smoke/{node_modules,package-lock.json,dist}`, then `npm run install:engine && npm run build && npm run smoke` | +| `Parse-check PowerShell scripts` | `verify.yml` | Parse every `*.ps1` with `[System.Management.Automation.Language.Parser]::ParseFile`, as the step does | +| `Run Pester tests` | `verify.yml` | `Invoke-Pester -Path tools -Output Detailed -PassThru` | +| `Validate the core/companion split` | `verify.yml` | `./tools/Test-Companion.ps1` | +| `Check the design state against the tree` | `verify.yml` | `./tools/Test-DesignState.ps1` | +| `Validate Markdown links, terminology, and generated files` | `docs-ci.yml` | `./build/Test-Documentation.ps1` | +| `Build documentation` | `docs-ci.yml` | `./docs.ps1 -BuildOnly` — needs Docker **and** an installed `docs.ps1` | +| `Build and verify landing page` | `docs-ci.yml` | `npm --prefix src/engine run build; npm --prefix site run check` | +| `Merge landing page into documentation build` | `docs-ci.yml` | `npm --prefix site run merge` — needs a completed docs build in `artifacts/docs` | +| `Test the host` | `host-image.yml` | `dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/…csproj` — needs `NUGET_GITHUB_TOKEN` for the sibling-repo feed | +| `Positive route and probe smoke` | `host-image.yml` | Build and run the host image, then `curl` `/`, `/roadmap/`, `/docs/`, `/health/live`, `/health/ready` → 200 and an unknown route → 404 | +| `Negative fixture -- corrupted artifact must fail to start` | `host-image.yml` | `docker build -f tools/host-smoke/Dockerfile.negative-fixture …`; the run must exit non-zero | + +Discover by reading the workflow files rather than trusting a memorized list; the seventeen rows +above describe this repository's gates as of the last sync, not a substitute for reading +`.github/workflows/*.yml` directly. + +Local discovery, beyond the workflow files: +```powershell +if (Test-Path package.json) { Get-Content package.json | Select-String '"scripts"' -Context 0,20 } +Get-ChildItem . -Include *.sln, *.csproj -Recurse -Depth 2 +if (Test-Path build) { Get-ChildItem build -Filter *.ps1 } +``` diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md index 6dd1216..b5c1c12 100644 --- a/.claude/commands/verify.md +++ b/.claude/commands/verify.md @@ -2,12 +2,12 @@ description: Discover this repository's gates, run them, and report honestly what did and did not run --- - + **Per-repo companion:** `.claude/commands/verify-local.md`. Read it now, if it exists — an absent, empty, or frontmatter-only file is no companion, and this file then stands alone. It may override: `vocabulary`, `extra-steps`, `gate-commands`. It may never override anything in [`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined. - + Run the checks this repository actually has, and report the result without softening it. @@ -53,36 +53,34 @@ invocation — that translation is still genuine judgement, the flag only says * is a gate, not *how* to reproduce it outside CI. This repository's current flagged steps and their local equivalents: -| Flagged step | Workflow | Run locally | -|---|---|---| -| `Typecheck` | `ci.yml` | `npm --prefix src/engine run typecheck` | -| `Lint` | `ci.yml` | `npm --prefix src/engine run lint` | -| `Test` | `ci.yml` | `npm --prefix src/engine test` | -| `Pack package` | `ci.yml` | `cd src/engine; npm pack --silent` | -| `Inspect tarball` | `ci.yml` | `tar -tzf` the packed tarball; assert no `src/`, no `tsconfig*.json`, no `.test.*` artifacts, `package/dist/` present | -| `Consumer smoke` | `ci.yml` | Clear `consumer-smoke/{node_modules,package-lock.json,dist}`, then `npm run install:engine && npm run build && npm run smoke` | -| `Parse-check PowerShell scripts` | `verify.yml` | Parse every `*.ps1` with `[System.Management.Automation.Language.Parser]::ParseFile`, as the step does | -| `Run Pester tests` | `verify.yml` | `Invoke-Pester -Path tools -Output Detailed -PassThru` | -| `Validate Markdown links, terminology, and generated files` | `docs-ci.yml` | `./build/Test-Documentation.ps1` | -| `Build documentation` | `docs-ci.yml` | `./docs.ps1 -BuildOnly` — needs Docker **and** an installed `docs.ps1` | -| `Build and verify landing page` | `docs-ci.yml` | `npm --prefix src/engine run build; npm --prefix site run check` | -| `Merge landing page into documentation build` | `docs-ci.yml` | `npm --prefix site run merge` — needs a completed docs build in `artifacts/docs` | -| `Test the host` | `host-image.yml` | `dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/…csproj` — needs `NUGET_GITHUB_TOKEN` for the sibling-repo feed | -| `Positive route and probe smoke` | `host-image.yml` | Build and run the host image, then `curl` `/`, `/roadmap/`, `/docs/`, `/health/live`, `/health/ready` → 200 and an unknown route → 404 | -| `Negative fixture -- corrupted artifact must fail to start` | `host-image.yml` | `docker build -f tools/host-smoke/Dockerfile.negative-fixture …`; the run must exit non-zero | +| Flagged step (`.github/workflows/verify.yml`) | Run locally | +|---|---| +| `Parse-check PowerShell scripts` | Parse every `*.ps1` with `[System.Management.Automation.Language.Parser]::ParseFile`, as the step does | +| `Run Pester tests` | `Invoke-Pester -Path tools -Output Detailed -PassThru` | +| `Validate the core/companion split` | `./tools/Test-Companion.ps1` | +| `Check the design state against the tree` | `./tools/Test-DesignState.ps1` | A repository can gain, lose, or rename flagged steps over time — re-derive this table from -the workflow files rather than trusting a memorized list; the fifteen rows above describe this +the workflow files rather than trusting a memorized list; the four rows above describe this repository's steps as of this writing, not a fixed schema. +**Two of those steps exit 2 as well as 0 and 1, and 2 is not a failure — it is the third +list.** `Test-Companion.ps1` and `Test-DesignState.ps1` both distinguish *ran and found +something* from *could not run at all*, and the exit code is how they say which: 0 is `Passed`, +1 is `Failed`, and **2 is `DidNotRun`, with the script's own could-not-evaluate reasons as the +`reason`**. Folding 2 into `Failed` loses the distinction the three lists exist for; folding it +into `Passed` is the substitution this whole command exists to prevent. Either way CI is red — +the workflow fails the step on 1 and 2 alike — and that is a separate question from which list +the gate belongs in here. + Then look for anything else the repository ships that is not CI-gated. These are optional — worth running and worth naming if run, but their absence from CI means they never enter the "did not run because it did not run" failure mode the flag exists to close: ```powershell -Get-Content package.json | Select-String '"scripts"' -Context 0,20 -Get-ChildItem . -Filter *.sln, *.csproj -Recurse -Depth 2 -Get-ChildItem build -Filter *.ps1 +if (Test-Path package.json) { Get-Content package.json | Select-String '"scripts"' -Context 0,20 } +Get-ChildItem . -Include *.sln, *.csproj -Recurse -Depth 2 +if (Test-Path build) { Get-ChildItem build -Filter *.ps1 } Test-Path docs.ps1 ``` @@ -113,6 +111,10 @@ Did not run: point of discovering gates by flag instead of by looking: a flagged step cannot be silently absent from the report the way a gate nobody thought to search for could be. Cross-check the list you are about to write against the flags you found before finishing. +- **A gate that reported *could not evaluate* is named in the third list, never the first.** + `Test-DesignState.ps1` exiting 2 means the design state was not read — an absent state set, + an unparseable record, a projector that would not run, an unauthenticated `gh`. Its + `reason` is the script's own could-not-evaluate output, quoted, not a paraphrase. - **Quote failures.** Paste the failing output into the artifact's `detail` field. A summary of a failure is a claim about a failure — `Test-VerifyReport.ps1` rejects a `detail` too short to plausibly be pasted output. - **Never write "all checks pass"** unless every discovered gate is in the first list. If anything is in the third list, the honest sentence names it: *"the three that ran passed; the documentation build did not run because Docker is unavailable."* - **A gate that cannot run locally is not a gate you may report on.** Say so, and say that the corresponding CI check on the pull request is where the answer will come from. diff --git a/.claude/kit.json b/.claude/kit.json index b9701a0..4d10077 100644 --- a/.claude/kit.json +++ b/.claude/kit.json @@ -1,6 +1,7 @@ { "source": "https://github.com/The-Running-Dev/SubZeroDev.AgentKit", - "commit": "af610a6a2c1ff65e4fd16e3600e2b6a89fb07c12", - "installed": "2026-08-12", - "syncedCommit": "6bdd8dcc347bb3c09a746bb27a204e7fbb205d49" -} \ No newline at end of file + "branch": "main", + "commit": "e9e21bf15b7e00c93ec5ad6f8fab2748671dea1d", + "installed": "2026-08-21", + "syncedCommit": "e9e21bf15b7e00c93ec5ad6f8fab2748671dea1d" +} diff --git a/design/90-decisions.md b/design/90-decisions.md index 45f3f11..5f6a084 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -940,3 +940,17 @@ is the class of latent content bug W83 existed to remove. Reversibility: cheap — the guard is one condition and the §9.2 sentence is one clause. Widening it back would reject content that is valid under this entry, so it is a one-way door for any campaign authored against it. + +### 2026-08-21 — `/verify`'s repo-specific gate table moves into a companion, not the core +Context: `/kit-sync` fast-forwarded `~/.agent-kit` and found this repository's `.claude/commands/verify.md` +edited with no `.claude/commands/verify-local.md` beside it — an `Unmigrated-Blocked` core, per +`.claude/COMPANIONS.md`. The edit was the repository's own gate table (`ci.yml`, `verify.yml`, +`docs-ci.yml`, `host-image.yml` steps and their local-run commands), which the kit's `verify.md` +core declares as the `gate-commands` category a companion may override. +Chosen: Move the gate table verbatim into `.claude/commands/verify-local.md` under a `## gate-commands` +heading, then let the sync take the core outright (`Superseded`). `Test-Companion.ps1` confirmed +the split (22 cores checked, 0 findings). +Rejected: **Leave the core edited and skip the sync for this file** — keeps the repository unable +to receive any future `verify.md` update without repeating this exact reconciliation. +Reversibility: cheap — the companion is one file under a declared category; deleting it reverts to +the kit's own default gate discovery. diff --git a/tools/Invoke-CodexCommand.ps1 b/tools/Invoke-CodexCommand.ps1 new file mode 100644 index 0000000..03b7d95 --- /dev/null +++ b/tools/Invoke-CodexCommand.ps1 @@ -0,0 +1,137 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Launches `codex` with the `--profile` that matches a command's required tier in + AGENTS.md, so the tier gate in "Model, effort, and review budget" never has to catch + a mismatch caused by launching on whatever profile the shell happened to have open. + +.DESCRIPTION + codex/PROFILES.md defines three profiles - architect (Sol, deep reasoning), builder + (Terra, implementation), quick (Codex Spark, high volume) - but nothing picks one from + a command name. AGENTS.md's *Command routing* table names a tier per command; this + script is that lookup, exec'd as `codex --profile [-c model_reasoning_effort=] @CodexArgs`. + + This is exactly the kind of mechanical, repeated lookup AGENTS.md's own "What should + stop being model work" table calls 🔴 Definitely avoidable - arithmetic over a table, + not judgement. The judgement (which tier a *novel* task needs) still belongs to + whoever is running the session; this script only removes the "which profile flag do I + type for a command I already know the tier of" step. + + -Effort overrides the profile's baked-in reasoning effort via `-c + model_reasoning_effort=`, for the routing table's documented exceptions (a large + /slice at high, an /reconcile mechanical-edit pass at medium instead of the profile's + default). It does not change which profile is selected. + + /redteam's requirement ("strongest model, different vendor from the design author") is + a constraint this script cannot enforce - it maps /redteam to `architect`, the + strongest local Codex profile, but vendor diversity is the caller's call to make before + running it. + +.PARAMETER Command + The command name, with or without a leading slash (e.g. 'kit-help' or '/kit-help'). + +.PARAMETER Effort + Override the profile's model_reasoning_effort for this run only (low, medium, high, + xhigh, max). Passed as `-c model_reasoning_effort=`. + +.PARAMETER List + Print the full command-to-profile table and exit. No command required. + +.PARAMETER WhatIf + Print the resolved codex invocation instead of running it. + +.PARAMETER CodexArgs + Everything after the command name/flags is passed through to `codex` verbatim (e.g. + the prompt text, or `resume `). + +.EXAMPLE + ./tools/Invoke-CodexCommand.ps1 kit-help + Resolves /kit-help to the 'quick' profile and runs `codex --profile quick`. + +.EXAMPLE + ./tools/Invoke-CodexCommand.ps1 slice -Effort high -- "implement S4" + Resolves /slice to 'builder' but overrides effort to high for a large slice. + +.EXAMPLE + ./tools/Invoke-CodexCommand.ps1 -List + Prints the full mapping without launching anything. +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $Command, + + [ValidateSet('low', 'medium', 'high', 'xhigh', 'max')] + [string] $Effort, + + [switch] $List, + + [switch] $WhatIf, + + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $CodexArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Mirrors AGENTS.md's *Command routing* table. Tier -> profile per codex/PROFILES.md: +# deep reasoning -> architect, implementation -> builder, high volume -> quick. +# Where routing names two tiers for one command (a decide phase and a mechanical phase), +# this maps to the tier of the phase that runs first / gates the rest. +$commandProfiles = [ordered]@{ + 'brief-check' = 'architect' + 'design' = 'architect' + 'contract' = 'architect' + 'slices' = 'architect' + 'redteam' = 'architect' # strongest local profile; vendor diversity is on the caller + 'slice' = 'builder' + 'reconcile' = 'architect' # deciding which side is correct gates the mechanical edits + 'make-human-docs' = 'builder' + 'track' = 'builder' + 'verify' = 'builder' + 'pr' = 'builder' + 'resolve' = 'builder' + 'fix' = 'builder' + 'refine' = 'builder' + 'install' = 'builder' + 'install-all' = 'builder' + 'kit-sync' = 'builder' + 'kit-help' = 'quick' + 'done' = 'quick' + 'freeze' = 'builder' + 'unfreeze' = 'builder' # its own reconcile/track sub-phases pick their own profile +} + +if ($List) { + $commandProfiles.GetEnumerator() | ForEach-Object { + [pscustomobject]@{ Command = "/$($_.Key)"; Profile = $_.Value } + } | Format-Table -AutoSize + return +} + +if (-not $Command) { + throw "No command given. Pass a command name (e.g. 'kit-help') or -List to see the table." +} + +$normalized = $Command.TrimStart('/') +if (-not $commandProfiles.Contains($normalized)) { + $known = ($commandProfiles.Keys | ForEach-Object { "/$_" }) -join ', ' + throw "No profile mapping for '/$normalized'. Known commands: $known. Pass --profile to codex directly for anything else." +} + +$codexProfile = $commandProfiles[$normalized] + +$codexInvocationArgs = @('--profile', $codexProfile) +if ($Effort) { + $codexInvocationArgs += @('-c', "model_reasoning_effort=$Effort") +} +$codexInvocationArgs += $CodexArgs + +if ($WhatIf) { + Write-Output "codex $($codexInvocationArgs -join ' ')" + return +} + +& codex @codexInvocationArgs +exit $LASTEXITCODE diff --git a/tools/Invoke-DoneHousekeeping.Tests.ps1 b/tools/Invoke-DoneHousekeeping.Tests.ps1 new file mode 100644 index 0000000..0661ff3 --- /dev/null +++ b/tools/Invoke-DoneHousekeeping.Tests.ps1 @@ -0,0 +1,83 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Invoke-DoneHousekeeping.ps1 has no exit-calling wrapper - it runs to completion and + returns its report object on the pipeline - so these tests invoke it end-to-end via `&` + against real git repos under $TestDrive, including a real second `git worktree`, the same + "not worth mocking" reasoning Sync-Kit.Tests.ps1 gives for its own script. +#> + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Invoke-DoneHousekeeping.ps1' + + function New-GitRepo { + param([Parameter(Mandatory)][string] $Path) + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & git init --quiet -b main $Path | Out-Null + & git -C $Path -c user.email='test@example.com' -c user.name='Test' commit --allow-empty --quiet -m 'initial' | Out-Null + $Path + } + + function New-MergedWorktreeBranch { + # Creates 'feature/foo' off main, merges it back into main with a real merge + # commit (so it shows up in --merged without being a fast-forward no-op), then + # checks it out in a second worktree - reproducing the '+ feature/foo' decoration + # `git branch --merged` only adds to a branch checked out somewhere other than the + # current worktree. + param([Parameter(Mandatory)][string] $RepoPath, [Parameter(Mandatory)][string] $WorktreePath) + & git -C $RepoPath checkout --quiet -b feature/foo | Out-Null + & git -C $RepoPath -c user.email='test@example.com' -c user.name='Test' commit --allow-empty --quiet -m 'feature work' | Out-Null + & git -C $RepoPath checkout --quiet main | Out-Null + & git -C $RepoPath -c user.email='test@example.com' -c user.name='Test' merge --no-ff --quiet feature/foo -m 'merge feature/foo' | Out-Null + & git -C $RepoPath worktree add --quiet $WorktreePath feature/foo *>$null + } +} + +Describe 'Invoke-DoneHousekeeping' { + + Context 'a merged branch checked out in another worktree' { + + It 'parses to its bare name in Candidates, not the "+ " decoration git branch --merged adds' { + $repo = New-GitRepo -Path (Join-Path $TestDrive 'repo-candidates') + $wt = Join-Path $TestDrive 'wt-candidates' + New-MergedWorktreeBranch -RepoPath $repo -WorktreePath $wt + + $result = & $script:ScriptPath -RepoRoot $repo -DefaultBranch main -SkipPull + + $result.Stopped | Should -Be $false + $branchNames = @($result.Candidates | ForEach-Object Branch) + $branchNames | Should -Contain 'feature/foo' + $branchNames | Should -Not -Contain '+ feature/foo' + } + + It 'is refused on delete with a reason naming the blocking worktree path, distinct from a not-merged refusal' { + $repo = New-GitRepo -Path (Join-Path $TestDrive 'repo-delete') + $wt = Join-Path $TestDrive 'wt-delete' + New-MergedWorktreeBranch -RepoPath $repo -WorktreePath $wt + + $result = & $script:ScriptPath -RepoRoot $repo -DefaultBranch main -SkipPull -DeleteBranches 'feature/foo' + + $result.Deleted | Should -Not -Contain 'feature/foo' + $refusal = $result.Refused | Where-Object Branch -eq 'feature/foo' + $refusal | Should -Not -BeNullOrEmpty + # git's own "used by worktree at ''" output always uses forward slashes, + # even on Windows where $wt (built from $TestDrive) uses backslashes - normalise + # both sides before comparing rather than asserting on separator-sensitive text. + $refusal.Reason.Replace('\', '/') | Should -Match ([regex]::Escape($wt.Replace('\', '/'))) + $refusal.Reason | Should -Not -Match "Not in --merged" + } + + It 'is deleted once the blocking worktree is removed' { + $repo = New-GitRepo -Path (Join-Path $TestDrive 'repo-clean') + $wt = Join-Path $TestDrive 'wt-clean' + New-MergedWorktreeBranch -RepoPath $repo -WorktreePath $wt + & git -C $repo worktree remove --force $wt | Out-Null + + $result = & $script:ScriptPath -RepoRoot $repo -DefaultBranch main -SkipPull -DeleteBranches 'feature/foo' + + $result.Deleted | Should -Contain 'feature/foo' + @($result.Refused | Where-Object Branch -eq 'feature/foo') | Should -BeNullOrEmpty + } + } +} diff --git a/tools/Invoke-DoneHousekeeping.ps1 b/tools/Invoke-DoneHousekeeping.ps1 index 4cca4d8..c228980 100644 --- a/tools/Invoke-DoneHousekeeping.ps1 +++ b/tools/Invoke-DoneHousekeeping.ps1 @@ -72,6 +72,18 @@ function Invoke-Git { return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($out -join "`n") } } +function Get-WorktreeBlockingPath { + # `git branch -d` refuses a branch checked out in another worktree with + # "cannot delete branch '' used by worktree at ''" - distinct from + # refusing an unmerged branch, and the remedy (`git worktree remove `) is + # different too, so callers need the path, not just a generic failure. + param([string]$GitOutput) + if ($GitOutput -match "used by worktree at '([^']+)'") { + return $Matches[1] + } + return $null +} + $stashed = $false $stashRef = $null @@ -157,9 +169,12 @@ if (-not $SkipPull) { $pruneResult = Invoke-Git -GitArgs @('fetch', '--prune', 'origin') -WorkingDir $repoRootResolved $prunedLines = @(($pruneResult.Output -split "`n") | Where-Object { $_ -match '\[deleted\]' }) -$mergedResult = Invoke-Git -GitArgs @('branch', '--merged', $DefaultBranch) -WorkingDir $repoRootResolved +# `git branch --merged` prefixes the current branch with "* " and any branch checked +# out in another worktree with "+ " - the latter survived a TrimStart('*', ' ') that +# only stripped the former. for-each-ref has no decoration to strip in the first place. +$mergedResult = Invoke-Git -GitArgs @('for-each-ref', '--format=%(refname:short)', "--merged=$DefaultBranch", 'refs/heads') -WorkingDir $repoRootResolved $mergedBranches = @(($mergedResult.Output -split "`n") | - ForEach-Object { $_.TrimStart('*', ' ') } | + ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -ne $DefaultBranch }) $candidates = [System.Collections.Generic.List[object]]::new() @@ -184,7 +199,15 @@ foreach ($branch in $DeleteBranches) { if ($deleteResult.ExitCode -eq 0) { $deleted.Add($branch) } else { - $refused.Add([pscustomobject]@{ Branch = $branch; Reason = $deleteResult.Output }) + $blockingPath = Get-WorktreeBlockingPath -GitOutput $deleteResult.Output + if ($blockingPath) { + $refused.Add([pscustomobject]@{ + Branch = $branch + Reason = "Checked out in another worktree at '$blockingPath' - run 'git worktree remove $blockingPath' first, not deleted." + }) + } else { + $refused.Add([pscustomobject]@{ Branch = $branch; Reason = $deleteResult.Output }) + } } } diff --git a/tools/Read-DesignState.Tests.ps1 b/tools/Read-DesignState.Tests.ps1 new file mode 100644 index 0000000..18ea99d --- /dev/null +++ b/tools/Read-DesignState.Tests.ps1 @@ -0,0 +1,255 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Read-DesignState.ps1 never exits the process - it has no exit-code contract, unlike + Wait-PullRequestCheck.ps1 or Test-DesignDrift.ps1 - so these tests dot-source it purely to + reuse its functions and skip its own invocation block, the same guard shape those two scripts + already use. + + Every fixture below is written into $TestDrive as design/state/... under a throwaway root; + none of these tests (other than the S4.6 closure checks, which are explicit about reading the + real one) read this repository's own design/state/. +#> + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Read-DesignState.ps1' + $script:PreDotSourceErrorActionPreference = $ErrorActionPreference + . $script:ScriptPath -Path $TestDrive + + function New-StateFile { + param([Parameter(Mandatory)][string] $RelativePath, [Parameter(Mandatory)][string] $Content) + $full = Join-Path $TestDrive (Join-Path 'design/state' $RelativePath) + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Content -Encoding utf8NoBOM + $full + } +} + +AfterAll { + $ErrorActionPreference = $script:PreDotSourceErrorActionPreference + Set-StrictMode -Off + Get-ChildItem (Join-Path $TestDrive 'design') -ErrorAction SilentlyContinue -Recurse -File | + Remove-Item -Force -ErrorAction SilentlyContinue +} + +Describe 'Read-DesignState' { + + BeforeEach { + Get-ChildItem (Join-Path $TestDrive 'design') -ErrorAction SilentlyContinue -Recurse -File | + Remove-Item -Force -ErrorAction SilentlyContinue + } + + It 'S4.4: an absent design/state/ yields empty Root, zero Records, zero Failures' { + $emptyRoot = Join-Path $TestDrive 'no-state-here' + New-Item -ItemType Directory -Path $emptyRoot -Force | Out-Null + + $graph = Read-DesignStateGraph -Path $emptyRoot + + $graph.Root | Should -BeExactly '' + $graph.Records.Count | Should -Be 0 + $graph.Failures.Count | Should -Be 0 + } + + It 'S4.2/S4.3: a well-formed record parses into exactly one record with zero failures' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +Status: active +Anchor: .claude/commands/track.md +Consumes: +Exposes: +Binds: I28 +Live: decision/2026-08-03-track-adds-to-existing-project +Archival: +Questions: +Work: +Evidence: + +## Owns +Syncs design/ into GitHub issues. +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Records.Count | Should -Be 1 + $graph.Failures.Count | Should -Be 0 + $graph.Records[0].Id | Should -Be 'unit/command/track' + $graph.Records[0].Scalars['Status'] | Should -Be 'active' + $graph.Records[0].Lists['Binds'] | Should -Be @('I28') + $graph.Records[0].Prose['Owns'] | Should -Be 'Syncs design/ into GitHub issues.' + } + + It 'S4.2: a record in which every line is malformed yields one Failures entry per unrecognised line and does not throw' { + New-StateFile -RelativePath 'units/command/broken.md' -Content @' +this is not an id line +neither: is: this + also not valid ## +'@ + + { $script:Result = Read-DesignStateGraph -Path $TestDrive } | Should -Not -Throw + + $script:Result.Records.Count | Should -Be 0 + $script:Result.Failures.Count | Should -Be 3 + $script:Result.Failures | ForEach-Object { $_.Reason | Should -Be 'Unparseable' } + } + + It 'S4.3: an unmatched line is reported with file, line number, and byte-for-byte text' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +this line matches no production at all +Status: active +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Failures.Count | Should -Be 1 + $f = $graph.Failures[0] + $f.Path | Should -Be 'design/state/units/command/track.md' + $f.Line | Should -Be 3 + $f.Text | Should -Be 'this line matches no production at all' + } + + It 'S4.7: an Id line disagreeing with the path-implied id still parses, and the record path recovers the path id' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/wrong-slug +Kind: command +Status: active +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Records.Count | Should -Be 1 + $graph.Failures.Count | Should -Be 0 + $graph.Records[0].Id | Should -Be 'unit/command/wrong-slug' + $graph.Records[0].Path | Should -Be 'design/state/units/command/track.md' + $info = Get-DesignPathInfo -RelativeToState 'units/command/track.md' + $info.PathId | Should -Be 'unit/command/track' + } + + It 'S4.8: a field line after the first ## is reported as unparseable, not accepted as a late field' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command + +## Owns +Some prose. +Status: active +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Failures.Count | Should -Be 1 + $graph.Failures[0].Reason | Should -Be 'LateField' + $graph.Failures[0].Text | Should -Be 'Status: active' + $graph.Records[0].Scalars.ContainsKey('Status') | Should -BeFalse + } + + It 'S4.8: a field name appearing twice in one record is reported' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +Status: active +Status: retired +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Failures.Count | Should -Be 1 + $graph.Failures[0].Reason | Should -Be 'DuplicateField' + $graph.Records[0].Scalars['Status'] | Should -Be 'active' + } + + It 'S4.9: a list field with nothing after the colon parses as an empty list, distinct from an omitted one' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +Consumes: +Binds: I28 +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + $record = $graph.Records[0] + + $record.Lists.ContainsKey('Consumes') | Should -BeTrue + $record.Lists['Consumes'] | Should -BeNullOrEmpty + $record.Lists.ContainsKey('Exposes') | Should -BeFalse + } + + It 'S4.10: Consumers, BoundBy and Affects fields are unparseable - the grammar has no production for a derived edge' { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +Consumers: unit/command/slice +'@ + New-StateFile -RelativePath 'invariants/I17.md' -Content @' +# I17 +Kind: invariant +BoundBy: unit/command/track +'@ + New-StateFile -RelativePath 'decisions/2026-08-03-x.md' -Content @' +# decision/2026-08-03-x +Affects: unit/command/track +'@ + + $graph = Read-DesignStateGraph -Path $TestDrive + + $graph.Failures.Count | Should -Be 3 + $graph.Failures | ForEach-Object { $_.Reason | Should -Be 'Unparseable' } + foreach ($record in $graph.Records) { + $record.Scalars.ContainsKey('Consumers') | Should -BeFalse + $record.Lists.ContainsKey('Consumers') | Should -BeFalse + $record.Scalars.ContainsKey('BoundBy') | Should -BeFalse + $record.Lists.ContainsKey('BoundBy') | Should -BeFalse + $record.Scalars.ContainsKey('Affects') | Should -BeFalse + $record.Lists.ContainsKey('Affects') | Should -BeFalse + } + } + + It 'writes nothing (I18): git status is empty after a run against a state set, including an all-failed one' { + # This checks the real repository's tree, so it exercises the reader against the real + # design/state/ this slice adds - the point is that reading it, however it parses, + # leaves the tree exactly as it was. + $repoRoot = Split-Path $PSScriptRoot -Parent + $before = & git -C $repoRoot status --short + Read-DesignStateGraph -Path $repoRoot | Out-Null + $after = & git -C $repoRoot status --short + $after | Should -Be $before + } +} + +Describe 'Read-DesignState against this repository''s own state set' { + + BeforeAll { + $script:RepoRoot = Split-Path $PSScriptRoot -Parent + $script:StatusBefore = & git -C $script:RepoRoot status --short + $script:RepoGraph = Read-DesignStateGraph -Path $script:RepoRoot + } + + It 'S4.5: git status is unchanged by the run - including this run, whose state set may carry parse failures' { + $after = & git -C $script:RepoRoot status --short + $after | Should -Be $script:StatusBefore + } + + It 'S4.6: unit/command/track and unit/document/agents-md exist and their closures are complete' { + $byId = @{} + foreach ($r in $script:RepoGraph.Records) { $byId[$r.Id] = $r } + + $byId.ContainsKey('unit/command/track') | Should -BeTrue + $byId.ContainsKey('unit/document/agents-md') | Should -BeTrue + + foreach ($unitId in @('unit/command/track', 'unit/document/agents-md')) { + $record = $byId[$unitId] + $named = [System.Collections.Generic.List[string]]::new() + foreach ($field in 'Consumes', 'Exposes', 'Binds', 'Live', 'Questions', 'Work') { + if ($record.Lists.ContainsKey($field)) { + foreach ($id in @($record.Lists[$field])) { $named.Add([string]$id) } + } + } + foreach ($id in $named) { + $byId.ContainsKey($id) | Should -BeTrue -Because "$unitId names $id directly, so its closure needs a record for it" + } + } + } +} diff --git a/tools/Read-DesignState.ps1 b/tools/Read-DesignState.ps1 new file mode 100644 index 0000000..cdf47ec --- /dev/null +++ b/tools/Read-DesignState.ps1 @@ -0,0 +1,368 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Reads design/state/ into a graph. Never throws, never writes, never skips a line. + +.DESCRIPTION + The state set (design/20-contract.md § Persisted schemas) is constrained Markdown: one + record per file, an H1 naming the id, scalar and list fields as colon lines, prose fields + as `## Field` sections. This is the one place that grammar is read. A line matching no + production is a parse failure (I24) - reported with its file, line number, and verbatim + text - never dropped and never a terminating error, because a caller that got an exception + would lose every record that did parse, which is the part a report is made of. + + A record's kind - Unit, Invariant, Contract, Decision, Question, WorkRef - is read from + which directory under design/state/ the file lives in, per the id-to-path table in + design/20-contract.md. Each kind has its own closed field vocabulary; a name outside it - + including the derived-edge names Consumers, BoundBy and Affects (I17) - matches no + production and is reported the same as any other unrecognised line. + + A record's own H1 id is taken literally, even when it disagrees with the id the file's path + implies (S4.7) - the graph carries both, because the path is already in memory and the + id-to-path mapping recovers the path-implied id from it without a second read. Which of the + two is wrong is the graph validator's call, not the reader's. + + Reads only. Writes nothing, ever (I18). An absent design/state/ is a graph with an empty + Root and zero records, not an error - deciding what absence means belongs to the checker, + not the reader. + +.PARAMETER Path + Repository root. design/state/ is resolved beneath it. Defaults to the current directory. + +.EXAMPLE + ./tools/Read-DesignState.ps1 + +.EXAMPLE + $graph = . ./tools/Read-DesignState.ps1 -Path 'unused'; Read-DesignStateGraph -Path $repo +#> +[CmdletBinding()] +param( + [string] $Path = (Get-Location).Path +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function New-DesignRecord { + param( + [Parameter(Mandatory)][string] $Id, + [Parameter(Mandatory)][string] $Kind, + [Parameter(Mandatory)][string] $Path, + [Parameter(Mandatory)][hashtable] $Scalars, + [Parameter(Mandatory)][hashtable] $Lists, + [Parameter(Mandatory)][hashtable] $Prose + ) + [pscustomobject]@{ + Id = $Id + Kind = $Kind + Path = $Path + Scalars = $Scalars + Lists = $Lists + Prose = $Prose + } +} + +function New-DesignStateGraph { + param( + [Parameter(Mandatory)][AllowEmptyString()][string] $Root, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Failures + ) + [pscustomobject]@{ + Root = $Root + Records = @($Records) + Failures = @($Failures) + } +} + +function New-DesignStateFailure { + param( + [Parameter(Mandatory)][string] $Reason, + [Parameter(Mandatory)][string] $Path, + [int] $Line, + [string] $Text + ) + [pscustomobject]@{ + Reason = $Reason + Path = $Path + Line = $Line + Text = $Text + } +} + +<# + One closed vocabulary per top-level kind, built from design/10-design.md § Data model. An + Invariant record specialises the Unit fields on the same record (design/20-contract.md, + "A unit of kind invariant is one record, not two"), so its table is the Unit table plus + Owner, Enforcement and Statement rather than a fresh one. + + Consumers, BoundBy and Affects are deliberately absent from every table - they are derived + reverse edges and design/10-design.md is explicit that writing one is forbidden (I17). Their + absence here, not a denylist checked separately, is what makes them fail the same way any + other unrecognised field would. +#> +$script:FieldTables = @{ + Unit = @{ + Scalar = @('Kind', 'Status', 'Anchor') + List = @('Consumes', 'Exposes', 'Binds', 'Live', 'Archival', 'Questions', 'Work', 'Evidence') + Prose = @('Owns') + } + Invariant = @{ + Scalar = @('Kind', 'Status', 'Anchor', 'Owner', 'Enforcement') + List = @('Consumes', 'Exposes', 'Binds', 'Live', 'Archival', 'Questions', 'Work', 'Evidence') + Prose = @('Statement') + } + Contract = @{ + Scalar = @('Status', 'Owner', 'Declaration') + List = @() + Prose = @('Semantics') + } + Decision = @{ + Scalar = @('Date', 'Anchor', 'Status', 'SupersededBy') + List = @() + Prose = @('Claim') + } + Question = @{ + Scalar = @('Status', 'AnsweredBy') + List = @() + Prose = @('Text') + } + WorkRef = @{ + Scalar = @('Issue', 'Title', 'State', 'Rank', 'MirroredAt') + List = @('Criteria') + Prose = @() + } +} + +<# + Maps a file's path relative to design/state/ to its top-level kind and the id its path + implies, per the table in design/20-contract.md § Persisted schemas. Returns $null for a + location the table does not name - the caller reports that as a parse failure rather than + guessing a kind for it. +#> +function Get-DesignPathInfo { + param([Parameter(Mandatory)][string] $RelativeToState) + + $parts = @($RelativeToState -split '[\\/]') + switch ($parts[0]) { + 'units' { + if ($parts.Count -ne 3) { return $null } + [pscustomobject]@{ Kind = 'Unit'; PathId = "unit/$($parts[1])/$([IO.Path]::GetFileNameWithoutExtension($parts[2]))" } + } + 'invariants' { + if ($parts.Count -ne 2) { return $null } + [pscustomobject]@{ Kind = 'Invariant'; PathId = [IO.Path]::GetFileNameWithoutExtension($parts[1]) } + } + 'contracts' { + if ($parts.Count -ne 2) { return $null } + [pscustomobject]@{ Kind = 'Contract'; PathId = "contract/$([IO.Path]::GetFileNameWithoutExtension($parts[1]))" } + } + 'decisions' { + if ($parts.Count -ne 2) { return $null } + [pscustomobject]@{ Kind = 'Decision'; PathId = "decision/$([IO.Path]::GetFileNameWithoutExtension($parts[1]))" } + } + 'questions' { + if ($parts.Count -ne 2) { return $null } + [pscustomobject]@{ Kind = 'Question'; PathId = "question/$([IO.Path]::GetFileNameWithoutExtension($parts[1]))" } + } + 'work' { + if ($parts.Count -ne 2) { return $null } + [pscustomobject]@{ Kind = 'WorkRef'; PathId = "work/$([IO.Path]::GetFileNameWithoutExtension($parts[1]))" } + } + default { $null } + } +} + +<# + Parses one record file. Returns @{ Record = ; Failures = }. + + Two passes over "no valid production": if the first non-blank line is not a well-formed H1, + nothing in the file can be attributed to a record - there is no id to build one under - so + every non-blank line is reported and no Record is returned (S4.2's "every line malformed" + shape). Once the H1 parses, every remaining line is matched against the field grammar for + this file's kind and reported individually on failure; the record built from what did parse + is still returned, because a malformed line elsewhere must not cost the ones that were fine. +#> +function Read-DesignRecordFile { + param( + [Parameter(Mandatory)][string] $FullPath, + [Parameter(Mandatory)][string] $RelativePath, + [Parameter(Mandatory)][string] $Kind + ) + + $failures = [System.Collections.Generic.List[object]]::new() + $lines = @(Get-Content -LiteralPath $FullPath) + $table = $script:FieldTables[$Kind] + + function Test-KnownField { + param([string] $Name) + $table.Scalar -contains $Name -or $table.List -contains $Name -or $table.Prose -contains $Name + } + + # Find the first non-blank line and test it as the H1 production. + $firstIndex = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if (-not [string]::IsNullOrWhiteSpace($lines[$i])) { $firstIndex = $i; break } + } + + if ($firstIndex -lt 0) { + # An empty (or all-blank) file names no id and carries no content to report as a failure. + return @{ Record = $null; Failures = @() } + } + + $id = $null + if ($lines[$firstIndex] -match '^#\s+(\S+)\s*$') { + $id = $Matches[1] + } + + if (-not $id) { + for ($i = 0; $i -lt $lines.Count; $i++) { + if ([string]::IsNullOrWhiteSpace($lines[$i])) { continue } + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line ($i + 1) -Text $lines[$i])) + } + return @{ Record = $null; Failures = @($failures) } + } + + $scalars = @{} + $lists = @{} + $prose = @{} + $seen = [System.Collections.Generic.HashSet[string]]::new() + $pastFirstHash = $false + $currentProseField = $null + $proseBody = [System.Collections.Generic.List[string]]::new() + + function Close-ProseSection { + if ($currentProseField) { + $prose[$currentProseField] = ($proseBody -join "`n").TrimEnd() + } + } + + for ($i = $firstIndex + 1; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + $lineNumber = $i + 1 + + if (-not $pastFirstHash) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + + if ($line -match '^##\s+(\S+)\s*$') { + $name = $Matches[1] + $pastFirstHash = $true + if (-not ($table.Prose -contains $name)) { + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line $lineNumber -Text $line)) + $currentProseField = $null + continue + } + if ($seen.Contains($name)) { + $failures.Add((New-DesignStateFailure -Reason 'DuplicateField' -Path $RelativePath -Line $lineNumber -Text $line)) + $currentProseField = $null + continue + } + [void]$seen.Add($name) + $currentProseField = $name + $proseBody = [System.Collections.Generic.List[string]]::new() + continue + } + + if ($line -match '^([A-Za-z]+):(.*)$') { + $name = $Matches[1] + $value = $Matches[2].TrimStart() + + if (-not (Test-KnownField -Name $name)) { + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line $lineNumber -Text $line)) + continue + } + if ($seen.Contains($name)) { + $failures.Add((New-DesignStateFailure -Reason 'DuplicateField' -Path $RelativePath -Line $lineNumber -Text $line)) + continue + } + if ($table.Prose -contains $name) { + # A prose field written as a colon line is not the `## Field` production. + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line $lineNumber -Text $line)) + continue + } + + [void]$seen.Add($name) + if ($table.List -contains $name) { + $lists[$name] = if ([string]::IsNullOrWhiteSpace($value)) { @() } else { @($value -split ',' | ForEach-Object { $_.Trim() }) } + } + else { + $scalars[$name] = $value + } + continue + } + + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line $lineNumber -Text $line)) + continue + } + + # Past the first `##`: only a new `## Field` header re-enters field territory. Anything + # else is prose body, except a line that reuses a known field name in colon form - that + # is a field line arriving too late (S4.8), not a coincidence of free Markdown. + if ($line -match '^##\s+(\S+)\s*$') { + Close-ProseSection + $name = $Matches[1] + if (-not ($table.Prose -contains $name)) { + $failures.Add((New-DesignStateFailure -Reason 'Unparseable' -Path $RelativePath -Line $lineNumber -Text $line)) + $currentProseField = $null + continue + } + if ($seen.Contains($name)) { + $failures.Add((New-DesignStateFailure -Reason 'DuplicateField' -Path $RelativePath -Line $lineNumber -Text $line)) + $currentProseField = $null + continue + } + [void]$seen.Add($name) + $currentProseField = $name + $proseBody = [System.Collections.Generic.List[string]]::new() + continue + } + + if ($line -match '^([A-Za-z]+):(.*)$' -and (Test-KnownField -Name $Matches[1])) { + $failures.Add((New-DesignStateFailure -Reason 'LateField' -Path $RelativePath -Line $lineNumber -Text $line)) + continue + } + + if ($currentProseField) { $proseBody.Add($line) } + } + Close-ProseSection + + $record = New-DesignRecord -Id $id -Kind $Kind -Path $RelativePath -Scalars $scalars -Lists $lists -Prose $prose + @{ Record = $record; Failures = @($failures) } +} + +function Read-DesignStateGraph { + param([Parameter(Mandatory)][string] $Path) + + $stateDir = Join-Path $Path 'design/state' + if (-not (Test-Path -LiteralPath $stateDir -PathType Container)) { + return New-DesignStateGraph -Root '' -Records @() -Failures @() + } + + $records = [System.Collections.Generic.List[object]]::new() + $failures = [System.Collections.Generic.List[object]]::new() + + $files = @(Get-ChildItem -LiteralPath $stateDir -Recurse -File -Filter '*.md' | Sort-Object FullName) + foreach ($file in $files) { + $relFromState = $file.FullName.Substring($stateDir.Length + 1) -replace '\\', '/' + $relPath = "design/state/$relFromState" + + $info = Get-DesignPathInfo -RelativeToState $relFromState + if (-not $info) { + $failures.Add((New-DesignStateFailure -Reason 'UnrecognisedLocation' -Path $relPath)) + continue + } + + $parsed = Read-DesignRecordFile -FullPath $file.FullName -RelativePath $relPath -Kind $info.Kind + foreach ($f in $parsed.Failures) { $failures.Add($f) } + if ($parsed.Record) { $records.Add($parsed.Record) } + } + + New-DesignStateGraph -Root $stateDir -Records @($records) -Failures @($failures) +} + +# Guards the invocation so this script's tests can dot-source it instead - that defines every +# function above in the caller's scope and skips straight past this block, the same shape +# Test-DesignDrift.ps1 and Wait-PullRequestCheck.ps1 already use. +if ($MyInvocation.InvocationName -ne '.') { + Read-DesignStateGraph -Path $Path +} diff --git a/tools/Sync-Kit.Tests.ps1 b/tools/Sync-Kit.Tests.ps1 index 8d97e8d..e175a12 100644 --- a/tools/Sync-Kit.Tests.ps1 +++ b/tools/Sync-Kit.Tests.ps1 @@ -207,6 +207,100 @@ Describe 'Sync-Kit' { } } + Context 'target content matching the pre-#20 encoding-corruption pattern (#42)' { + + BeforeAll { + function New-CorruptedPair { + <# + Simulates what a24541e's missing StandardOutputEncoding baked into a target's + on-disk bytes before that fix: the recorded blob's UTF-8 bytes, mis-decoded + under code page 437 (the host the bug report came from), written straight to + the target file - not a real local edit, but byte-for-byte different from the + recorded blob all the same. + #> + param([Parameter(Mandatory)][string] $Name) + + $kit = New-GitRepo -Path (Join-Path $TestDrive "kit-$Name") + $target = New-GitRepo -Path (Join-Path $TestDrive "target-$Name") + $correctText = "line one em dash `u{2014} end`n" + $baseSha = Add-GitCommit -Path $kit -RelPath 'tools/Foo.ps1' -Content $correctText -Message 'base' + Write-KitJson -TargetRepo $target -RecordedSha $baseSha + + $cp437 = [System.Text.Encoding]::GetEncoding(437) + $corruptedText = $cp437.GetString([System.Text.Encoding]::UTF8.GetBytes($correctText)) + $dir = Join-Path $target 'tools' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $targetPath = Join-Path $dir 'Foo.ps1' + [System.IO.File]::WriteAllText($targetPath, $corruptedText, [System.Text.UTF8Encoding]::new($false)) + + $headText = "$correctText more`n" + Add-GitCommit -Path $kit -RelPath 'tools/Foo.ps1' -Content $headText -Message 'head' | Out-Null + + [pscustomobject]@{ + Kit = $kit; Target = $target; BaseSha = $baseSha + TargetPath = $targetPath; CorrectText = $correctText; CorruptedText = $corruptedText; HeadText = $headText + } + } + } + + It 'is still Divergent-Skipped by default, but the Detail names the corruption pattern' { + $f = New-CorruptedPair -Name 'corrupt-detect' + + $report = & $script:ScriptPath -TargetRepo $f.Target -KitRoot $f.Kit -RecordedSha $f.BaseSha -DryRun + $row = $report | Where-Object Path -eq 'tools/Foo.ps1' + + $row.Status | Should -Be 'Divergent-Skipped' + $row.Detail | Should -Match 'pre-#20' + $row.Detail | Should -Match 'RepairCorruption' + } + + It 'a genuine ASCII-only local edit is never flagged as this corruption pattern' { + $kit = New-GitRepo -Path (Join-Path $TestDrive 'kit-ascii-edit') + $target = New-GitRepo -Path (Join-Path $TestDrive 'target-ascii-edit') + $baseSha = Add-GitCommit -Path $kit -RelPath 'tools/Foo.ps1' -Content "base`n" -Message 'base' + Write-KitJson -TargetRepo $target -RecordedSha $baseSha + $dir = Join-Path $target 'tools' + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $dir 'Foo.ps1'), "base, edited`n", [System.Text.UTF8Encoding]::new($false)) + Add-GitCommit -Path $kit -RelPath 'tools/Foo.ps1' -Content "base`nmore`n" -Message 'head' | Out-Null + + $report = & $script:ScriptPath -TargetRepo $target -KitRoot $kit -RecordedSha $baseSha -DryRun -RepairCorruption + $row = $report | Where-Object Path -eq 'tools/Foo.ps1' + + $row.Status | Should -Be 'Divergent-Skipped' + $row.Detail | Should -Not -Match 'pre-#20' + [System.IO.File]::ReadAllText((Join-Path $dir 'Foo.ps1')) | Should -Be "base, edited`n" + } + + It '-RepairCorruption reports WouldRepairedCorruption and writes nothing in a dry run' { + $f = New-CorruptedPair -Name 'corrupt-dryrun' + + $report = & $script:ScriptPath -TargetRepo $f.Target -KitRoot $f.Kit -RecordedSha $f.BaseSha -DryRun -RepairCorruption + $row = $report | Where-Object Path -eq 'tools/Foo.ps1' + + $row.Status | Should -Be 'WouldRepairedCorruption' + [System.IO.File]::ReadAllText($f.TargetPath) | Should -Be $f.CorruptedText + } + + It '-RepairCorruption without -DryRun overwrites the corrupted file with the kit''s head content' { + $f = New-CorruptedPair -Name 'corrupt-apply' + + $report = & $script:ScriptPath -TargetRepo $f.Target -KitRoot $f.Kit -RecordedSha $f.BaseSha -RepairCorruption + $row = $report | Where-Object Path -eq 'tools/Foo.ps1' + + $row.Status | Should -Be 'RepairedCorruption' + [System.IO.File]::ReadAllText($f.TargetPath) | Should -Be $f.HeadText + } + + It 'without -RepairCorruption, the corrupted file is left untouched even on a real (non-dry-run) sync' { + $f = New-CorruptedPair -Name 'corrupt-untouched' + + & $script:ScriptPath -TargetRepo $f.Target -KitRoot $f.Kit -RecordedSha $f.BaseSha | Out-Null + + [System.IO.File]::ReadAllText($f.TargetPath) | Should -Be $f.CorruptedText + } + } + Context 'Invoke-GitRaw decodes git output as UTF-8 regardless of the console default' { BeforeAll { diff --git a/tools/Sync-Kit.ps1 b/tools/Sync-Kit.ps1 index 0aca4a3..7e2a488 100644 --- a/tools/Sync-Kit.ps1 +++ b/tools/Sync-Kit.ps1 @@ -28,6 +28,14 @@ Collision). This is real modification and it is /install's job to reconcile it by hand, not this script's. + EXCEPTION: a target synced before #20's fix (a24541e) can + have this row's content mis-decoded, byte for byte, by the + old bug rather than genuinely edited (an em dash becoming + "ΓÇö", for example) - see Test-EncodingCorruptionOf below. + That is not real modification, so it is called out in the + row's Detail either way, and -RepairCorruption takes the + kit's content for it instead of leaving it for /install. + This is the mechanism that entry recorded as "not yet built." Scope is exactly the two directories INSTALL.md calls kit-owned (`.claude/commands/*.md`, `tools/*.ps1`) - every other artifact (AGENTS.md, agent.md, design/, .claude/settings.json, @@ -85,6 +93,15 @@ sign-off; this is what a command following that procedure calls during classification, then calls again without -DryRun in phase 4 once approved. +.PARAMETER RepairCorruption + Also take the kit's content for a divergence that exactly matches the encoding + corruption #20's fix (a24541e) left behind in targets synced before it: a file whose + on-disk content differs from the recorded blob by nothing more than what git's UTF-8 + output decoded to under some legacy single-byte code page (an em dash becoming + "ΓÇö", for example). Without this switch such a file is still reported and left alone + - the detection only narrows what Divergent-Skipped/Unmigrated-Blocked's Detail says, + it does not change the default of leaving real-looking local content untouched. + .EXAMPLE ./tools/Sync-Kit.ps1 Sync the current repository's kit-owned files against its recorded install sha. @@ -100,7 +117,8 @@ param( [string] $RecordedSha, [switch] $Force, [switch] $IncludeUnchanged, - [switch] $DryRun + [switch] $DryRun, + [switch] $RepairCorruption ) Set-StrictMode -Version Latest @@ -155,6 +173,33 @@ function Invoke-GitRaw { return $stdout } +# Windows OEM code pages a target's console could plausibly have had as its default before +# #20's fix (a24541e) - the set Invoke-GitRaw's missing StandardOutputEncoding exposed the +# process to. Not exhaustive of every code page .NET can name, just the ones a real host is +# likely to have had as its OEM default. +$script:LegacyOemCodePages = @(437, 850, 852, 855, 857, 858, 860, 861, 862, 863, 865, 866, 869, 874, 720) + +function Test-EncodingCorruptionOf { + <# + $true when $Candidate is exactly what you get from mis-decoding $Correct's UTF-8 bytes + under some single-byte legacy code page - the pattern a24541e's missing + StandardOutputEncoding baked into target files on disk before that fix landed (an em + dash becoming "ΓÇö" under code page 437, for example). Identical strings never match + (nothing to explain), and content that is pure ASCII can never trigger a false positive + here either - every byte below 0x80 round-trips unchanged through every code page in the + list, so a real edit confined to ASCII cannot masquerade as this corruption. + #> + param([string] $Correct, [string] $Candidate) + if ($Correct -ceq $Candidate) { return $false } + $utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($Correct) + foreach ($codePage in $script:LegacyOemCodePages) { + $legacyEncoding = $null + try { $legacyEncoding = [System.Text.Encoding]::GetEncoding($codePage) } catch { continue } + if ($legacyEncoding.GetString($utf8Bytes) -ceq $Candidate) { return $true } + } + return $false +} + function Get-GitOutput { param([string[]]$GitArgs, [string]$WorkingDir) $raw = Invoke-GitRaw -GitArgs $GitArgs -WorkingDir $WorkingDir @@ -190,7 +235,7 @@ if (-not (Test-Path -LiteralPath $companionScript)) { throw "'$companionScript' is missing. It owns the companion-absence rule this script needs; both ship together as kit-owned files in tools/." } $ownParams = @{} -foreach ($name in @('TargetRepo', 'KitRoot', 'RecordedSha', 'Force', 'IncludeUnchanged', 'DryRun')) { +foreach ($name in @('TargetRepo', 'KitRoot', 'RecordedSha', 'Force', 'IncludeUnchanged', 'DryRun', 'RepairCorruption')) { $ownParams[$name] = (Get-Variable -Name $name -ValueOnly) } . $companionScript @@ -309,7 +354,16 @@ foreach ($relPath in ($scopePaths | Sort-Object)) { $companionPresent = $isCore -and -not (Test-CompanionAbsent -Path (Join-Path $targetRepoResolved (Get-CompanionPathFor -RelPath $relPath))) $supersede = $false - if (-not $targetMatchesRecorded) { + # Only a real target-vs-recorded divergence with both blobs present can be this pattern - + # a brand-new or upstream-deleted path has nothing recorded to have been corrupted from. + $isEncodingCorruption = (-not $targetMatchesRecorded) -and $recordedExists -and $targetExists -and + (Test-EncodingCorruptionOf -Correct $recordedContentNormalized -Candidate $targetContentNormalized) + $repairingCorruption = $isEncodingCorruption -and $RepairCorruption + $corruptionNote = if ($isEncodingCorruption) { + " Matches the pre-#20 (a24541e) encoding-corruption pattern baked in by writes under the old bug, not a genuine edit - re-run with -RepairCorruption to take the kit's content." + } else { '' } + + if (-not $targetMatchesRecorded -and -not $repairingCorruption) { if (-not $recordedExists -and $targetExists) { # New-to-the-kit path, but the target already has an unrelated file there. Tested # before the core branches below: a same-named file the kit never installed is not @@ -318,11 +372,11 @@ foreach ($relPath in ($scopePaths | Sort-Object)) { continue } if (-not $isCore) { - $report.Add((New-SyncReport $relPath 'Divergent-Skipped' 'Target modified this file locally; never merged.')) + $report.Add((New-SyncReport $relPath 'Divergent-Skipped' "Target modified this file locally; never merged.$corruptionNote")) continue } if (-not $companionPresent) { - $report.Add((New-SyncReport $relPath 'Unmigrated-Blocked' "Target edited this core and has no $(Get-CompanionPathFor -RelPath $relPath). Move the edit there, then re-sync.")) + $report.Add((New-SyncReport $relPath 'Unmigrated-Blocked' "Target edited this core and has no $(Get-CompanionPathFor -RelPath $relPath). Move the edit there, then re-sync.$corruptionNote")) continue } # Companion present: the split is adopted here, so the core is the kit's outright. Falls @@ -342,7 +396,7 @@ foreach ($relPath in ($scopePaths | Sort-Object)) { continue } - $verb = if ($supersede) { 'Superseded' } elseif ($targetExists) { 'Updated' } else { 'Added' } + $verb = if ($repairingCorruption) { 'RepairedCorruption' } elseif ($supersede) { 'Superseded' } elseif ($targetExists) { 'Updated' } else { 'Added' } $status = if ($DryRun) { "Would$verb" } else { $verb } if (-not $DryRun) { $parent = Split-Path -Parent $targetPath @@ -379,6 +433,10 @@ $removedCount = @($report | Where-Object Status -eq 'RemovedUpstream-Skipped').C if ($removedCount -gt 0) { Write-Host "$removedCount file(s) were removed upstream but left in place - re-run with -Force to delete them." } +$repairedCount = @($report | Where-Object { $_.Status -in @('RepairedCorruption', 'WouldRepairedCorruption') }).Count +if ($repairedCount -gt 0) { + Write-Host "$repairedCount file(s) had pre-#20 encoding corruption repaired with the kit's correct content." +} if ($advanceKitJson -and -not $DryRun -and $headSha -ne $RecordedSha) { $kitJson | Add-Member -NotePropertyName syncedCommit -NotePropertyValue $headSha -Force diff --git a/tools/Test-CIWorkflow.Tests.ps1 b/tools/Test-CIWorkflow.Tests.ps1 new file mode 100644 index 0000000..7f381a7 --- /dev/null +++ b/tools/Test-CIWorkflow.Tests.ps1 @@ -0,0 +1,34 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Regression coverage for #79: the "Run Pester tests" CI step calls the real design-state + check (S12.5, tools/Test-DesignState.Tests.ps1) against this repository, which needs an + authenticated gh exactly as the later "Check the design state against the tree" step + already does - so it needs the same GH_TOKEN. Without it, an unauthenticated gh turns + S12.5 into a could-not-evaluate (TrackerUnavailable) rather than a check of anything this + step is meant to gate. +#> + +Describe 'CI workflow: the Run Pester tests step is authenticated (#79)' { + + BeforeAll { + $script:WorkflowPath = Join-Path (Split-Path $PSScriptRoot -Parent) '.github/workflows/verify.yml' + $script:Lines = Get-Content -LiteralPath $script:WorkflowPath + } + + It 'the "Run Pester tests" step carries a GH_TOKEN env, the same as "Check the design state against the tree"' { + $stepIndex = ($script:Lines | Select-String -Pattern '- name: Run Pester tests').LineNumber + $stepIndex | Should -Not -BeNullOrEmpty + + # The step body runs from its `- name:` line to the line before the next `- name:` + # (or end of file), so this only inspects this one step's own env block. + $nextStepIndex = ($script:Lines | Select-String -Pattern '^\s*- name:' | + Where-Object { $_.LineNumber -gt $stepIndex } | + Select-Object -First 1).LineNumber + $endIndex = if ($nextStepIndex) { $nextStepIndex - 1 } else { $script:Lines.Count } + $stepBody = $script:Lines[($stepIndex - 1)..($endIndex - 1)] -join "`n" + + $stepBody | Should -Match 'GH_TOKEN:\s*\$\{\{\s*secrets\.GITHUB_TOKEN\s*\}\}' + } +} diff --git a/tools/Test-Companion.Tests.ps1 b/tools/Test-Companion.Tests.ps1 index 5e98637..f99dd11 100644 --- a/tools/Test-Companion.Tests.ps1 +++ b/tools/Test-Companion.Tests.ps1 @@ -68,12 +68,12 @@ BeforeAll { if (-not $CompanionPath) { $CompanionPath = ".claude/commands/$Name-local.md" } $cats = ($Categories | ForEach-Object { "``$_``" }) -join ', ' $block = @( - '' + '' "**Per-repo companion:** ``$CompanionPath``. Read it now, if it exists — an absent," 'empty, or frontmatter-only file is no companion, and this file then stands alone.' "It may override: $cats. It may never override anything in" '[`.claude/COMPANIONS.md`](../COMPANIONS.md) § *Never*, which is also where these categories are defined.' - '' + '' ) -join "`n" $body = @("---", "description: fixture $Name", "---", "") @@ -86,6 +86,19 @@ BeforeAll { param([Parameter(Mandatory)][string] $Repo, [Parameter(Mandatory)][string] $Name) Write-Fixture -Repo $Repo -RelPath ".claude/commands/$Name.md" -Content "---`ndescription: fixture $Name`n---`n`nDo the $Name thing.`n" } + + # The bare form means projected (AGENTS.md, *Marked regions*), so a core still carrying it + # is indistinguishable from one with no fence at all - MissingBlock, not a parsed block. + function New-CoreWithBareBlock { + param([Parameter(Mandatory)][string] $Repo, [Parameter(Mandatory)][string] $Name) + $block = @( + '' + "**Per-repo companion:** ``.claude/commands/$Name-local.md``." + 'It may override: `vocabulary`.' + '' + ) -join "`n" + Write-Fixture -Repo $Repo -RelPath ".claude/commands/$Name.md" -Content "---`ndescription: fixture $Name`n---`n`n$block`n`nDo the $Name thing.`n" + } } AfterAll { @@ -203,6 +216,17 @@ Describe 'Test-Companion — negative cases, one per rule' { Get-CompanionExitCode -State $r.State | Should -Be 1 } + It 'MissingBlock — a core carrying the bare (projected) form rather than the declared form' { + $repo = New-Fixture -Name 'neg-bare-block' + New-CoreWithBareBlock -Repo $repo -Name 'slice' + + $r = Invoke-CompanionCheck -TargetRepo $repo + + $r.State | Should -Be 'Invalid' + $r.Findings.Rule | Should -Contain 'MissingBlock' + $r.CoreCount | Should -Be 1 + } + It 'DuplicateBlock — a core with two fenced blocks' { $repo = New-Fixture -Name 'neg-duplicate-block' New-Core -Repo $repo -Name 'slice' -BlockCount 2 diff --git a/tools/Test-Companion.ps1 b/tools/Test-Companion.ps1 index dd0b62d..d5f9b9a 100644 --- a/tools/Test-Companion.ps1 +++ b/tools/Test-Companion.ps1 @@ -9,6 +9,10 @@ .claude/commands/-local.md. The core enumerates which categories the companion may override; COMPANIONS.md owns the category vocabulary and the never-list. + The core's fence is a declared marked region, id "companion" (AGENTS.md, *Marked regions*): + ... . The bare form means + projected, so a core carrying it rather than the declared form is nonconforming. + None of that is checkable by reading the core files alone, which is the whole reason this script exists - AGENTS.md, *Verification*: "A schema or validator change is not done until it has rejected something." @@ -19,7 +23,7 @@ What is checked, per finding rule: - MissingBlock A core has no ... :end fence + MissingBlock A core has no ... :end fence DuplicateBlock A core has more than one WrongCompanionPath The fence names a path other than -local.md NoCategories A core declares an empty override list @@ -113,7 +117,7 @@ function Get-CoreDeclaration { param([Parameter(Mandatory)][string] $Path) $text = [System.IO.File]::ReadAllText($Path) -replace "`r`n", "`n" - $blocks = [regex]::Matches($text, '(?s)(.*?)') + $blocks = [regex]::Matches($text, '(?s)(.*?)') if ($blocks.Count -eq 0) { return $null } $body = $blocks[0].Groups[1].Value @@ -203,7 +207,7 @@ function Invoke-CompanionCheck { $decl = Get-CoreDeclaration -Path $core.FullName if ($null -eq $decl) { - $findings.Add((New-CompanionFinding $rel 'MissingBlock' 'No block. Every core must declare what its companion may override, even when the answer is a short list.')) + $findings.Add((New-CompanionFinding $rel 'MissingBlock' 'No block. Every core must declare what its companion may override, even when the answer is a short list.')) continue } $declarations[$name] = $decl.Categories diff --git a/tools/Test-DesignState.Tests.ps1 b/tools/Test-DesignState.Tests.ps1 new file mode 100644 index 0000000..c823fdd --- /dev/null +++ b/tools/Test-DesignState.Tests.ps1 @@ -0,0 +1,1330 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Test-DesignState.ps1 exits the process on real invocation (0/1/2), so these tests dot-source + it purely to reuse its functions and skip its own invocation block - the same guard shape + Test-DesignDrift.ps1, Wait-PullRequestCheck.ps1 and Read-DesignState.ps1 already use. + + Every fixture below is written into $TestDrive under a throwaway root; the final Describe + block is explicit about reading this repository's own tree instead. +#> + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Test-DesignState.ps1' + . $script:ScriptPath -Path $TestDrive + + function New-StateFile { + param([Parameter(Mandatory)][string] $RelativePath, [Parameter(Mandatory)][string] $Content) + $full = Join-Path $TestDrive (Join-Path 'design/state' $RelativePath) + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Content -Encoding utf8NoBOM + $full + } + + function New-TreeFile { + param([Parameter(Mandatory)][string] $RelativePath, [Parameter(Mandatory)][string] $Content) + $full = Join-Path $TestDrive $RelativePath + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Content -Encoding utf8NoBOM + $full + } + + function New-Record { + param( + [Parameter(Mandatory)][string] $Id, + [string] $Kind = 'Unit', + [hashtable] $Scalars = @{}, + [hashtable] $Lists = @{}, + [hashtable] $Prose = @{}, + [string] $Path = 'design/state/units/command/placeholder.md' + ) + New-DesignRecord -Id $Id -Kind $Kind -Path $Path -Scalars $Scalars -Lists $Lists -Prose $Prose + } + + # A record file of an exact byte length, for the two sides of the closure ceiling. Shared by + # the ClosureOverBudget pair below so the boundary case and its near-miss are built the same + # way and differ only in the one number under test. + function New-ExactSizeRecord { + param([Parameter(Mandatory)][string] $Slug, [Parameter(Mandatory)][int] $TotalBytes) + $header = "# unit/command/$Slug`nKind: command`n" + $pad = $TotalBytes - [System.Text.Encoding]::UTF8.GetByteCount($header) + $content = $header + ('z' * $pad) + $full = Join-Path $TestDrive "design/state/units/command/$Slug.md" + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + [System.IO.File]::WriteAllText($full, $content, [System.Text.UTF8Encoding]::new($false)) + $full + } + + # A minimal but exact stand-in for the two sections of design/20-contract.md the checker + # parses about itself - the same 23 class ids Test-DesignState.ps1 declares, and a verbatim + # copy of § "Artifacts of a unit kind"'s table - so end-to-end tests below do not spuriously + # raise ClassListDisagreement or GlobDisagreement while exercising something else entirely. + # The glob table agrees with the enumeration over any tree by construction, which is exactly + # the property GlobDisagreement exists to keep true of the real document. + $script:MinimalContract = @' +### Artifacts of a unit kind + +| Kind | Glob | Excluded | +|---|---|---| +| command | `.claude/commands/*.md` | `*-local.md` | +| script | `tools/*.ps1` | `*.Tests.ps1` | +| document | `design/*.md`, `templates/design/*.md`, `*.md`, `.claude/COMPANIONS.md`, `.github/ISSUE_TEMPLATE/*.md`, `codex/PROFILES.md` | `design/FROZEN.md`, `CLAUDE.md` | +| invariant | not a tree path | — | + +### The divergence classes + +**This is the closed list.** + +**Blocking.** + +| Class | Raised when | Caller sees | +|---|---|---| +| `UnresolvedId` | x | x | +| `AnchorMissing` | x | x | +| `OwnerMismatch` | x | x | +| `UnrecordedArtifact` | x | x | +| `ProjectionStale` | x | x | +| `RegionMalformed` | x | x | +| `IdCollision` | x | x | +| `DecisionAnchorAmbiguous` | x | x | +| `LogEntryUnrecorded` | x | x | +| `EnforcementUnevidenced` | x | x | +| `ClosureOverBudget` | x | x | +| `ClassListDisagreement` | x | x | +| `GlobDisagreement` | x | x | + +**Reported, never blocking.** + +| Class | Raised when | Why it never blocks | +|---|---|---| +| `MirrorStale` | x | x | +| `WorkStateDivergence` | x | x | +| `PinAncestry` | x | x | +| `SemanticDisagreement` | x | x | + +**Could not evaluate.** + +| `DesignStateFailure` | Raised when | Caller does | +|---|---|---| +| `StateSetAbsent` | x | x | +| `RecordUnparseable` | x | x | +| `TrackerUnavailable` | x | x | +| `ShallowCheckout` | x | x | +| `ProjectorFailed` | x | x | +| `ContractListUnreadable` | x | x | + +### The freeze + +## Invariants + +| | Statement | Owner | Enforcement | Evidence | +|---|---|---|---|---| + +## Unresolved +'@ +} + +AfterAll { + Get-ChildItem $TestDrive -ErrorAction SilentlyContinue -Recurse -File | + Remove-Item -Force -ErrorAction SilentlyContinue +} + +Describe 'Test-DesignState: id resolution and record-level classes' { + + It 'S5.1: UnresolvedId fires when a list field names an id with no record' -Tag 'Fires','UnresolvedId' { + $a = New-Record -Id 'unit/command/a' -Lists @{ Binds = @('I999') } + $findings = Test-UnresolvedId -ById @{ 'unit/command/a' = $a } -Records @($a) + $findings.Count | Should -Be 1 + $findings[0].Class | Should -Be 'UnresolvedId' + $findings[0].Subject | Should -Be 'unit/command/a' + } + + It 'UnresolvedId does not fire for a retired id a live record names (still resolvable)' -Tag 'NearMiss','UnresolvedId' { + $a = New-Record -Id 'unit/command/a' -Lists @{ Live = @('decision/x') } + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Status = 'retired' } + $findings = Test-UnresolvedId -ById @{ 'unit/command/a' = $a; 'decision/x' = $d } -Records @($a, $d) + $findings.Count | Should -Be 0 + } + + It 'UnresolvedId does not check Work or Evidence - they are not design-state ids' -Tag 'NearMiss','UnresolvedId' { + $a = New-Record -Id 'unit/command/a' -Lists @{ Work = @('42'); Evidence = @('tools/x.ps1') } + $findings = Test-UnresolvedId -ById @{ 'unit/command/a' = $a } -Records @($a) + $findings.Count | Should -Be 0 + } + + It 'S5.1: UnresolvedId also checks scalar id fields (Owner, SupersededBy, AnsweredBy)' -Tag 'Fires','UnresolvedId' { + $c = New-Record -Id 'contract/x' -Kind 'Contract' -Scalars @{ Owner = 'unit/command/nobody' } + $findings = Test-UnresolvedId -ById @{ 'contract/x' = $c } -Records @($c) + $findings.Count | Should -Be 1 + $findings[0].Detail | Should -Match 'Owner' + } + + It 'S5.1/module boundaries: AnchorMissing fires only for an active Unit whose Anchor is not in the tree' -Tag 'Fires','AnchorMissing' { + $active = New-Record -Id 'unit/command/a' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/nope.md' } + $retired = New-Record -Id 'unit/command/b' -Scalars @{ Status = 'retired'; Kind = 'command'; Anchor = '.claude/commands/also-nope.md' } + $invariant = New-Record -Id 'I1' -Kind 'Invariant' -Scalars @{ Status = 'active'; Anchor = 'I1'; Kind = 'invariant' } + + $findings = Test-AnchorMissing -Records @($active, $retired, $invariant) -RepoPath $TestDrive + + $findings.Count | Should -Be 1 + $findings[0].Subject | Should -Be 'unit/command/a' + } + + It 'AnchorMissing does not fire when the anchor exists' -Tag 'NearMiss','AnchorMissing' { + New-TreeFile -RelativePath '.claude/commands/real.md' -Content 'hi' + $active = New-Record -Id 'unit/command/a' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/real.md' } + $findings = Test-AnchorMissing -Records @($active) -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } + + It 'AnchorMissing fires for a Contract Declaration that is not in the tree' -Tag 'Fires','AnchorMissing' { + $c = New-Record -Id 'contract/x' -Kind 'Contract' -Scalars @{ Status = 'active'; Owner = 'unit/script/a'; Declaration = 'tools/Absent.ps1' } + $findings = Test-AnchorMissing -Records @($c) -RepoPath $TestDrive + $findings.Count | Should -Be 1 + $findings[0].Subject | Should -Be 'contract/x' + $findings[0].Detail | Should -Match 'Declaration' + } + + It 'AnchorMissing does not fire for a Contract Declaration of the literal prose, or one that resolves' -Tag 'NearMiss','AnchorMissing' { + New-TreeFile -RelativePath 'tools/Present.ps1' -Content 'x' + $prose = New-Record -Id 'contract/p' -Kind 'Contract' -Scalars @{ Status = 'active'; Owner = 'unit/command/a'; Declaration = 'prose' } + $real = New-Record -Id 'contract/r' -Kind 'Contract' -Scalars @{ Status = 'active'; Owner = 'unit/script/a'; Declaration = 'tools/Present.ps1' } + $retired = New-Record -Id 'contract/g' -Kind 'Contract' -Scalars @{ Status = 'retired'; Owner = 'unit/script/a'; Declaration = 'tools/Gone.ps1' } + $findings = Test-AnchorMissing -Records @($prose, $real, $retired) -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } + + It 'AnchorMissing fires for an Evidence entry that is not in the tree, on a Unit and on an Invariant' -Tag 'Fires','AnchorMissing' { + New-TreeFile -RelativePath '.claude/commands/anchored.md' -Content 'x' + $unit = New-Record -Id 'unit/command/e' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/anchored.md' } -Lists @{ Evidence = @('tools/Nothing.Tests.ps1') } + $inv = New-Record -Id 'I2' -Kind 'Invariant' -Scalars @{ Status = 'active'; Anchor = 'I2'; Kind = 'invariant'; Enforcement = 'code' } -Lists @{ Evidence = @('tools/AlsoNothing.Tests.ps1') } + + $findings = Test-AnchorMissing -Records @($unit, $inv) -RepoPath $TestDrive + + $findings.Count | Should -Be 2 + @($findings | ForEach-Object { $_.Subject }) | Should -Contain 'unit/command/e' + @($findings | ForEach-Object { $_.Subject }) | Should -Contain 'I2' + @($findings | ForEach-Object { $_.Detail }) | Should -Not -Contain $null + $findings[0].Detail | Should -Match 'Evidence' + } + + It 'AnchorMissing does not fire for an Evidence entry that resolves, or an empty Evidence list' -Tag 'NearMiss','AnchorMissing' { + New-TreeFile -RelativePath '.claude/commands/anchored2.md' -Content 'x' + New-TreeFile -RelativePath 'tools/Something.Tests.ps1' -Content 'x' + $withEvidence = New-Record -Id 'unit/command/f' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/anchored2.md' } -Lists @{ Evidence = @('tools/Something.Tests.ps1') } + $empty = New-Record -Id 'unit/command/g' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/anchored2.md' } -Lists @{ Evidence = @() } + $findings = Test-AnchorMissing -Records @($withEvidence, $empty) -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } + + It 'S5.1: OwnerMismatch fires when nobody exposes the contract' -Tag 'Fires','OwnerMismatch' { + $c = New-Record -Id 'contract/x' -Kind 'Contract' -Scalars @{ Owner = 'unit/command/a'; Status = 'active' } + $findings = Test-OwnerMismatch -Records @($c) + $findings.Count | Should -Be 1 + $findings[0].Detail | Should -Match 'nobody' + } + + It 'OwnerMismatch fires when two units expose the same contract' -Tag 'Fires','OwnerMismatch' { + $c = New-Record -Id 'contract/x' -Kind 'Contract' -Scalars @{ Owner = 'unit/command/a'; Status = 'active' } + $a = New-Record -Id 'unit/command/a' -Scalars @{ Status = 'active' } -Lists @{ Exposes = @('contract/x') } + $b = New-Record -Id 'unit/command/b' -Scalars @{ Status = 'active' } -Lists @{ Exposes = @('contract/x') } + $findings = Test-OwnerMismatch -Records @($c, $a, $b) + $findings.Count | Should -Be 1 + } + + It 'OwnerMismatch does not fire for the unique active exposer matching Owner' -Tag 'NearMiss','OwnerMismatch' { + $c = New-Record -Id 'contract/x' -Kind 'Contract' -Scalars @{ Owner = 'unit/command/a'; Status = 'active' } + $a = New-Record -Id 'unit/command/a' -Scalars @{ Status = 'active' } -Lists @{ Exposes = @('contract/x') } + $findings = Test-OwnerMismatch -Records @($c, $a) + $findings.Count | Should -Be 0 + } + + It 'S5.1: EnforcementUnevidenced fires for an invariant claiming code enforcement with no Evidence' -Tag 'Fires','EnforcementUnevidenced' { + $i = New-Record -Id 'I1' -Kind 'Invariant' -Scalars @{ Enforcement = 'code' } -Lists @{ Evidence = @() } + $findings = Test-EnforcementUnevidenced -Records @($i) + $findings.Count | Should -Be 1 + } + + It 'EnforcementUnevidenced does not fire when Evidence is present, or when Enforcement is instruction' -Tag 'NearMiss','EnforcementUnevidenced' { + $withEvidence = New-Record -Id 'I1' -Kind 'Invariant' -Scalars @{ Enforcement = 'code' } -Lists @{ Evidence = @('tools/x.Tests.ps1') } + $instruction = New-Record -Id 'I2' -Kind 'Invariant' -Scalars @{ Enforcement = 'instruction' } -Lists @{ Evidence = @() } + $findings = Test-EnforcementUnevidenced -Records @($withEvidence, $instruction) + $findings.Count | Should -Be 0 + } + + It 'S18.1: EnforcementUnevidenced fires for a superseded decision with no SupersededBy' -Tag 'Fires','EnforcementUnevidenced' { + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Status = 'superseded' } + $findings = Test-EnforcementUnevidenced -Records @($d) + $findings.Count | Should -Be 1 + $findings[0].Detail | Should -Match "SupersededBy" + } + + It 'S18.3: EnforcementUnevidenced does not fire for an accepted decision with no SupersededBy' -Tag 'NearMiss','EnforcementUnevidenced' { + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Status = 'accepted' } + $findings = Test-EnforcementUnevidenced -Records @($d) + $findings.Count | Should -Be 0 + } + + It 'S18.1: EnforcementUnevidenced fires for an answered question with no AnsweredBy' -Tag 'Fires','EnforcementUnevidenced' { + $q = New-Record -Id 'question/x' -Kind 'Question' -Scalars @{ Status = 'answered' } + $findings = Test-EnforcementUnevidenced -Records @($q) + $findings.Count | Should -Be 1 + $findings[0].Detail | Should -Match "AnsweredBy" + } + + It 'S18.3: EnforcementUnevidenced does not fire for an open question with no AnsweredBy' -Tag 'NearMiss','EnforcementUnevidenced' { + $q = New-Record -Id 'question/x' -Kind 'Question' -Scalars @{ Status = 'open' } + $findings = Test-EnforcementUnevidenced -Records @($q) + $findings.Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: IdCollision' { + + It 'S5.1: fires when two records claim the same id' -Tag 'Fires','IdCollision' { + # a-again.md's own path implies id 'unit/command/a-again', which also disagrees with + # the record's declared id - that is a second, independent IdCollision (a record whose + # id disagrees with its file path), so both records claiming 'unit/command/a' produce + # two findings here, not one. + $a = New-Record -Id 'unit/command/a' -Path 'design/state/units/command/a.md' + $b = New-Record -Id 'unit/command/a' -Path 'design/state/units/command/a-again.md' + $findings = Test-RecordIdCollision -Records @($a, $b) + (@($findings | Where-Object { $_.Detail -match 'claimed by more than one file' })).Count | Should -Be 1 + } + + It 'S4.7: fires when a record''s own id disagrees with the id its file path implies' -Tag 'Fires','IdCollision' { + $a = New-Record -Id 'unit/command/wrong' -Path 'design/state/units/command/right.md' + $findings = Test-RecordIdCollision -Records @($a) + $findings.Count | Should -Be 1 + $findings[0].Detail | Should -Match 'right' + } + + It 'does not fire when a single record''s id agrees with its path' -Tag 'NearMiss','IdCollision' { + $a = New-Record -Id 'unit/command/right' -Path 'design/state/units/command/right.md' + $findings = Test-RecordIdCollision -Records @($a) + $findings.Count | Should -Be 0 + } + + It 'region form collision: fires when an id appears as both the projected and the declared form' -Tag 'Fires','IdCollision' { + $inventory = @( + [pscustomobject]@{ Id = 'companion'; Form = 'Projected'; File = 'a.md' } + [pscustomobject]@{ Id = 'companion'; Form = 'Declared'; File = 'b.md' } + ) + $findings = Test-RegionFormCollision -Inventory $inventory + $findings.Count | Should -Be 1 + $findings[0].Class | Should -Be 'IdCollision' + } + + It 'region form collision: does not fire when the same id only ever appears in one form' -Tag 'NearMiss','IdCollision' { + $inventory = @( + [pscustomobject]@{ Id = 'companion'; Form = 'Projected'; File = 'a.md' } + [pscustomobject]@{ Id = 'companion'; Form = 'Projected'; File = 'b.md' } + ) + $findings = Test-RegionFormCollision -Inventory $inventory + $findings.Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: marked regions (RegionMalformed)' { + + It 'balanced projected and declared regions raise nothing' -Tag 'NearMiss','RegionMalformed' { + New-TreeFile -RelativePath '.claude/commands/ok.md' -Content @' +before + +body + + +hand-written + +after +'@ + $result = Get-MarkedRegions -RepoPath $TestDrive -Files @('.claude/commands/ok.md') + $result.Findings.Count | Should -Be 0 + $result.Inventory.Count | Should -Be 2 + } + + It 'S5.1: an unterminated region is RegionMalformed' -Tag 'Fires','RegionMalformed' { + New-TreeFile -RelativePath '.claude/commands/unterminated.md' -Content @' + +never closed +'@ + $result = Get-MarkedRegions -RepoPath $TestDrive -Files @('.claude/commands/unterminated.md') + $result.Findings.Count | Should -Be 1 + $result.Findings[0].Class | Should -Be 'RegionMalformed' + } + + It 'S5.1: a nested region of the same id is RegionMalformed' -Tag 'Fires','RegionMalformed' { + New-TreeFile -RelativePath '.claude/commands/nested.md' -Content @' + + + + +'@ + $result = Get-MarkedRegions -RepoPath $TestDrive -Files @('.claude/commands/nested.md') + $result.Findings.Count | Should -BeGreaterThan 0 + } + + It 'a marker mentioned mid-sentence in prose (not alone on its line) is not treated as a region' -Tag 'NearMiss','RegionMalformed' { + New-TreeFile -RelativePath 'design/prose.md' -Content @' +This paragraph mentions `` as an example of the syntax, inline. +'@ + $result = Get-MarkedRegions -RepoPath $TestDrive -Files @('design/prose.md') + $result.Findings.Count | Should -Be 0 + $result.Inventory.Count | Should -Be 0 + } + + It 'a mismatched closing marker is RegionMalformed' -Tag 'Fires','RegionMalformed' { + New-TreeFile -RelativePath '.claude/commands/mismatch.md' -Content @' + + +'@ + $result = Get-MarkedRegions -RepoPath $TestDrive -Files @('.claude/commands/mismatch.md') + $result.Findings.Count | Should -BeGreaterThan 0 + } +} + +Describe 'Test-DesignState: DecisionAnchorAmbiguous and LogEntryUnrecorded' { + + It 'S5.1: DecisionAnchorAmbiguous fires when a decision''s Anchor resolves to zero headings' -Tag 'Fires','DecisionAnchorAmbiguous' { + New-TreeFile -RelativePath 'design/90-decisions.md' -Content @' +### 2026-01-01 — Something happened +'@ + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Anchor = '2026-01-01 — Something else entirely' } + $findings = Test-DecisionAnchors -Records @($d) -LogPath (Join-Path $TestDrive 'design/90-decisions.md') + (@($findings | Where-Object { $_.Class -eq 'DecisionAnchorAmbiguous' })).Count | Should -Be 1 + } + + It 'DecisionAnchorAmbiguous fires when a decision''s Anchor resolves to two headings' -Tag 'Fires','DecisionAnchorAmbiguous' { + New-TreeFile -RelativePath 'design/90-decisions.md' -Content @' +### 2026-01-01 — Duplicate heading +### 2026-01-01 — Duplicate heading +'@ + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Anchor = '2026-01-01 — Duplicate heading' } + $findings = Test-DecisionAnchors -Records @($d) -LogPath (Join-Path $TestDrive 'design/90-decisions.md') + (@($findings | Where-Object { $_.Class -eq 'DecisionAnchorAmbiguous' })).Count | Should -Be 1 + } + + It 'DecisionAnchorAmbiguous does not fire when the Anchor resolves to exactly one heading' -Tag 'NearMiss','DecisionAnchorAmbiguous' { + New-TreeFile -RelativePath 'design/90-decisions.md' -Content @' +### 2026-01-01 — Only one +'@ + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Anchor = '2026-01-01 — Only one' } + $findings = Test-DecisionAnchors -Records @($d) -LogPath (Join-Path $TestDrive 'design/90-decisions.md') + (@($findings | Where-Object { $_.Class -eq 'DecisionAnchorAmbiguous' })).Count | Should -Be 0 + } + + It 'S5.1: LogEntryUnrecorded fires for a log heading with no decision record naming it' -Tag 'Fires','LogEntryUnrecorded' { + New-TreeFile -RelativePath 'design/90-decisions.md' -Content @' +### 2026-01-01 — Unrecorded entry +'@ + $findings = Test-DecisionAnchors -Records @() -LogPath (Join-Path $TestDrive 'design/90-decisions.md') + (@($findings | Where-Object { $_.Class -eq 'LogEntryUnrecorded' })).Count | Should -Be 1 + } + + It 'LogEntryUnrecorded does not fire when every heading has a matching decision record' -Tag 'NearMiss','LogEntryUnrecorded' { + New-TreeFile -RelativePath 'design/90-decisions.md' -Content @' +### 2026-01-01 — Recorded +'@ + $d = New-Record -Id 'decision/x' -Kind 'Decision' -Scalars @{ Anchor = '2026-01-01 — Recorded' } + $findings = Test-DecisionAnchors -Records @($d) -LogPath (Join-Path $TestDrive 'design/90-decisions.md') + (@($findings | Where-Object { $_.Class -eq 'LogEntryUnrecorded' })).Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: UnrecordedArtifact' { + + It 'fires for a command-glob file with no active unit record naming it as Anchor' -Tag 'Fires','UnrecordedArtifact' { + New-TreeFile -RelativePath '.claude/commands/lonely.md' -Content 'x' + $findings = Test-UnrecordedArtifact -Records @() -RepoPath $TestDrive + (@($findings | Where-Object { $_.Subject -eq '.claude/commands/lonely.md' })).Count | Should -Be 1 + } + + It 'excludes a *-local.md companion file from the command glob' -Tag 'NearMiss','UnrecordedArtifact' { + New-TreeFile -RelativePath '.claude/commands/foo-local.md' -Content 'x' + $findings = Test-UnrecordedArtifact -Records @() -RepoPath $TestDrive + (@($findings | Where-Object { $_.Subject -eq '.claude/commands/foo-local.md' })).Count | Should -Be 0 + } + + It 'does not fire when an active unit record names the artifact as its Anchor' -Tag 'NearMiss','UnrecordedArtifact' { + New-TreeFile -RelativePath '.claude/commands/known.md' -Content 'x' + $unit = New-Record -Id 'unit/command/known' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/known.md' } + $findings = Test-UnrecordedArtifact -Records @($unit) -RepoPath $TestDrive + (@($findings | Where-Object { $_.Subject -eq '.claude/commands/known.md' })).Count | Should -Be 0 + } + + It 'reverse direction: fires when an active unit record''s Anchor is not matched by its kind''s glob' -Tag 'Fires','UnrecordedArtifact' { + $unit = New-Record -Id 'unit/command/ghost' -Scalars @{ Status = 'active'; Kind = 'command'; Anchor = '.claude/commands/does-not-exist.md' } + $findings = Test-UnrecordedArtifact -Records @($unit) -RepoPath $TestDrive + (@($findings | Where-Object { $_.Subject -eq 'unit/command/ghost' })).Count | Should -Be 1 + } + + It 'invariant kind: fires for a contract row with no record, and for a record that is no row' -Tag 'Fires','UnrecordedArtifact' { + $recorded = New-Record -Id 'I8' -Kind 'Invariant' -Scalars @{ Status = 'active' } + $findings = Test-UnrecordedArtifact -Records @($recorded) -RepoPath $TestDrive -InvariantIds @('I7') + + $subjects = @($findings | ForEach-Object { $_.Subject }) + $subjects | Should -Contain 'I7' + $subjects | Should -Contain 'I8' + } + + It 'invariant kind: raises nothing when the contract table and the records agree' -Tag 'NearMiss','UnrecordedArtifact' { + $recorded = New-Record -Id 'I7' -Kind 'Invariant' -Scalars @{ Status = 'active' } + $findings = Test-UnrecordedArtifact -Records @($recorded) -RepoPath $TestDrive -InvariantIds @('I7') + (@($findings | Where-Object { $_.Subject -eq 'I7' })).Count | Should -Be 0 + } + + It 'invariant kind: a citation nothing records is not a finding - membership is the table, not the quote' -Tag 'NearMiss','UnrecordedArtifact' { + New-TreeFile -RelativePath 'AGENTS.md' -Content 'This project relies on I7 throughout.' + $findings = Test-UnrecordedArtifact -Records @() -RepoPath $TestDrive -InvariantIds @() + (@($findings | Where-Object { $_.Subject -eq 'I7' })).Count | Should -Be 0 + } + + It 'invariant kind: an unreadable table leaves the half uncomputed rather than clean' -Tag 'NearMiss','UnrecordedArtifact' { + $recorded = New-Record -Id 'I8' -Kind 'Invariant' -Scalars @{ Status = 'active' } + $findings = Test-UnrecordedArtifact -Records @($recorded) -RepoPath $TestDrive -InvariantIds $null + (@($findings | Where-Object { $_.Subject -eq 'I8' })).Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: Get-ContractInvariantIds' { + + It 'reads every invariant row of the Invariants section and stops at the next section' { + $path = New-TreeFile -RelativePath 'design/20-contract.md' -Content @' +# Contract + +## Invariants + + +| | Statement | Owner | Enforcement | Evidence | +|---|---|---|---|---| +| **I3** | x | y | instruction | - | + + +| | Statement | Owner | Enforcement | Evidence | +|---|---|---|---|---| +| **I1** | x | y | code | z | +| **I2** | x | y | code | z | + +## Unresolved + +| **I99** | not an invariant row - it is past the section | +'@ + $parsed = Get-ContractInvariantIds -ContractPath $path + $parsed.Failure | Should -BeNullOrEmpty + $parsed.Ids | Should -Be @('I1', 'I2', 'I3') + } + + It 'reports ContractPathMissing rather than an empty set when the document is absent' { + $parsed = Get-ContractInvariantIds -ContractPath (Join-Path $TestDrive 'design/absent.md') + $parsed.Ids | Should -BeNullOrEmpty + $parsed.Failure | Should -Be 'ContractPathMissing' + } + + It 'reports InvariantsSectionNotFound rather than an empty set when the section is absent' { + $path = New-TreeFile -RelativePath 'design/no-invariants.md' -Content "# Contract`n`n## Types`n`nnothing here`n" + $parsed = Get-ContractInvariantIds -ContractPath $path + $parsed.Ids | Should -BeNullOrEmpty + $parsed.Failure | Should -Be 'InvariantsSectionNotFound' + } +} + +Describe 'Test-DesignState: the budget meter (S5.5, S5.7)' { + + It 'S5.5: closure excludes Archival and excludes any named record whose Status is retired' { + New-StateFile -RelativePath 'units/command/root.md' -Content @' +# unit/command/root +Kind: command +Status: active +Live: decision/live-one +Archival: decision/archival-one +Binds: I1 +'@ + New-StateFile -RelativePath 'decisions/live-one.md' -Content @' +# decision/live-one +Status: accepted +'@ + New-StateFile -RelativePath 'decisions/archival-one.md' -Content @' +# decision/archival-one +Status: accepted +'@ + New-StateFile -RelativePath 'invariants/I1.md' -Content @' +# I1 +Kind: invariant +Status: retired +'@ + $graph = Read-DesignStateGraph -Path $TestDrive + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + $root = $byId['unit/command/root'] + + $members = Get-DesignClosure -Root $root -ById $byId + $ids = @($members | ForEach-Object { $_.Id }) + + $ids | Should -Contain 'unit/command/root' + $ids | Should -Contain 'decision/live-one' + $ids | Should -Not -Contain 'decision/archival-one' + $ids | Should -Not -Contain 'I1' + } + + It 'S5.5: a live record naming a retired one raises no UnresolvedId finding' { + New-StateFile -RelativePath 'units/command/root.md' -Content @' +# unit/command/root +Kind: command +Live: decision/retired-one +'@ + New-StateFile -RelativePath 'decisions/retired-one.md' -Content @' +# decision/retired-one +Status: retired +'@ + $graph = Read-DesignStateGraph -Path $TestDrive + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + $findings = Test-UnresolvedId -ById $byId -Records $graph.Records + $findings.Count | Should -Be 0 + } + + It 'S5.7: ClosureOverBudget fires at 16,385 bytes' -Tag 'Fires','ClosureOverBudget' { + $overPath = New-ExactSizeRecord -Slug 'big-over' -TotalBytes 16385 + (Get-Item $overPath).Length | Should -Be 16385 + + $graph = Read-DesignStateGraph -Path $TestDrive + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + + $result = Test-ClosureBudget -Records $graph.Records -ById $byId -RepoPath $TestDrive + (@($result.Findings | Where-Object { $_.Subject -eq 'unit/command/big-over' })).Count | Should -Be 1 + } + + It 'S5.7: ClosureOverBudget does not fire at exactly 16,384 bytes - the ceiling is inclusive' -Tag 'NearMiss','ClosureOverBudget' { + $underPath = New-ExactSizeRecord -Slug 'big-under' -TotalBytes 16384 + (Get-Item $underPath).Length | Should -Be 16384 + + $graph = Read-DesignStateGraph -Path $TestDrive + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + + $result = Test-ClosureBudget -Records $graph.Records -ById $byId -RepoPath $TestDrive + (@($result.Findings | Where-Object { $_.Subject -eq 'unit/command/big-under' })).Count | Should -Be 0 + } + + It 'S5.6: names the largest closure, its unit, and its largest contributor' { + New-StateFile -RelativePath 'units/command/small.md' -Content @' +# unit/command/small +Kind: command +'@ + $graph = Read-DesignStateGraph -Path $TestDrive + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + $result = Test-ClosureBudget -Records $graph.Records -ById $byId -RepoPath $TestDrive + + $result.Largest | Should -Not -BeNullOrEmpty + $result.Largest.Unit | Should -Not -BeNullOrEmpty + $result.Largest.Bytes | Should -BeGreaterThan 0 + $result.Largest.LargestContributor | Should -Not -BeNullOrEmpty + } +} + +Describe 'Test-DesignState: ClassListDisagreement (S5.1)' { + + It 'raises nothing when the contract document declares exactly the same 23 ids' -Tag 'NearMiss','ClassListDisagreement' { + New-TreeFile -RelativePath 'design/20-contract.md' -Content $script:MinimalContract + $result = Test-ClassListAgreement -ContractPath (Join-Path $TestDrive 'design/20-contract.md') + $result.Finding | Should -BeNullOrEmpty + $result.CouldNotEvaluate | Should -BeNullOrEmpty + } + + It 'fires when the contract document is missing a blocking class the script declares' -Tag 'Fires','ClassListDisagreement' { + $missingOne = $script:MinimalContract -replace "\| ``ClosureOverBudget`` \| x \| x \|\r?\n", '' + New-TreeFile -RelativePath 'design/20-contract-missing.md' -Content $missingOne + $result = Test-ClassListAgreement -ContractPath (Join-Path $TestDrive 'design/20-contract-missing.md') + $result.Finding | Should -Not -BeNullOrEmpty + $result.Finding.Class | Should -Be 'ClassListDisagreement' + } + + It 'S5.1: ContractListUnreadable is could-not-evaluate when the contract document cannot be found' { + $result = Test-ClassListAgreement -ContractPath (Join-Path $TestDrive 'design/does-not-exist.md') + $result.CouldNotEvaluate | Should -Not -BeNullOrEmpty + $result.CouldNotEvaluate.Reason | Should -Be 'ContractListUnreadable' + $result.Finding | Should -BeNullOrEmpty + } + + It 'the DesignStateFailure header cell in the "could not evaluate" table is not read as a class id' -Tag 'NearMiss','ClassListDisagreement' { + New-TreeFile -RelativePath 'design/20-contract.md' -Content $script:MinimalContract + $parsed = Get-ContractClassIds -ContractPath (Join-Path $TestDrive 'design/20-contract.md') + $parsed.Ids.CouldNotEvaluate | Should -Not -Contain 'DesignStateFailure' + } +} + +Describe 'Test-DesignState: GlobDisagreement (#74)' { + + BeforeAll { + # A throwaway tree carrying one artifact per globbed kind plus one of each exclusion, so + # every case below varies only the contract table against a tree that does not move. + $script:GlobRoot = Join-Path $TestDrive 'globfixture' + foreach ($rel in @( + '.claude/commands/alpha.md', '.claude/commands/beta-local.md', + 'tools/Thing.ps1', 'tools/Thing.Tests.ps1', + 'design/10-design.md', 'design/FROZEN.md', + 'templates/design/00-brief.md', 'templates/design/CLAUDE.md', + 'README.md', 'CLAUDE.md', + '.claude/COMPANIONS.md', '.github/ISSUE_TEMPLATE/bug.md', 'codex/PROFILES.md' + )) { + $full = Join-Path $script:GlobRoot $rel + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value 'x' -Encoding utf8NoBOM + } + + # The table as design/20-contract.md carries it. Each test below rewrites one cell. + $script:GlobTable = @' +| Kind | Glob | Excluded | +|---|---|---| +| command | `.claude/commands/*.md` | `*-local.md` | +| script | `tools/*.ps1` | `*.Tests.ps1` | +| document | `design/*.md`, `templates/design/*.md`, `*.md`, `.claude/COMPANIONS.md`, `.github/ISSUE_TEMPLATE/*.md`, `codex/PROFILES.md` | `design/FROZEN.md`, `CLAUDE.md` | +| invariant | not a tree path | — | + +trailing prose +'@ + + function New-GlobContract { + param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Table) + $full = Join-Path $TestDrive "globcontracts/$Name.md" + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Table -Encoding utf8NoBOM + $full + } + } + + It 'raises nothing when every kind resolves to exactly what the checker enumerates' -Tag 'NearMiss','GlobDisagreement' { + $path = New-GlobContract -Name 'agree' -Table $script:GlobTable + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $result.Findings | Should -BeNullOrEmpty + $result.CouldNotEvaluate | Should -BeNullOrEmpty + } + + It 'fires when the contract drops an exclusion the checker still applies' -Tag 'Fires','GlobDisagreement' { + $table = $script:GlobTable -replace '\| `\*-local\.md` \|', '| — |' + $path = New-GlobContract -Name 'no-local-exclusion' -Table $table + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $result.Findings.Class | Should -Contain 'GlobDisagreement' + $finding = @($result.Findings | Where-Object { $_.Subject -eq 'command' })[0] + $finding.Detail | Should -Match 'the contract''s patterns reach' + $finding.Detail | Should -Match 'beta-local\.md' + $finding.Blocking | Should -BeTrue + } + + It 'fires when the checker enumerates a location the contract''s patterns do not reach' -Tag 'Fires','GlobDisagreement' { + $table = $script:GlobTable -replace ', `\.github/ISSUE_TEMPLATE/\*\.md`', '' + $path = New-GlobContract -Name 'no-issue-templates' -Table $table + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $finding = @($result.Findings | Where-Object { $_.Subject -eq 'document' })[0] + $finding.Detail | Should -Match 'the checker enumerates' + $finding.Detail | Should -Match 'ISSUE_TEMPLATE/bug\.md' + } + + <# + The case Option 1A could not have caught, and the reason this class compares resolved + file sets. The document row still names exactly two exclusions - the token count and + every other token are unchanged - but one of them changes scope from a + repository-relative path to a basename pattern, so it now also excludes + templates/design/CLAUDE.md, which Get-DocumentGlobFiles still enumerates. + #> + It 'fires when an exclusion changes scope while the token list stays the same size' -Tag 'Fires','GlobDisagreement' { + $table = $script:GlobTable -replace '`design/FROZEN\.md`, `CLAUDE\.md`', '`design/FROZEN.md`, `*CLAUDE.md`' + $path = New-GlobContract -Name 'exclusion-scope' -Table $table + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $finding = @($result.Findings | Where-Object { $_.Subject -eq 'document' })[0] + $finding | Should -Not -BeNullOrEmpty + $finding.Detail | Should -Match 'templates/design/CLAUDE\.md' + } + + It 'does not fire merely because an exclusion path repeats a basename elsewhere in the tree' -Tag 'NearMiss','GlobDisagreement' { + # templates/design/CLAUDE.md and CLAUDE.md share a basename; only the latter is excluded + # on both sides, which is the near-miss the case above turns into a fire. + $path = New-GlobContract -Name 'basename-collision' -Table $script:GlobTable + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $result.Findings | Should -BeNullOrEmpty + } + + It 'fires when the table carries no patterns for a kind the checker enumerates' -Tag 'Fires','GlobDisagreement' { + $table = $script:GlobTable -replace '\| script \| `tools/\*\.ps1` \| `\*\.Tests\.ps1` \|\r?\n', '' + $path = New-GlobContract -Name 'no-script-row' -Table $table + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $finding = @($result.Findings | Where-Object { $_.Subject -eq 'script' })[0] + $finding.Detail | Should -Match 'carries no patterns for it' + } + + It 'does not compare the invariant row, which has no pattern in either cell' -Tag 'NearMiss','GlobDisagreement' { + $parsed = Get-ContractGlobPatterns -ContractPath (New-GlobContract -Name 'invariant-row' -Table $script:GlobTable) + $parsed.Failure | Should -BeNullOrEmpty + $parsed.Kinds.Keys | Should -Not -Contain 'invariant' + $parsed.Kinds.Keys | Should -Contain 'document' + } + + It 'reports ContractListUnreadable - uncomputed, never clean - when the table cannot be read' { + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath (Join-Path $TestDrive 'globcontracts/absent.md') + $result.Findings | Should -BeNullOrEmpty + $result.CouldNotEvaluate.Reason | Should -Be 'ContractListUnreadable' + $result.CouldNotEvaluate.Detail | Should -Match 'uncomputed, not clean' + } + + It 'reports ContractListUnreadable when the document exists but carries no glob table' { + $path = New-GlobContract -Name 'no-table' -Table "# Contract`n`nnothing tabular here`n" + $result = Test-GlobDisagreement -RepoPath $script:GlobRoot -ContractPath $path + $result.CouldNotEvaluate.Reason | Should -Be 'ContractListUnreadable' + $result.CouldNotEvaluate.Detail | Should -Match 'GlobTableNotFound' + } + + It 'this repository''s own table and its own enumeration agree' -Tag 'NearMiss','GlobDisagreement' { + $repo = Split-Path $PSScriptRoot -Parent + $result = Test-GlobDisagreement -RepoPath $repo -ContractPath (Join-Path $repo 'design/20-contract.md') + $result.CouldNotEvaluate | Should -BeNullOrEmpty + $result.Findings | Should -BeNullOrEmpty + } +} + +Describe 'Test-DesignState: the freeze gate (S5.8)' { + + BeforeEach { + Remove-Item -LiteralPath (Join-Path $TestDrive 'design/FROZEN.md') -Force -ErrorAction SilentlyContinue + } + + It 'downgrades every blocking finding to reported, states the count, and reproduces the marker verbatim' { + New-TreeFile -RelativePath 'design/FROZEN.md' -Content @' +# design/ is frozen + +Frozen at: abc1234, 2026-08-19 +Frozen because: escaping the generative loop +Lifts when: tier one is code-complete + +To lift: run `/unfreeze`. +'@ + $marker = Get-FreezeMarker -RepoPath $TestDrive + $marker | Should -Not -BeNullOrEmpty + $marker.FrozenBecause | Should -Be 'Frozen because: escaping the generative loop' + $marker.LiftsWhen | Should -Be 'Lifts when: tier one is code-complete' + } + + It 'returns null when design/FROZEN.md does not exist' { + $marker = Get-FreezeMarker -RepoPath $TestDrive + $marker | Should -BeNullOrEmpty + } +} + +Describe 'Test-DesignState: the projector seam (S5.10)' { + + It 'reports Ran = $false when tools/Update-DesignProjection.ps1 does not exist' { + $result = Invoke-Projector -RepoPath $TestDrive + $result.Ran | Should -BeFalse + $result.Detail | Should -Match 'does not exist' + } + + It 'reports Ran = $false when the projector exits non-zero' { + New-TreeFile -RelativePath 'tools/Update-DesignProjection.ps1' -Content 'exit 1' + $result = Invoke-Projector -RepoPath $TestDrive + $result.Ran | Should -BeFalse + } + + It 'reports Ran = $true when the projector exits zero' { + New-TreeFile -RelativePath 'tools/Update-DesignProjection.ps1' -Content 'param([string]$Path,[switch]$DryRun) exit 0' + $result = Invoke-Projector -RepoPath $TestDrive + $result.Ran | Should -BeTrue + } + + It 'S7.9: captures the projector''s -DryRun regions as structured objects, not just the exit code' { + New-TreeFile -RelativePath 'tools/Update-DesignProjection.ps1' -Content @' +param([string]$Path,[switch]$DryRun) +[pscustomobject]@{ Document = 'x.md'; Id = 'units'; Content = 'rendered' } | ConvertTo-Json +exit 0 +'@ + $result = Invoke-Projector -RepoPath $TestDrive + $result.Ran | Should -BeTrue + $result.Regions.Count | Should -Be 1 + $result.Regions[0].Id | Should -Be 'units' + } +} + +Describe 'Test-DesignState: ProjectionStale (S7.9)' { + + It 'fires when the tree''s region body differs from the projector''s rendering' -Tag 'Fires','ProjectionStale' { + New-TreeFile -RelativePath 'x.md' -Content @' +# X + + +old content + +'@ + $regions = @([pscustomobject]@{ Document = 'x.md'; Id = 'units'; Content = 'new content' }) + $findings = Test-ProjectionStale -Regions $regions -RepoPath $TestDrive + $findings.Count | Should -Be 1 + $findings[0].Class | Should -Be 'ProjectionStale' + } + + It 'does not fire when the tree''s region body matches the projector''s rendering exactly' -Tag 'NearMiss','ProjectionStale' { + New-TreeFile -RelativePath 'x.md' -Content @' +# X + + +same content + +'@ + $regions = @([pscustomobject]@{ Document = 'x.md'; Id = 'units'; Content = 'same content' }) + $findings = Test-ProjectionStale -Regions $regions -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } + + It 'S7.9: does not fire when the only difference is CRLF against LF' -Tag 'NearMiss','ProjectionStale' { + New-TreeFile -RelativePath 'x.md' -Content "# X`r`n`r`n`r`nline one`r`nline two`r`n`r`n" + $regions = @([pscustomobject]@{ Document = 'x.md'; Id = 'units'; Content = "line one`nline two" }) + $findings = Test-ProjectionStale -Regions $regions -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } + + It 'skips a region with no Document (the agent projection - no tree region to compare against)' -Tag 'NearMiss','ProjectionStale' { + $regions = @([pscustomobject]@{ Document = $null; Id = 'agent'; Content = 'anything' }) + $findings = Test-ProjectionStale -Regions $regions -RepoPath $TestDrive + $findings.Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: the tracker classes (S5.11)' { + + It 'S5.11: gh unavailable yields TrackerUnavailable, names WorkStateDivergence as not compared, and MirrorStale still runs' { + Mock -CommandName Test-TrackerAvailable -MockWith { $false } + + $ref = New-Record -Id 'work/42' -Kind 'WorkRef' -Scalars @{ Issue = '42'; State = 'open'; MirroredAt = 'deadbeef' } + $result = Test-TrackerClasses -Records @($ref) -RepoPath $TestDrive -Repository 'x/y' + + ($result.CouldNotEvaluate | Where-Object { $_.Reason -eq 'TrackerUnavailable' }).Count | Should -Be 1 + $result.CouldNotEvaluate[0].Detail | Should -Match 'WorkStateDivergence not compared' + } + + It 'MirrorStale fires when a WorkRef''s MirroredAt is not the current commit' { + Mock -CommandName Test-TrackerAvailable -MockWith { $false } + Mock -CommandName Get-CurrentCommitSha -MockWith { 'currentsha' } + + $ref = New-Record -Id 'work/42' -Kind 'WorkRef' -Scalars @{ Issue = '42'; MirroredAt = 'stalesha' } + $result = Test-TrackerClasses -Records @($ref) -RepoPath $TestDrive -Repository 'x/y' + + (@($result.Reported | Where-Object { $_.Class -eq 'MirrorStale' })).Count | Should -Be 1 + } + + It 'MirrorStale does not fire when MirroredAt matches the current commit' { + Mock -CommandName Test-TrackerAvailable -MockWith { $false } + Mock -CommandName Get-CurrentCommitSha -MockWith { 'currentsha' } + Mock -CommandName Test-CommitIsAncestor -MockWith { 'Ancestor' } + + $ref = New-Record -Id 'work/42' -Kind 'WorkRef' -Scalars @{ Issue = '42'; MirroredAt = 'currentsha' } + $result = Test-TrackerClasses -Records @($ref) -RepoPath $TestDrive -Repository 'x/y' + + (@($result.Reported | Where-Object { $_.Class -eq 'MirrorStale' })).Count | Should -Be 0 + } + + It 'PinAncestry fires when MirroredAt is not an ancestor of HEAD, and does not need gh' { + Mock -CommandName Test-TrackerAvailable -MockWith { $false } + Mock -CommandName Get-CurrentCommitSha -MockWith { 'currentsha' } + Mock -CommandName Test-CommitIsAncestor -MockWith { 'NotAncestor' } + + $ref = New-Record -Id 'work/42' -Kind 'WorkRef' -Scalars @{ Issue = '42'; MirroredAt = 'orphaned' } + $result = Test-TrackerClasses -Records @($ref) -RepoPath $TestDrive -Repository 'x/y' + + (@($result.Reported | Where-Object { $_.Class -eq 'PinAncestry' })).Count | Should -Be 1 + } + + It 'ShallowCheckout is could-not-evaluate, and never a pass, when ancestry cannot be resolved' { + Mock -CommandName Test-TrackerAvailable -MockWith { $false } + Mock -CommandName Get-CurrentCommitSha -MockWith { 'currentsha' } + Mock -CommandName Test-CommitIsAncestor -MockWith { 'Unresolvable' } + + $ref = New-Record -Id 'work/42' -Kind 'WorkRef' -Scalars @{ Issue = '42'; MirroredAt = 'orphaned' } + $result = Test-TrackerClasses -Records @($ref) -RepoPath $TestDrive -Repository 'x/y' + + ($result.CouldNotEvaluate | Where-Object { $_.Reason -eq 'ShallowCheckout' }).Count | Should -Be 1 + (@($result.Reported | Where-Object { $_.Class -eq 'PinAncestry' })).Count | Should -Be 0 + } + + It 'no WorkRef records: every tracker class runs to completion with nothing to report' { + Mock -CommandName Test-TrackerAvailable -MockWith { $true } + $result = Test-TrackerClasses -Records @() -RepoPath $TestDrive -Repository 'x/y' + $result.Reported.Count | Should -Be 0 + $result.CouldNotEvaluate.Count | Should -Be 0 + } +} + +Describe 'Test-DesignState: end-to-end (S5.2, S5.3, S5.4, S5.9)' { + + BeforeEach { + Get-ChildItem $TestDrive -ErrorAction SilentlyContinue -Recurse -File | + Remove-Item -Force -ErrorAction SilentlyContinue + New-TreeFile -RelativePath 'design/20-contract.md' -Content $script:MinimalContract + New-TreeFile -RelativePath 'design/90-decisions.md' -Content "# Decisions`n" + } + + It 'S5.4: an absent design/state/ yields StateSetAbsent, exit 2, and zero findings - never clean' { + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + + $result.ExitCode | Should -Be 2 + $result.Findings.Count | Should -Be 0 + $result.Reported.Count | Should -Be 0 + (@($result.CouldNotEvaluate | Where-Object { $_.Reason -eq 'StateSetAbsent' })).Count | Should -Be 1 + } + + It 'S5.2: all three lists are always present, even when empty (checked on the absent-state-set path)' { + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + ($null -eq $result.Findings) | Should -BeFalse + ($null -eq $result.Reported) | Should -BeFalse + ($null -eq $result.CouldNotEvaluate) | Should -BeFalse + } + + It 'S5.3: exit code is 2 (could-not-evaluate) even when a blocking finding also exists, in a run with records' { + New-StateFile -RelativePath 'units/command/a.md' -Content @' +# unit/command/a +Kind: command +Status: active +Binds: I999 +'@ + # ProjectorFailed always fires today (no projector exists), guaranteeing a could-not-evaluate + # alongside the UnresolvedId blocking finding this record also produces. + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + + (@($result.Findings | Where-Object { $_.Class -eq 'UnresolvedId' })).Count | Should -Be 1 + $result.CouldNotEvaluate.Count | Should -BeGreaterThan 0 + $result.ExitCode | Should -Be 2 + } + + It 'S5.9: git status --short is empty after a run that found blocking divergences' { + Push-Location $TestDrive + try { + & git init --quiet 2>$null + & git config user.email 'test@example.com' 2>$null + & git config user.name 'Test' 2>$null + & git add -A 2>$null + & git commit --quiet -m 'seed' 2>$null + + New-StateFile -RelativePath 'units/command/a.md' -Content @' +# unit/command/a +Kind: command +Status: active +Binds: I999 +'@ + $before = & git status --short + $null = Invoke-DesignStateCheck -RepoPath $TestDrive + $after = & git status --short + + # Only the new untracked state file (created above, outside the checker's own run) + # should differ - the checker itself must not have written anything. + $after | Should -Be $before + } finally { + Pop-Location + } + } + + It 'S5.8/I21: exit 2 still stands during a freeze, even with every blocking class downgraded to reported' { + New-TreeFile -RelativePath 'design/FROZEN.md' -Content @' +# design/ is frozen + +Frozen at: abc1234, 2026-08-19 +Frozen because: escaping the generative loop +Lifts when: tier one is code-complete +'@ + New-StateFile -RelativePath 'units/command/a.md' -Content @' +# unit/command/a +Kind: command +Status: active +Binds: I999 +'@ + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + + $result.Findings.Count | Should -Be 0 + (@($result.Reported | Where-Object { $_.Class -eq 'UnresolvedId' })).Count | Should -Be 1 + $result.ExitCode | Should -Be 2 + $result.DowngradedCount | Should -BeGreaterThan 0 + } +} + +Describe 'Test-DesignState against this repository''s own tree' { + + BeforeAll { + $script:RepoRoot = Split-Path $PSScriptRoot -Parent + $script:StatusBefore = & git -C $script:RepoRoot status --short + $script:RealResult = Invoke-DesignStateCheck -RepoPath $script:RepoRoot + } + + It 'S5.9/I18: git status is unchanged by a real run against this repository' { + $after = & git -C $script:RepoRoot status --short + $after | Should -Be $script:StatusBefore + } + + It 'S5.1: this repository''s design/20-contract.md declares exactly the same class ids as the script' { + (@($script:RealResult.Findings | Where-Object { $_.Class -eq 'ClassListDisagreement' })).Count | Should -Be 0 + } + + It 'S5.6: the real run names a largest closure, its unit, and its largest contributor' { + $script:RealResult.LargestClosure | Should -Not -BeNullOrEmpty + $script:RealResult.LargestClosure.Unit | Should -Not -BeNullOrEmpty + $script:RealResult.LargestClosure.LargestContributor | Should -Not -BeNullOrEmpty + } + + It 'S5.12: neither S4.6 closure (unit/command/track, unit/document/agents-md) exceeds the 16,384-byte ceiling' { + $graph = Read-DesignStateGraph -Path $script:RepoRoot + $byId = @{} + foreach ($r in $graph.Records) { $byId[$r.Id] = $r } + $result = Test-ClosureBudget -Records $graph.Records -ById $byId -RepoPath $script:RepoRoot + (@($result.Findings | Where-Object { $_.Subject -eq 'unit/command/track' })).Count | Should -Be 0 + (@($result.Findings | Where-Object { $_.Subject -eq 'unit/document/agents-md' })).Count | Should -Be 0 + } + + It 'S12.5: the check exits 0 against this repository, and names the largest closure and its size' { + # Replaces S5's 'never clean against this repository', whose stated reason - that most + # commands, scripts and documents had no unit record - stopped being true at S8 and S9. + # It kept passing on a divergence it was never written to describe, which is the shape + # AGENTS.md (Verification) calls a test that guards nothing. + $failing = @($script:RealResult.Findings | ForEach-Object { "[$($_.Class)] $($_.Subject): $($_.Detail)" }) + $unevaluated = @($script:RealResult.CouldNotEvaluate | ForEach-Object { "[$($_.Reason)] $($_.Detail)" }) + $script:RealResult.ExitCode | Should -Be 0 -Because "findings: $($failing -join ' | '); could not evaluate: $($unevaluated -join ' | ')" + $script:RealResult.LargestClosure.Unit | Should -Not -BeNullOrEmpty + $script:RealResult.LargestClosure.Bytes | Should -BeGreaterThan 0 + } + + It 'S7.9: the projector runs against this repository and ProjectionStale does not fire - the committed regions match their regeneration' { + $script:RealResult.CouldNotEvaluate | Where-Object { $_.Reason -eq 'ProjectorFailed' } | Should -BeNullOrEmpty + (@($script:RealResult.Findings | Where-Object { $_.Class -eq 'ProjectionStale' })).Count | Should -Be 0 + } + + It 'S16.1/S16.2: this repository has one Contract record per design/20-contract.md Public-surface entry, and OwnerMismatch reports none' { + # S14 wrote tools/Update-WorkMirror.ps1 and contract/update-workmirror with it, so the + # S16.6 exclusion (a Declaration pointing at an absent file) no longer applies - the + # count grew from 8 to 9 with it. + $graph = Read-DesignStateGraph -Path $script:RepoRoot + $contracts = @($graph.Records | Where-Object { $_.Kind -eq 'Contract' }) + $contracts.Count | Should -Be 9 + (@($script:RealResult.Findings | Where-Object { $_.Class -eq 'OwnerMismatch' })).Count | Should -Be 0 + } + + It 'S16.5: design/state-index.md''s consumers region lists real consumers, not the empty-set placeholder' { + $text = Get-Content -LiteralPath (Join-Path $script:RepoRoot 'design/state-index.md') -Raw + $text | Should -Not -Match '_\(no contract records yet\)_' + $text | Should -Match 'unit/command/pr' + } + + It 'S17.2: every invariant row in the real Invariants section sits inside the single invariants region, none below it' { + $contractPath = Join-Path $script:RepoRoot 'design/20-contract.md' + $text = Get-Content -LiteralPath $contractPath -Raw + $start = $text.IndexOf("`n## Invariants") + $rest = $text.Substring($start + 1) + $end = $rest.IndexOf("`n## ", 1) + $section = if ($end -lt 0) { $rest } else { $rest.Substring(0, $end) } + + $regionStart = $section.IndexOf('') + $regionEnd = $section.IndexOf('') + $regionStart | Should -BeGreaterThan -1 + $regionEnd | Should -BeGreaterThan $regionStart + + $before = $section.Substring(0, $regionStart) + $after = $section.Substring($regionEnd + ''.Length) + $rowPattern = '(?m)^\|\s*\*\*I\d+\*\*\s*\|' + ([regex]::Matches($before, $rowPattern)).Count | Should -Be 0 + ([regex]::Matches($after, $rowPattern)).Count | Should -Be 0 + + $graph = Read-DesignStateGraph -Path $script:RepoRoot + # Active only: a retired invariant leaves the rendered table, which is the invariant unit + # set rather than a history (design/20-contract.md, "The state set", I30). + $recordedCount = (@($graph.Records | Where-Object { $_.Kind -eq 'Invariant' -and $_.Scalars['Status'] -eq 'active' })).Count + $parsed = Get-ContractInvariantIds -ContractPath $contractPath + $parsed.Ids.Count | Should -Be $recordedCount + } + + It 'S18.6: EnforcementUnevidenced rejects this repository''s own superseded decision once its SupersededBy line is removed, and clears once it is restored' { + $supersededPath = Join-Path $script:RepoRoot 'design/state/decisions/2026-08-03-ticking-checkbox-is-the-users.md' + $original = Get-Content -LiteralPath $supersededPath -Raw + $original | Should -Match '(?m)^SupersededBy:' + try { + $stripped = $original -replace '(?m)^SupersededBy:.*\r?\n', '' + Set-Content -LiteralPath $supersededPath -Value $stripped -Encoding utf8NoBOM -NoNewline + + $strippedResult = Invoke-DesignStateCheck -RepoPath $script:RepoRoot + $strippedResult.ExitCode | Should -Be 1 + $hit = @($strippedResult.Findings | Where-Object { $_.Class -eq 'EnforcementUnevidenced' -and $_.Subject -eq 'decision/2026-08-03-ticking-checkbox-is-the-users' }) + $hit.Count | Should -Be 1 + $hit[0].Detail | Should -Match 'SupersededBy' + } finally { + Set-Content -LiteralPath $supersededPath -Value $original -Encoding utf8NoBOM -NoNewline + } + + $restoredResult = Invoke-DesignStateCheck -RepoPath $script:RepoRoot + (@($restoredResult.Findings | Where-Object { $_.Class -eq 'EnforcementUnevidenced' })).Count | Should -Be 0 + $restoredResult.ExitCode | Should -Be 0 + (& git -C $script:RepoRoot status --short) | Should -Be $script:StatusBefore + } +} + +# ================================================================================================= +# S12. The gate runs in CI, and the evidence that it constrains anything is on the record. +# ================================================================================================= + +Describe 'S12.2: every blocking class has fired on a real divergence and held on a near-miss' { + + BeforeAll { + # The coverage matrix is read out of this file's own source with the PowerShell parser - + # the same [Parser]::ParseFile the verify workflow's parse-check step uses - rather than + # from a list kept by hand beside it. A hand-kept list is a second copy of the tag + # inventory (AGENTS.md, Single ownership) and goes stale the first time a test is renamed + # or deleted, which is the one thing this census exists to notice. + $script:CensusPath = Join-Path $PSScriptRoot 'Test-DesignState.Tests.ps1' + $censusAst = [System.Management.Automation.Language.Parser]::ParseFile($script:CensusPath, [ref]$null, [ref]$null) + + $itCalls = $censusAst.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'It' + }, $true) + + $script:Fires = @{} + $script:NearMiss = @{} + $script:TaggedClasses = [System.Collections.Generic.SortedSet[string]]::new() + + foreach ($call in $itCalls) { + $elements = $call.CommandElements + for ($i = 0; $i -lt $elements.Count; $i++) { + $element = $elements[$i] + if ($element -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue } + if ($element.ParameterName -ne 'Tag') { continue } + + $argument = if ($element.Argument) { $element.Argument } elseif ($i + 1 -lt $elements.Count) { $elements[$i + 1] } else { $null } + $values = @() + if ($argument -is [System.Management.Automation.Language.ArrayLiteralAst]) { + $values = @($argument.Elements | ForEach-Object { $_.Value }) + } elseif ($argument) { + $values = @($argument.Value) + } + + $directions = @($values | Where-Object { $_ -in 'Fires', 'NearMiss' }) + $classes = @($values | Where-Object { $_ -notin 'Fires', 'NearMiss' }) + foreach ($class in $classes) { [void]$script:TaggedClasses.Add($class) } + foreach ($direction in $directions) { + $bucket = if ($direction -eq 'Fires') { $script:Fires } else { $script:NearMiss } + foreach ($class in $classes) { + if (-not $bucket.ContainsKey($class)) { $bucket[$class] = 0 } + $bucket[$class]++ + } + } + } + } + + $script:FiresTotal = @($script:Fires.Values | Measure-Object -Sum).Sum + $script:NearMissTotal = @($script:NearMiss.Values | Measure-Object -Sum).Sum + + # S12.2's "the run states both counts". Written to the host so the numbers land in the CI + # log on a green run too - a count only visible in a failure message is a count nobody + # reads until it is already too late to be evidence of anything. + Write-Host "S12.2 coverage over $($script:BlockingClasses.Count) blocking classes: $($script:FiresTotal) fires test(s), $($script:NearMissTotal) near-miss test(s)" + foreach ($class in $script:BlockingClasses) { + $f = if ($script:Fires.ContainsKey($class)) { $script:Fires[$class] } else { 0 } + $n = if ($script:NearMiss.ContainsKey($class)) { $script:NearMiss[$class] } else { 0 } + Write-Host (" {0,-24} fires {1} near-miss {2}" -f $class, $f, $n) + } + } + + It 'S12.2: every blocking class has at least one test that constructs a real divergence and confirms it fires' { + $missing = @($script:BlockingClasses | Where-Object { -not $script:Fires.ContainsKey($_) }) + $missing -join ', ' | Should -BeNullOrEmpty -Because 'each of these blocking classes has no test tagged Fires, so nothing has ever seen it reject a divergence' + } + + It 'S12.2: every blocking class has at least one test that constructs a near-miss and confirms it does not fire' { + $missing = @($script:BlockingClasses | Where-Object { -not $script:NearMiss.ContainsKey($_) }) + $missing -join ', ' | Should -BeNullOrEmpty -Because 'each of these blocking classes has no test tagged NearMiss, so nothing bounds what it rejects' + } + + It 'S12.2: every class tagged in this file is a blocking class the checker actually declares' { + $unknown = @($script:TaggedClasses | Where-Object { $_ -notin $script:BlockingClasses }) + $unknown -join ', ' | Should -BeNullOrEmpty -Because 'a tag naming no real class silently under-counts the class it was meant to cover' + } + + It 'S12.2: both counts are stated, and neither is zero' { + $script:FiresTotal | Should -BeGreaterThan 0 + $script:NearMissTotal | Should -BeGreaterThan 0 + $script:FiresTotal | Should -BeGreaterOrEqual $script:BlockingClasses.Count + $script:NearMissTotal | Should -BeGreaterOrEqual $script:BlockingClasses.Count + } +} + +Describe 'S12.3: a freeze permits known staleness, not a checker that could not run' { + + BeforeEach { + Get-ChildItem $TestDrive -ErrorAction SilentlyContinue -Recurse -File | + Remove-Item -Force -ErrorAction SilentlyContinue + + New-TreeFile -RelativePath 'design/20-contract.md' -Content $script:MinimalContract + New-TreeFile -RelativePath 'design/90-decisions.md' -Content "# Decisions`n" + New-TreeFile -RelativePath 'design/FROZEN.md' -Content @' +# design/ is frozen + +Frozen at: abc1234, 2026-08-19 +Frozen because: escaping the generative loop +Lifts when: tier one is code-complete +'@ + # A projector that runs and renders nothing. Without it ProjectorFailed fires, and the + # run would exit 2 for a reason that has nothing to do with the freeze - which is exactly + # the confusion the second test below exists to keep apart from the first. + New-TreeFile -RelativePath 'tools/Update-DesignProjection.ps1' -Content 'param([string]$Path,[switch]$DryRun) exit 0' + + New-StateFile -RelativePath 'units/command/a.md' -Content @' +# unit/command/a +Kind: command +Status: active +Binds: I999 +'@ + } + + It 'S12.3: with design/FROZEN.md present, no blocking class fails the build' { + Mock Test-TrackerAvailable { $true } + + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + + $result.CouldNotEvaluate.Count | Should -Be 0 -Because 'this fixture is deliberately free of could-not-evaluate, so the exit code below is the freeze''s doing and nothing else''s' + $result.Findings.Count | Should -Be 0 + $result.DowngradedCount | Should -BeGreaterThan 0 + (@($result.Reported | Where-Object { $_.Class -eq 'UnresolvedId' })).Count | Should -Be 1 + $result.ExitCode | Should -Be 0 + } + + It 'S12.3: a could-not-evaluate still exits 2 during a freeze' { + Mock Test-TrackerAvailable { $false } + + $result = Invoke-DesignStateCheck -RepoPath $TestDrive + + $result.Findings.Count | Should -Be 0 + $result.DowngradedCount | Should -BeGreaterThan 0 + (@($result.CouldNotEvaluate | Where-Object { $_.Reason -eq 'TrackerUnavailable' })).Count | Should -Be 1 + $result.ExitCode | Should -Be 2 -Because 'writing one file must never be a way to switch a broken checker off (I21)' + } +} + +Describe 'S12.6: a checkout with design/state/ removed' { + + BeforeAll { + # A real copy of this repository, minus design/state/ - the shape every installed target + # has by construction, since nothing under the kit's own design/ is on INSTALL.md's + # artifact list. Built from the tree rather than from a fixture so that "the state set is + # absent" is asserted against a checkout that is otherwise complete. + $script:S12RepoRoot = Split-Path $PSScriptRoot -Parent + $script:S12Checkout = Join-Path $TestDrive 'checkout-without-state' + New-Item -ItemType Directory -Path $script:S12Checkout -Force | Out-Null + Get-ChildItem -LiteralPath $script:S12RepoRoot -Force | + Where-Object { $_.Name -ne '.git' } | + Copy-Item -Destination $script:S12Checkout -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $script:S12Checkout 'design/state') -Recurse -Force + + $script:S12Result = Invoke-DesignStateCheck -RepoPath $script:S12Checkout + } + + It 'S12.6: exits 2 reporting StateSetAbsent' { + (@($script:S12Result.CouldNotEvaluate | Where-Object { $_.Reason -eq 'StateSetAbsent' })).Count | Should -Be 1 + $script:S12Result.ExitCode | Should -Be 2 + } + + It 'S12.6: and never 0' { + $script:S12Result.ExitCode | Should -Not -Be 0 + $script:S12Result.Findings.Count | Should -Be 0 -Because 'absence of a finding is not a finding of absence (I8''s shape, I19)' + } +} diff --git a/tools/Test-DesignState.ps1 b/tools/Test-DesignState.ps1 new file mode 100644 index 0000000..20ecf74 --- /dev/null +++ b/tools/Test-DesignState.ps1 @@ -0,0 +1,1274 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + The design-state divergence checker: validator, projection checker, budget meter, freeze + gate, and the three-list report (design/20-contract.md § tools/Test-DesignState.ps1). + +.DESCRIPTION + Reads design/state/ via Read-DesignState.ps1, then runs every class in the closed list + design/20-contract.md § "The divergence classes" declares, and only those - a class not on + that list does not exist here, and ClassListDisagreement is what makes a drift between the + two documents visible rather than silent. + + Emits three lists, always all three: Findings (blocking), Reported (never blocking), and + CouldNotEvaluate. An omitted empty list would read as an absent category rather than an + empty one (I12's shape, one level up). Exit codes: 0 clean, 1 findings, 2 could not + evaluate - 2 always takes precedence over 1 (I20). + + Never clean on an absent or empty state set (I19) - that is the expected shape in every + installed target, where design/state/ does not exist by design (INSTALL.md phase 1 never + ships the kit's own design/). + + Regenerates before comparing, by invoking the projector with -DryRun if it exists. + tools/Update-DesignProjection.ps1 (S7) exists and is invoked this way, so ProjectionStale + is computed on every run today. Where the projector is absent or exits non-zero, that run + instead reports ProjectorFailed and names ProjectionStale as uncomputed rather than clean + (S5.10): a contracted case, not a gap. + + Writes nothing, ever (I18): not design/, not a record, not an issue, not git. + +.PARAMETER Path + Repository root. Defaults to the current directory. Has no -Fix, no -Force, and resolves + nothing - the checker only ever establishes that something disagrees. + +.PARAMETER Repository + owner/repo, for the tracker classes only (MirrorStale needs no network; WorkStateDivergence + and PinAncestry do). Defaults to the current git remote via gh's own resolution. + +.PARAMETER Quiet + Suppresses the human-readable report only. The result object is always emitted. + +.EXAMPLE + pwsh ./tools/Test-DesignState.ps1 +#> +[CmdletBinding()] +param( + [string] $Path = (Get-Location).Path, + [string] $Repository, + [switch] $Quiet +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Read-DesignState.ps1's relative-path substring math assumes $Path is already absolute (a +# pre-existing defect outside this slice's Touches list, not fixed here - AGENTS.md, "You find +# a defect outside this slice. Note it, do not fix it."). Resolving here, once, keeps every +# call into it well-formed without touching that file. +$Path = (Resolve-Path -LiteralPath $Path).Path + +# See Test-DesignDrift.ps1's identical assignment: `git merge-base --is-ancestor` and +# `gh`/the projector all return non-zero to mean "the answer is no", not "something broke", and +# PowerShell 7.3+ would otherwise turn that into a terminating error under -Stop. +$PSNativeCommandUseErrorActionPreference = $false + +# --------------------------------------------------------------------------------------------- +# The closed class list (design/20-contract.md § "The divergence classes"). This is the +# checker's own declaration; ClassListDisagreement compares it against the contract document's +# copy, which is the one restatement in the system that cannot be checked any other way +# (design/10-design.md § Module boundaries). +# --------------------------------------------------------------------------------------------- +$script:BlockingClasses = @( + 'UnresolvedId', 'AnchorMissing', 'OwnerMismatch', 'UnrecordedArtifact', 'ProjectionStale', + 'RegionMalformed', 'IdCollision', 'DecisionAnchorAmbiguous', 'LogEntryUnrecorded', + 'EnforcementUnevidenced', 'ClosureOverBudget', 'ClassListDisagreement', 'GlobDisagreement' +) +$script:ReportedClasses = @( + 'MirrorStale', 'WorkStateDivergence', 'PinAncestry', 'SemanticDisagreement' +) +$script:CouldNotEvaluateClasses = @( + 'StateSetAbsent', 'RecordUnparseable', 'TrackerUnavailable', 'ShallowCheckout', + 'ProjectorFailed', 'ContractListUnreadable' +) + +$script:ClosureBudgetBytes = 16384 + +function New-DesignFinding { + <# design/20-contract.md § "What the checker emits" - the scaffolded factory, unchanged. #> + param( + [Parameter(Mandatory)][string] $Class, + [Parameter(Mandatory)][string] $Subject, + [Parameter(Mandatory)][string] $Detail, + [Parameter(Mandatory)][bool] $Blocking + ) + [pscustomobject]@{ + Class = $Class + Subject = $Subject + Detail = $Detail + Blocking = $Blocking + } +} + +function New-CouldNotEvaluate { + param( + [Parameter(Mandatory)][string] $Reason, + [string] $Detail = '' + ) + [pscustomobject]@{ Reason = $Reason; Detail = $Detail } +} + +function New-DesignStateResult { + param( + [object[]] $Findings, + [object[]] $Reported, + [object[]] $CouldNotEvaluate, + [Parameter(Mandatory)][int] $ExitCode, + [pscustomobject] $LargestClosure, + [string[]] $ReportLines, + [int] $DowngradedCount = 0 + ) + [pscustomobject]@{ + Findings = @($Findings) + Reported = @($Reported) + CouldNotEvaluate = @($CouldNotEvaluate) + ExitCode = $ExitCode + LargestClosure = $LargestClosure + ReportLines = @($ReportLines) + DowngradedCount = $DowngradedCount + } +} + +# --------------------------------------------------------------------------------------------- +# The reader. Dot-sourced at script scope - not inside a function, which would confine its +# functions (Read-DesignStateGraph, Get-DesignPathInfo, the New-DesignState* factories) to that +# function's own local scope - so every function below can reuse them rather than duplicating +# them (AGENTS.md, Single ownership). The guard inside Read-DesignState.ps1 means dot-sourcing +# it here never runs its own top-level invocation. +# --------------------------------------------------------------------------------------------- +$script:ReaderPath = Join-Path $PSScriptRoot 'Read-DesignState.ps1' +if (-not (Test-Path -LiteralPath $script:ReaderPath)) { + throw "tools/Read-DesignState.ps1 not found beside tools/Test-DesignState.ps1 at $script:ReaderPath" +} +. $script:ReaderPath -Path $Path + +# --------------------------------------------------------------------------------------------- +# design/20-contract.md's own class list, parsed from the document so ClassListDisagreement has +# something independent to compare $script:BlockingClasses etc. against. The "Could not +# evaluate" table's header row names its own row-type, `DesignStateFailure`, in the same +# backticked-first-column shape every real class row uses - it is excluded by name because it +# is a type name, never a class id. +# --------------------------------------------------------------------------------------------- +function Get-ContractClassIds { + param([Parameter(Mandatory)][string] $ContractPath) + + if (-not (Test-Path -LiteralPath $ContractPath)) { + return [pscustomobject]@{ Ids = $null; Failure = 'ContractPathMissing' } + } + + $text = Get-Content -LiteralPath $ContractPath -Raw + if ($null -eq $text) { $text = '' } + $start = $text.IndexOf('### The divergence classes') + $end = $text.IndexOf('### The freeze') + if ($start -lt 0 -or $end -lt 0 -or $end -le $start) { + return [pscustomobject]@{ Ids = $null; Failure = 'SectionNotFound' } + } + $section = $text.Substring($start, $end - $start) + + $blockingEnd = $section.IndexOf('**Reported, never blocking.**') + $reportedEnd = $section.IndexOf('**Could not evaluate.**') + if ($blockingEnd -lt 0 -or $reportedEnd -lt 0 -or $reportedEnd -le $blockingEnd) { + return [pscustomobject]@{ Ids = $null; Failure = 'SubsectionNotFound' } + } + + $blockingText = $section.Substring(0, $blockingEnd) + $reportedText = $section.Substring($blockingEnd, $reportedEnd - $blockingEnd) + $cneText = $section.Substring($reportedEnd) + + function Get-RowIds { + param([string] $Chunk) + $ids = [System.Collections.Generic.List[string]]::new() + foreach ($line in ($Chunk -split "`n")) { + if ($line -match '^\|\s*`([A-Za-z]+)`\s*\|') { + $id = $Matches[1] + if ($id -eq 'DesignStateFailure') { continue } + $ids.Add($id) + } + } + ,@($ids | Sort-Object -Unique) + } + + [pscustomobject]@{ + Ids = [pscustomobject]@{ + Blocking = Get-RowIds -Chunk $blockingText + Reported = Get-RowIds -Chunk $reportedText + CouldNotEvaluate = Get-RowIds -Chunk $cneText + } + Failure = $null + } +} + +<# + design/20-contract.md's own Invariants table, parsed so UnrecordedArtifact's invariant half + has a set to take a difference against. The section is the invariant unit set per + § "Artifacts of a unit kind"; a citation scan is deliberately not what defines membership. + An unreadable section is ContractListUnreadable - the caller reports the invariant half as + uncomputed rather than as an empty difference, which would be a clean run over a table + nobody could read. +#> +function Get-ContractInvariantIds { + param([Parameter(Mandatory)][string] $ContractPath) + + if (-not (Test-Path -LiteralPath $ContractPath)) { + return [pscustomobject]@{ Ids = $null; Failure = 'ContractPathMissing' } + } + + $text = Get-Content -LiteralPath $ContractPath -Raw + if ($null -eq $text) { $text = '' } + $start = $text.IndexOf("`n## Invariants") + if ($start -lt 0) { + return [pscustomobject]@{ Ids = $null; Failure = 'InvariantsSectionNotFound' } + } + $rest = $text.Substring($start + 1) + $end = $rest.IndexOf("`n## ", 1) + $section = if ($end -lt 0) { $rest } else { $rest.Substring(0, $end) } + + $ids = [System.Collections.Generic.List[string]]::new() + foreach ($line in ($section -split "`n")) { + if ($line -match '^\|\s*\*\*(I\d+)\*\*\s*\|') { $ids.Add($Matches[1]) } + } + + + [pscustomobject]@{ Ids = @($ids | Sort-Object -Unique); Failure = $null } +} + +function Test-ClassListAgreement { + param([Parameter(Mandatory)][string] $ContractPath) + + $parsed = Get-ContractClassIds -ContractPath $ContractPath + if ($parsed.Failure) { + return [pscustomobject]@{ + CouldNotEvaluate = (New-CouldNotEvaluate -Reason 'ContractListUnreadable' -Detail "$($parsed.Failure): $ContractPath") + Finding = $null + } + } + + $declared = [pscustomobject]@{ + Blocking = @($script:BlockingClasses | Sort-Object -Unique) + Reported = @($script:ReportedClasses | Sort-Object -Unique) + CouldNotEvaluate = @($script:CouldNotEvaluateClasses | Sort-Object -Unique) + } + $doc = $parsed.Ids + + $onlyInScript = [System.Collections.Generic.List[string]]::new() + $onlyInDoc = [System.Collections.Generic.List[string]]::new() + + foreach ($tier in 'Blocking', 'Reported', 'CouldNotEvaluate') { + $s = @($declared.$tier) + $d = @($doc.$tier) + foreach ($id in $s) { if ($id -notin $d) { $onlyInScript.Add("$tier`:$id") } } + foreach ($id in $d) { if ($id -notin $s) { $onlyInDoc.Add("$tier`:$id") } } + } + + if ($onlyInScript.Count -eq 0 -and $onlyInDoc.Count -eq 0) { + return [pscustomobject]@{ CouldNotEvaluate = $null; Finding = $null } + } + + $detail = "declared-only: [$($onlyInScript -join ', ')]; contract-only: [$($onlyInDoc -join ', ')]" + [pscustomobject]@{ + CouldNotEvaluate = $null + Finding = (New-DesignFinding -Class 'ClassListDisagreement' -Subject 'tools/Test-DesignState.ps1' -Detail $detail -Blocking $true) + } +} + +# --------------------------------------------------------------------------------------------- +# Id resolution. Every list/scalar field whose value is documented as an id in +# design/10-design.md § Data model, excluding Archival (excluded from closures, not from +# UnresolvedId - a live record naming a retired id is still resolvable and raises nothing) and +# excluding Work (documented as "issue numbers", not a design-state id) and Evidence +# (documented as "tree pointers", checked by AnchorMissing's sibling logic, not here). +# --------------------------------------------------------------------------------------------- +$script:IdListFields = @('Consumes', 'Exposes', 'Binds', 'Live', 'Archival', 'Questions') +$script:IdScalarFields = @('Owner', 'SupersededBy', 'AnsweredBy') + +function Test-UnresolvedId { + param([Parameter(Mandatory)][hashtable] $ById, [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($record in $Records) { + foreach ($field in $script:IdListFields) { + if (-not $record.Lists.ContainsKey($field)) { continue } + foreach ($id in $record.Lists[$field]) { + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if (-not $ById.ContainsKey($id)) { + $findings.Add((New-DesignFinding -Class 'UnresolvedId' -Subject $record.Id -Detail "$field names '$id', which has no record" -Blocking $true)) + } + } + } + foreach ($field in $script:IdScalarFields) { + if (-not $record.Scalars.ContainsKey($field)) { continue } + $id = $record.Scalars[$field] + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if (-not $ById.ContainsKey($id)) { + $findings.Add((New-DesignFinding -Class 'UnresolvedId' -Subject $record.Id -Detail "$field names '$id', which has no record" -Blocking $true)) + } + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# AnchorMissing. Every tree-pointer field an active record carries, not only a unit's Anchor: +# a Contract's Declaration and the Evidence list on a Unit or an Invariant are restatements of +# a tree path in exactly the same way (design/20-contract.md § The divergence classes). Three +# exemptions, each of which would otherwise block forever: a retired record (I30); an +# Invariant's Anchor, which is the invariant number and resolves by well-formedness and +# uniqueness rather than Test-Path; and a Contract Declaration of the literal `prose`, which is +# the field's documented second value for a Markdown command surface with nothing to point at. +# --------------------------------------------------------------------------------------------- +function Test-AnchorMissing { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, [Parameter(Mandatory)][string] $RepoPath) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($record in $Records) { + if ($record.Kind -notin @('Unit', 'Invariant', 'Contract')) { continue } + if ($record.Scalars['Status'] -ne 'active') { continue } + + $pointers = [System.Collections.Generic.List[object]]::new() + + if ($record.Kind -eq 'Unit') { + $anchor = $record.Scalars['Anchor'] + if (-not [string]::IsNullOrWhiteSpace($anchor)) { + $pointers.Add([pscustomobject]@{ Field = 'Anchor'; Value = $anchor }) + } + } + if ($record.Kind -eq 'Contract') { + $declaration = $record.Scalars['Declaration'] + if (-not [string]::IsNullOrWhiteSpace($declaration) -and $declaration -ne 'prose') { + $pointers.Add([pscustomobject]@{ Field = 'Declaration'; Value = $declaration }) + } + } + if ($record.Lists.ContainsKey('Evidence')) { + foreach ($entry in $record.Lists['Evidence']) { + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + $pointers.Add([pscustomobject]@{ Field = 'Evidence'; Value = $entry }) + } + } + + foreach ($pointer in $pointers) { + $full = Join-Path $RepoPath $pointer.Value + if (-not (Test-Path -LiteralPath $full)) { + $findings.Add((New-DesignFinding -Class 'AnchorMissing' -Subject $record.Id -Detail "$($pointer.Field) '$($pointer.Value)' does not exist in the tree" -Blocking $true)) + } + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# OwnerMismatch. A Contract record's Owner must be the unique active Unit whose Exposes names +# that contract id. +# --------------------------------------------------------------------------------------------- +function Test-OwnerMismatch { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + + $findings = [System.Collections.Generic.List[object]]::new() + $contracts = @($Records | Where-Object { $_.Kind -eq 'Contract' }) + if ($contracts.Count -eq 0) { return ,@() } + + $exposers = @{} + foreach ($record in $Records) { + if (-not $record.Lists.ContainsKey('Exposes')) { continue } + if ($record.Scalars['Status'] -ne 'active') { continue } + foreach ($contractId in $record.Lists['Exposes']) { + if ([string]::IsNullOrWhiteSpace($contractId)) { continue } + if (-not $exposers.ContainsKey($contractId)) { $exposers[$contractId] = [System.Collections.Generic.List[string]]::new() } + $exposers[$contractId].Add($record.Id) + } + } + + foreach ($contract in $contracts) { + $owner = $contract.Scalars['Owner'] + $who = @(if ($exposers.ContainsKey($contract.Id)) { @($exposers[$contract.Id]) } else { @() }) + $isUnique = ($who.Count -eq 1 -and $who[0] -eq $owner) + if (-not $isUnique) { + $whoText = if ($who.Count -eq 0) { '(nobody)' } else { $who -join ', ' } + $findings.Add((New-DesignFinding -Class 'OwnerMismatch' -Subject $contract.Id -Detail "Owner declared as '$owner'; units exposing it: $whoText" -Blocking $true)) + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# UnrecordedArtifact. design/20-contract.md § "Artifacts of a unit kind" - a glob with a named +# exclusion list, per kind, plus the invariant kind's citation-based set difference. +# --------------------------------------------------------------------------------------------- +function Get-DocumentGlobFiles { + param([Parameter(Mandatory)][string] $RepoPath) + + $set = [System.Collections.Generic.SortedSet[string]]::new() + $add = { + param($p) + if (Test-Path -LiteralPath $p) { + $rel = ([IO.Path]::GetRelativePath($RepoPath, $p)) -replace '\\', '/' + [void]$set.Add($rel) + } + } + + Get-ChildItem -LiteralPath (Join-Path $RepoPath 'design') -Filter '*.md' -File -ErrorAction SilentlyContinue | + ForEach-Object { & $add $_.FullName } + $templatesDesign = Join-Path $RepoPath 'templates/design' + if (Test-Path -LiteralPath $templatesDesign) { + Get-ChildItem -LiteralPath $templatesDesign -Filter '*.md' -File -ErrorAction SilentlyContinue | + ForEach-Object { & $add $_.FullName } + } + Get-ChildItem -LiteralPath $RepoPath -Filter '*.md' -File -ErrorAction SilentlyContinue | + ForEach-Object { & $add $_.FullName } + & $add (Join-Path $RepoPath '.claude/COMPANIONS.md') + $issueTemplates = Join-Path $RepoPath '.github/ISSUE_TEMPLATE' + if (Test-Path -LiteralPath $issueTemplates) { + Get-ChildItem -LiteralPath $issueTemplates -Filter '*.md' -File -ErrorAction SilentlyContinue | + ForEach-Object { & $add $_.FullName } + } + & $add (Join-Path $RepoPath 'codex/PROFILES.md') + + $excluded = @('design/FROZEN.md', 'CLAUDE.md') + ,@($set | Where-Object { $_ -notin $excluded }) +} + +function Get-CommandGlobFiles { + param([Parameter(Mandatory)][string] $RepoPath) + + $dir = Join-Path $RepoPath '.claude/commands' + if (-not (Test-Path -LiteralPath $dir)) { return ,@() } + ,@( + Get-ChildItem -LiteralPath $dir -Filter '*.md' -File | + Where-Object { $_.Name -notlike '*-local.md' } | + ForEach-Object { ([IO.Path]::GetRelativePath($RepoPath, $_.FullName)) -replace '\\', '/' } | + Sort-Object + ) +} + +function Get-ScriptGlobFiles { + param([Parameter(Mandatory)][string] $RepoPath) + + $dir = Join-Path $RepoPath 'tools' + if (-not (Test-Path -LiteralPath $dir)) { return ,@() } + ,@( + Get-ChildItem -LiteralPath $dir -Filter '*.ps1' -File | + Where-Object { $_.Name -notlike '*.Tests.ps1' } | + ForEach-Object { ([IO.Path]::GetRelativePath($RepoPath, $_.FullName)) -replace '\\', '/' } | + Sort-Object + ) +} + +<# + design/20-contract.md § "Artifacts of a unit kind"'s own glob table, parsed so + GlobDisagreement has something to expand and compare against the three Get-*GlobFiles + enumerations. The third parsed source this document carries, after the class list and + § Invariants; ContractListUnreadable covers all three. + + Both cells carry patterns and nothing else, which is what makes this parse a token scan + rather than a prose reader. The invariant row has no pattern in either cell and drops out + for that reason, not by being named here. +#> +function Get-ContractGlobPatterns { + param([Parameter(Mandatory)][string] $ContractPath) + + if (-not (Test-Path -LiteralPath $ContractPath)) { + return [pscustomobject]@{ Kinds = $null; Failure = 'ContractPathMissing' } + } + + $text = Get-Content -LiteralPath $ContractPath -Raw + if ($null -eq $text) { $text = '' } + $start = $text.IndexOf('| Kind | Glob | Excluded |') + if ($start -lt 0) { + return [pscustomobject]@{ Kinds = $null; Failure = 'GlobTableNotFound' } + } + $rest = $text.Substring($start) + $end = $rest.IndexOf("`n`n") + $table = if ($end -lt 0) { $rest } else { $rest.Substring(0, $end) } + + $kinds = @{} + foreach ($line in ($table -split "`n")) { + if ($line -notmatch '^\|\s*([a-z]+)\s*\|(.*)\|(.*)\|\s*$') { continue } + $kind = $Matches[1] + $globCell = $Matches[2] + $excludedCell = $Matches[3] + $globs = @([regex]::Matches($globCell, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value }) + if ($globs.Count -eq 0) { continue } + $kinds[$kind] = [pscustomobject]@{ + Glob = $globs + Excluded = @([regex]::Matches($excludedCell, '`([^`]+)`') | ForEach-Object { $_.Groups[1].Value }) + } + } + + if ($kinds.Count -eq 0) { + return [pscustomobject]@{ Kinds = $null; Failure = 'GlobTableHasNoPatterns' } + } + [pscustomobject]@{ Kinds = $kinds; Failure = $null } +} + +<# + Expands one parsed pattern against the checkout. A pattern is repository-relative and + wildcards only its final segment, so the directory half is literal and the file half is a + -Filter. This is deliberately not a general glob engine: the table's own rule is the whole + grammar, and anything outside it should fail to resolve rather than be guessed at. +#> +function Expand-ContractGlobPattern { + param( + [Parameter(Mandatory)][string] $RepoPath, + [Parameter(Mandatory)][string] $Pattern + ) + + $normalised = $Pattern -replace '\\', '/' + $dir = [IO.Path]::GetDirectoryName($normalised) -replace '\\', '/' + $leaf = [IO.Path]::GetFileName($normalised) + $searchRoot = if ([string]::IsNullOrEmpty($dir)) { $RepoPath } else { Join-Path $RepoPath $dir } + if (-not (Test-Path -LiteralPath $searchRoot)) { return ,@() } + + if ($leaf -notmatch '[*?]') { + $full = Join-Path $searchRoot $leaf + if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { return ,@() } + return ,@($normalised) + } + + ,@( + Get-ChildItem -LiteralPath $searchRoot -Filter $leaf -File -ErrorAction SilentlyContinue | + ForEach-Object { ([IO.Path]::GetRelativePath($RepoPath, $_.FullName)) -replace '\\', '/' } + ) +} + +<# + GlobDisagreement. design/20-contract.md § "The divergence classes" - the file set the + contract's patterns resolve to, against the set the checker's own enumeration returns, per + globbed kind and in both directions. + + File sets, never pattern text: an exclusion applied at the wrong level or a directory quietly + skipped diverges semantically while the tokens still match, and that is the case this class + exists for. The parsed patterns only ever compare - UnrecordedArtifact keeps reading the + Get-*GlobFiles enumerations - so a mis-parse can report a disagreement or report + ContractListUnreadable, and can never narrow the world being checked. +#> +function Test-GlobDisagreement { + param( + [Parameter(Mandatory)][string] $RepoPath, + [Parameter(Mandatory)][string] $ContractPath + ) + + $parsed = Get-ContractGlobPatterns -ContractPath $ContractPath + if ($parsed.Failure) { + return [pscustomobject]@{ + CouldNotEvaluate = (New-CouldNotEvaluate -Reason 'ContractListUnreadable' -Detail "$($parsed.Failure): $ContractPath; GlobDisagreement is uncomputed, not clean") + Findings = @() + } + } + + $enumerators = @{ + command = { Get-CommandGlobFiles -RepoPath $RepoPath } + script = { Get-ScriptGlobFiles -RepoPath $RepoPath } + document = { Get-DocumentGlobFiles -RepoPath $RepoPath } + } + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($kind in @($enumerators.Keys | Sort-Object)) { + if (-not $parsed.Kinds.ContainsKey($kind)) { + $findings.Add((New-DesignFinding -Class 'GlobDisagreement' -Subject $kind ` + -Detail "the checker enumerates the $kind kind and design/20-contract.md § Artifacts of a unit kind carries no patterns for it" -Blocking $true)) + continue + } + $spec = $parsed.Kinds[$kind] + + $resolved = [System.Collections.Generic.SortedSet[string]]::new() + foreach ($pattern in $spec.Glob) { + # Expand-ContractGlobPattern and the Get-*GlobFiles enumerations all emit `,@(...)`, + # a single object that *is* an array. Both sides are cast flat before comparing; + # without it the set difference compares arrays and reports every path as divergent. + [string[]] $hits = Expand-ContractGlobPattern -RepoPath $RepoPath -Pattern $pattern + foreach ($hit in $hits) { [void]$resolved.Add($hit) } + } + # An exclusion carrying a wildcard is matched against the basename; one without is a + # repository-relative path. That is the table's own stated grammar, and matching it + # exactly is the point - a matcher looser than the enumeration would report a + # disagreement of its own making rather than the one in the tree. + $contractSide = @($resolved | Where-Object { + $path = $_ + $leaf = [IO.Path]::GetFileName($path) + -not (@($spec.Excluded) | Where-Object { + if ($_ -match '[*?]') { $leaf -like $_ } else { $path -eq $_ } + }) + }) + + [string[]] $checkerSide = & $enumerators[$kind] + + [string[]] $onlyContract = @($contractSide | Where-Object { $_ -notin $checkerSide }) + [string[]] $onlyChecker = @($checkerSide | Where-Object { $_ -notin $contractSide }) + if ($onlyContract.Count -gt 0) { + $findings.Add((New-DesignFinding -Class 'GlobDisagreement' -Subject $kind ` + -Detail "the contract's patterns reach $($onlyContract.Count) path(s) the checker does not enumerate: $($onlyContract -join ', ')" -Blocking $true)) + } + if ($onlyChecker.Count -gt 0) { + $findings.Add((New-DesignFinding -Class 'GlobDisagreement' -Subject $kind ` + -Detail "the checker enumerates $($onlyChecker.Count) path(s) the contract's patterns do not reach: $($onlyChecker -join ', ')" -Blocking $true)) + } + } + + foreach ($kind in @($parsed.Kinds.Keys | Sort-Object)) { + if (-not $enumerators.ContainsKey($kind)) { + $findings.Add((New-DesignFinding -Class 'GlobDisagreement' -Subject $kind ` + -Detail "design/20-contract.md § Artifacts of a unit kind carries patterns for the $kind kind and the checker enumerates no such kind" -Blocking $true)) + } + } + + [pscustomobject]@{ CouldNotEvaluate = $null; Findings = @($findings) } +} + +function Test-UnrecordedArtifact { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, + [Parameter(Mandatory)][string] $RepoPath, + [AllowNull()][string[]] $InvariantIds + ) + + $findings = [System.Collections.Generic.List[object]]::new() + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' -and $_.Scalars['Status'] -eq 'active' }) + + $byUnitKind = @{ command = @(); script = @(); document = @() } + foreach ($u in $units) { + $k = $u.Scalars['Kind'] + if ($byUnitKind.ContainsKey($k)) { $byUnitKind[$k] += $u } + } + + $kindGlobs = @{ + command = (Get-CommandGlobFiles -RepoPath $RepoPath) + script = (Get-ScriptGlobFiles -RepoPath $RepoPath) + document = (Get-DocumentGlobFiles -RepoPath $RepoPath) + } + + foreach ($kind in 'command', 'script', 'document') { + $anchors = @($byUnitKind[$kind] | ForEach-Object { $_.Scalars['Anchor'] }) + $files = @($kindGlobs[$kind]) + + foreach ($file in $files) { + if ($file -notin $anchors) { + $findings.Add((New-DesignFinding -Class 'UnrecordedArtifact' -Subject $file -Detail "tree artifact of kind '$kind' has no active unit record naming it as Anchor" -Blocking $true)) + } + } + foreach ($u in $byUnitKind[$kind]) { + $anchor = $u.Scalars['Anchor'] + if ($anchor -notin $files) { + $findings.Add((New-DesignFinding -Class 'UnrecordedArtifact' -Subject $u.Id -Detail "Anchor '$anchor' is not matched by the '$kind' glob (or was excluded from it)" -Blocking $true)) + } + } + } + + # Invariant kind: "not a tree path" - the set is every I row in design/20-contract.md + # § Invariants (design/20-contract.md § "Artifacts of a unit kind"). $InvariantIds is $null + # when that section could not be read; the caller has already recorded ContractListUnreadable + # and this half stays uncomputed rather than reporting an empty difference as agreement. + if ($null -ne $InvariantIds) { + $declaredIds = @($InvariantIds) + $invariantRecords = @($Records | Where-Object { $_.Kind -eq 'Invariant' }) + $recordedIds = @($invariantRecords | ForEach-Object { $_.Id }) + + foreach ($id in $declaredIds) { + if ($id -notin $recordedIds) { + $findings.Add((New-DesignFinding -Class 'UnrecordedArtifact' -Subject $id -Detail "a row in design/20-contract.md - Invariants, with no invariant record" -Blocking $true)) + } + } + foreach ($record in $invariantRecords) { + if ($record.Scalars['Status'] -ne 'active') { continue } + if ($record.Id -notin $declaredIds) { + $findings.Add((New-DesignFinding -Class 'UnrecordedArtifact' -Subject $record.Id -Detail "invariant record exists but is no row in design/20-contract.md - Invariants" -Blocking $true)) + } + } + } + + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# Marked regions: RegionMalformed (balance/nesting) and the region half of IdCollision (an id +# appearing as both the projected and the declared form). Scoped to the document and command +# globs - the only checkout-local carriers of a real region today; an issue's `agent:start` +# block lives on GitHub and is not evaluable from the checkout alone (I22), so it is out of +# reach for a blocking class regardless. Matching requires the marker to be the entire +# (trimmed) line, which is what keeps prose that merely *mentions* the marker syntax - this +# document does, at length - from being misread as a region. +# --------------------------------------------------------------------------------------------- +function Get-MarkedRegions { + param([Parameter(Mandatory)][string] $RepoPath, [Parameter(Mandatory)][AllowEmptyCollection()][string[]] $Files) + + $findings = [System.Collections.Generic.List[object]]::new() + $inventory = [System.Collections.Generic.List[object]]::new() # { Id; Form; File } + + $startRe = '^$' + $endRe = '^$' + + foreach ($file in $Files) { + $full = Join-Path $RepoPath $file + if (-not (Test-Path -LiteralPath $full)) { continue } + $lines = @(Get-Content -LiteralPath $full) + $stack = [System.Collections.Generic.List[object]]::new() # { Id; Form } + + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i].Trim() + + if ($line -match $startRe) { + $id = $Matches[1] + $form = if ($Matches[2]) { 'Declared' } else { 'Projected' } + + if ($stack | Where-Object { $_.Id -eq $id }) { + $findings.Add((New-DesignFinding -Class 'RegionMalformed' -Subject $file -Detail "region '$id' opened again before its enclosing region closed (line $($i + 1))" -Blocking $true)) + } + $stack.Add([pscustomobject]@{ Id = $id; Form = $form; Line = ($i + 1) }) + $inventory.Add([pscustomobject]@{ Id = $id; Form = $form; File = $file }) + continue + } + + if ($line -match $endRe) { + $id = $Matches[1] + $form = if ($Matches[2]) { 'Declared' } else { 'Projected' } + + if ($stack.Count -eq 0 -or $stack[$stack.Count - 1].Id -ne $id -or $stack[$stack.Count - 1].Form -ne $form) { + $findings.Add((New-DesignFinding -Class 'RegionMalformed' -Subject $file -Detail "closing marker for '$id' ($form) at line $($i + 1) does not match the innermost open region" -Blocking $true)) + continue + } + $stack.RemoveAt($stack.Count - 1) + continue + } + } + + foreach ($open in $stack) { + $findings.Add((New-DesignFinding -Class 'RegionMalformed' -Subject $file -Detail "region '$($open.Id)' ($($open.Form)) opened at line $($open.Line) is never closed" -Blocking $true)) + } + } + + [pscustomobject]@{ Findings = @($findings); Inventory = @($inventory) } +} + +function Test-RegionFormCollision { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Inventory) + + $findings = [System.Collections.Generic.List[object]]::new() + $byId = @{} + foreach ($entry in $Inventory) { + if (-not $byId.ContainsKey($entry.Id)) { $byId[$entry.Id] = [System.Collections.Generic.SortedSet[string]]::new() } + [void]$byId[$entry.Id].Add($entry.Form) + } + foreach ($id in $byId.Keys) { + if ($byId[$id].Count -gt 1) { + $findings.Add((New-DesignFinding -Class 'IdCollision' -Subject $id -Detail "region id '$id' appears in both the projected and the declared marker form" -Blocking $true)) + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# IdCollision over records: a duplicated id, an id disagreeing with the id its file path +# implies (S4.7), plus the region-form collision above. +# --------------------------------------------------------------------------------------------- +function Test-RecordIdCollision { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + + $findings = [System.Collections.Generic.List[object]]::new() + $byId = @{} + foreach ($record in $Records) { + if (-not $byId.ContainsKey($record.Id)) { $byId[$record.Id] = [System.Collections.Generic.List[object]]::new() } + $byId[$record.Id].Add($record) + } + foreach ($id in $byId.Keys) { + if ($byId[$id].Count -gt 1) { + $paths = ($byId[$id] | ForEach-Object { $_.Path }) -join ', ' + $findings.Add((New-DesignFinding -Class 'IdCollision' -Subject $id -Detail "id is claimed by more than one file: $paths" -Blocking $true)) + } + } + + foreach ($record in $Records) { + $info = Get-DesignPathInfo -RelativeToState ($record.Path -replace '^design/state/', '') + if (-not $info) { continue } + if ($info.PathId -ne $record.Id) { + $findings.Add((New-DesignFinding -Class 'IdCollision' -Subject $record.Id -Detail "record's own id disagrees with the id implied by its file path '$($record.Path)' (path implies '$($info.PathId)')" -Blocking $true)) + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# DecisionAnchorAmbiguous and LogEntryUnrecorded: design/90-decisions.md heading text against +# Decision.Anchor. +# --------------------------------------------------------------------------------------------- +function Test-DecisionAnchors { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, [Parameter(Mandatory)][string] $LogPath) + + $findings = [System.Collections.Generic.List[object]]::new() + if (-not (Test-Path -LiteralPath $LogPath)) { return ,@() } + + $headings = [System.Collections.Generic.List[string]]::new() + foreach ($line in (Get-Content -LiteralPath $LogPath)) { + if ($line -match '^###\s+(.+?)\s*$') { $headings.Add($Matches[1]) } + } + + $decisions = @($Records | Where-Object { $_.Kind -eq 'Decision' }) + foreach ($decision in $decisions) { + $anchor = $decision.Scalars['Anchor'] + $count = @($headings | Where-Object { $_ -eq $anchor }).Count + if ($count -ne 1) { + $findings.Add((New-DesignFinding -Class 'DecisionAnchorAmbiguous' -Subject $decision.Id -Detail "Anchor '$anchor' resolves to $count heading(s) in $LogPath" -Blocking $true)) + } + } + + $decisionAnchors = @($decisions | ForEach-Object { $_.Scalars['Anchor'] }) + foreach ($heading in $headings) { + if ($heading -notin $decisionAnchors) { + $findings.Add((New-DesignFinding -Class 'LogEntryUnrecorded' -Subject $heading -Detail "log heading has no decision record naming it as Anchor" -Blocking $true)) + } + } + + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# EnforcementUnevidenced: a conditionally-required field absent on a record whose own Status or +# Enforcement requires it - an Invariant with Enforcement 'code' and no Evidence, a Decision with +# Status 'superseded' and no SupersededBy, or a Question with Status 'answered' and no AnsweredBy +# (design/20-contract.md § "The divergence classes"; design/90-decisions.md, 2026-08-19). +# --------------------------------------------------------------------------------------------- +function Test-EnforcementUnevidenced { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($record in @($Records | Where-Object { $_.Kind -eq 'Invariant' })) { + if ($record.Scalars['Enforcement'] -ne 'code') { continue } + $evidence = @(if ($record.Lists.ContainsKey('Evidence')) { @($record.Lists['Evidence'] | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } else { @() }) + if ($evidence.Count -eq 0) { + $findings.Add((New-DesignFinding -Class 'EnforcementUnevidenced' -Subject $record.Id -Detail "field 'Evidence' is required because Enforcement is 'code', and is absent" -Blocking $true)) + } + } + foreach ($record in @($Records | Where-Object { $_.Kind -eq 'Decision' })) { + if ($record.Scalars['Status'] -ne 'superseded') { continue } + if ([string]::IsNullOrWhiteSpace($record.Scalars['SupersededBy'])) { + $findings.Add((New-DesignFinding -Class 'EnforcementUnevidenced' -Subject $record.Id -Detail "field 'SupersededBy' is required because Status is 'superseded', and is absent" -Blocking $true)) + } + } + foreach ($record in @($Records | Where-Object { $_.Kind -eq 'Question' })) { + if ($record.Scalars['Status'] -ne 'answered') { continue } + if ([string]::IsNullOrWhiteSpace($record.Scalars['AnsweredBy'])) { + $findings.Add((New-DesignFinding -Class 'EnforcementUnevidenced' -Subject $record.Id -Detail "field 'AnsweredBy' is required because Status is 'answered', and is absent" -Blocking $true)) + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# The budget meter. closure(U) = record(U), plus the record of every id record(U) names +# directly, excluding Archival and excluding any named record whose Status is retired +# (design/10-design.md § "The orientation closure"; S5.5). Size is the sum of the closure +# members' own file sizes on disk, because the measurement must equal what a reader actually +# opens. +# --------------------------------------------------------------------------------------------- +function Get-RecordFileBytes { + param([Parameter(Mandatory)][string] $RepoPath, [Parameter(Mandatory)]$Record) + $full = Join-Path $RepoPath $Record.Path + if (-not (Test-Path -LiteralPath $full)) { return 0 } + (Get-Item -LiteralPath $full).Length +} + +function Get-DesignClosure { + param([Parameter(Mandatory)]$Root, [Parameter(Mandatory)][hashtable] $ById) + + $members = [System.Collections.Generic.List[object]]::new() + $members.Add($Root) + $seen = [System.Collections.Generic.HashSet[string]]::new() + [void]$seen.Add($Root.Id) + + $namedFields = @('Consumes', 'Exposes', 'Binds', 'Live', 'Questions') + foreach ($field in $namedFields) { + if (-not $Root.Lists.ContainsKey($field)) { continue } + foreach ($id in $Root.Lists[$field]) { + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if (-not $ById.ContainsKey($id)) { continue } + $named = $ById[$id] + if ($named.Scalars.ContainsKey('Status') -and $named.Scalars['Status'] -eq 'retired') { continue } + if ($seen.Contains($id)) { continue } + [void]$seen.Add($id) + $members.Add($named) + } + } + foreach ($field in $script:IdScalarFields) { + if (-not $Root.Scalars.ContainsKey($field)) { continue } + $id = $Root.Scalars[$field] + if ([string]::IsNullOrWhiteSpace($id)) { continue } + if (-not $ById.ContainsKey($id)) { continue } + $named = $ById[$id] + if ($named.Scalars.ContainsKey('Status') -and $named.Scalars['Status'] -eq 'retired') { continue } + if ($seen.Contains($id)) { continue } + [void]$seen.Add($id) + $members.Add($named) + } + + ,@($members) +} + +function Test-ClosureBudget { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, [Parameter(Mandatory)][hashtable] $ById, [Parameter(Mandatory)][string] $RepoPath) + + $findings = [System.Collections.Generic.List[object]]::new() + $largest = $null + + $roots = @($Records | Where-Object { + if ($_.Kind -eq 'WorkRef') { return $true } + -not ($_.Scalars.ContainsKey('Status') -and $_.Scalars['Status'] -eq 'retired') + }) + + foreach ($root in $roots) { + $members = Get-DesignClosure -Root $root -ById $ById + $sized = @($members | ForEach-Object { [pscustomobject]@{ Record = $_; Bytes = (Get-RecordFileBytes -RepoPath $RepoPath -Record $_) } }) + $total = ($sized | Measure-Object -Property Bytes -Sum).Sum + $biggest = $sized | Sort-Object Bytes -Descending | Select-Object -First 1 + + if (-not $largest -or $total -gt $largest.Bytes) { + $largest = [pscustomobject]@{ + Unit = $root.Id + Bytes = $total + LargestContributor = $biggest.Record.Id + } + } + + if ($total -gt $script:ClosureBudgetBytes) { + $findings.Add((New-DesignFinding -Class 'ClosureOverBudget' -Subject $root.Id -Detail "closure is $total bytes (ceiling $($script:ClosureBudgetBytes)); largest contributor '$($biggest.Record.Id)'" -Blocking $true)) + } + } + + [pscustomobject]@{ Findings = @($findings); Largest = $largest } +} + +# --------------------------------------------------------------------------------------------- +# The projector (S7). Its absence, or a non-zero exit, is a contracted case (ProjectorFailed) - +# S5's own Out of scope line, unchanged now that S7 has written it. -DryRun's output is the +# projector's own JSON rendering of every region it would write (design/20-contract.md § +# tools/Update-DesignProjection.ps1); a caller comparing region content, not just the exit code, +# is what makes ProjectionStale (S7.9) computable rather than permanently uncomputed. +# --------------------------------------------------------------------------------------------- +function Invoke-Projector { + param([Parameter(Mandatory)][string] $RepoPath) + + $projectorPath = Join-Path $RepoPath 'tools/Update-DesignProjection.ps1' + if (-not (Test-Path -LiteralPath $projectorPath)) { + return [pscustomobject]@{ Ran = $false; Detail = 'tools/Update-DesignProjection.ps1 does not exist'; Regions = @() } + } + try { + $raw = & pwsh -NoProfile -File $projectorPath -Path $RepoPath -DryRun 2>$null + if ($LASTEXITCODE -ne 0) { + return [pscustomobject]@{ Ran = $false; Detail = "exited $LASTEXITCODE"; Regions = @() } + } + } catch { + return [pscustomobject]@{ Ran = $false; Detail = $_.Exception.Message; Regions = @() } + } + + $regions = @() + try { + if ($raw) { + $regions = @(($raw -join "`n") | ConvertFrom-Json) + } + } catch { + return [pscustomobject]@{ Ran = $false; Detail = "unparseable projector output: $($_.Exception.Message)"; Regions = @() } + } + + [pscustomobject]@{ Ran = $true; Detail = $null; Regions = $regions } +} + +# --------------------------------------------------------------------------------------------- +# ProjectionStale (S7.9). A region the projector rendered but has no document (the `agent` +# projection - it targets GitHub, not the tree) is not comparable here and is skipped; every +# other region is compared, CRLF-normalised, against the tree's own copy of that region's body. +# --------------------------------------------------------------------------------------------- +function Get-RegionBody { + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines, [Parameter(Mandatory)][string] $Id) + + $startPattern = "" + $endPattern = "" + $startIndex = -1 + $endIndex = -1 + for ($i = 0; $i -lt $Lines.Count; $i++) { + $t = $Lines[$i].Trim() + if ($startIndex -lt 0 -and $t -eq $startPattern) { $startIndex = $i; continue } + if ($startIndex -ge 0 -and $endIndex -lt 0 -and $t -eq $endPattern) { $endIndex = $i; continue } + } + if ($startIndex -lt 0 -or $endIndex -lt 0 -or $endIndex -le $startIndex) { return $null } + if ($endIndex -eq $startIndex + 1) { return '' } + ,@($Lines[($startIndex + 1)..($endIndex - 1)]) +} + +function ConvertTo-NormalisedNewlines { + param([string] $Text) + ($Text -replace "`r`n", "`n") -replace "`r", "`n" +} + +function Test-ProjectionStale { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Regions, [Parameter(Mandatory)][string] $RepoPath) + + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($region in $Regions) { + if (-not $region.Document) { continue } # e.g. `agent` - no tree region to compare against + $full = Join-Path $RepoPath $region.Document + if (-not (Test-Path -LiteralPath $full)) { + $findings.Add((New-DesignFinding -Class 'ProjectionStale' -Subject "$($region.Document)#$($region.Id)" -Detail 'document named by the projector does not exist in the tree' -Blocking $true)) + continue + } + $lines = @(Get-Content -LiteralPath $full) + $body = Get-RegionBody -Lines $lines -Id $region.Id + if ($null -eq $body) { continue } # a missing/malformed region is RegionMissing/RegionMalformed's territory, not this one's + + $current = (ConvertTo-NormalisedNewlines -Text (($body -join "`n"))).Trim("`n") + $rendered = (ConvertTo-NormalisedNewlines -Text $region.Content).Trim("`n") + if ($current -ne $rendered) { + $findings.Add((New-DesignFinding -Class 'ProjectionStale' -Subject "$($region.Document)#$($region.Id)" -Detail 'the tree''s copy of this region differs from its regeneration' -Blocking $true)) + } + } + ,@($findings) +} + +# --------------------------------------------------------------------------------------------- +# The tracker classes: MirrorStale (no network - compares MirroredAt to HEAD), PinAncestry and +# WorkStateDivergence (need gh / git history). TrackerUnavailable is could-not-evaluate for the +# gh-dependent classes only; MirrorStale still runs without gh. +# --------------------------------------------------------------------------------------------- +function Get-CurrentCommitSha { + param([Parameter(Mandatory)][string] $RepoPath) + try { + Push-Location $RepoPath + $sha = (& git rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0) { return $null } + return $sha.Trim() + } finally { + Pop-Location + } +} + +function Test-CommitIsAncestor { + param([Parameter(Mandatory)][string] $RepoPath, [Parameter(Mandatory)][string] $Sha) + try { + Push-Location $RepoPath + & git merge-base --is-ancestor $Sha HEAD 2>$null | Out-Null + switch ($LASTEXITCODE) { + 0 { 'Ancestor' } + 1 { 'NotAncestor' } + default { 'Unresolvable' } + } + } finally { + Pop-Location + } +} + +function Test-TrackerAvailable { + param([string] $Repository) + $ghArgs = @('issue', 'list', '--state', 'all', '--limit', '1', '--json', 'number') + if ($Repository) { $ghArgs += @('-R', $Repository) } + try { + & gh @ghArgs 2>$null | Out-Null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +function Test-TrackerClasses { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records, [Parameter(Mandatory)][string] $RepoPath, [string] $Repository) + + $reported = [System.Collections.Generic.List[object]]::new() + $couldNotEvaluate = [System.Collections.Generic.List[object]]::new() + + $workRefs = @($Records | Where-Object { $_.Kind -eq 'WorkRef' }) + $headSha = Get-CurrentCommitSha -RepoPath $RepoPath + + foreach ($ref in $workRefs) { + $mirroredAt = $ref.Scalars['MirroredAt'] + if (-not [string]::IsNullOrWhiteSpace($mirroredAt) -and $headSha -and $mirroredAt -ne $headSha) { + $reported.Add((New-DesignFinding -Class 'MirrorStale' -Subject $ref.Id -Detail "MirroredAt '$mirroredAt' is not the current commit '$headSha'" -Blocking $false)) + } + } + + $ghOk = Test-TrackerAvailable -Repository $Repository + if (-not $ghOk) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'TrackerUnavailable' -Detail 'gh missing or unauthenticated; WorkStateDivergence not compared')) + } else { + foreach ($ref in $workRefs) { + $number = $ref.Scalars['Issue'] + if ([string]::IsNullOrWhiteSpace($number)) { continue } + $json = & gh issue view $number --json title, state 2>$null + if ($LASTEXITCODE -ne 0 -or -not $json) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'TrackerUnavailable' -Detail "could not read issue #$number for $($ref.Id)")) + continue + } + try { + $issue = ($json -join "`n") | ConvertFrom-Json + } catch { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'TrackerUnavailable' -Detail "unparseable gh output for issue #$number")) + continue + } + $mirroredState = $ref.Scalars['State'] + $mirroredTitle = $ref.Scalars['Title'] + if ($mirroredState -and $issue.state -and ($mirroredState -ne $issue.state)) { + $reported.Add((New-DesignFinding -Class 'WorkStateDivergence' -Subject $ref.Id -Detail "mirrored State '$mirroredState' disagrees with tracker's '$($issue.state)'" -Blocking $false)) + } + if ($mirroredTitle -and $issue.title -and ($mirroredTitle -ne $issue.title)) { + $reported.Add((New-DesignFinding -Class 'WorkStateDivergence' -Subject $ref.Id -Detail "mirrored Title disagrees with tracker's current title" -Blocking $false)) + } + } + } + + foreach ($ref in $workRefs) { + $mirroredAt = $ref.Scalars['MirroredAt'] + if ([string]::IsNullOrWhiteSpace($mirroredAt)) { continue } + if (-not $headSha) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'ShallowCheckout' -Detail "no history to resolve HEAD for $($ref.Id)")) + continue + } + $ancestry = Test-CommitIsAncestor -RepoPath $RepoPath -Sha $mirroredAt + switch ($ancestry) { + 'NotAncestor' { $reported.Add((New-DesignFinding -Class 'PinAncestry' -Subject $ref.Id -Detail "MirroredAt '$mirroredAt' is not an ancestor of HEAD" -Blocking $false)) } + 'Unresolvable' { $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'ShallowCheckout' -Detail "ancestry of '$mirroredAt' could not be resolved for $($ref.Id) - no history for merge-base")) } + } + } + + [pscustomobject]@{ Reported = @($reported); CouldNotEvaluate = @($couldNotEvaluate) } +} + +# --------------------------------------------------------------------------------------------- +# The freeze gate. While design/FROZEN.md exists, every blocking class is downgraded to +# reported, the count downgraded is stated, and the marker's Frozen because / Lifts when lines +# are reproduced verbatim (AGENTS.md § "The design freeze"; S5.8). Exit 2 still stands. +# --------------------------------------------------------------------------------------------- +function Get-FreezeMarker { + param([Parameter(Mandatory)][string] $RepoPath) + + $markerPath = Join-Path $RepoPath 'design/FROZEN.md' + if (-not (Test-Path -LiteralPath $markerPath)) { return $null } + + $text = Get-Content -LiteralPath $markerPath + $because = ($text | Where-Object { $_ -match '^Frozen because:' }) | Select-Object -First 1 + $lifts = ($text | Where-Object { $_ -match '^Lifts when:' }) | Select-Object -First 1 + + [pscustomobject]@{ + FrozenBecause = if ($because) { $because } else { '(Frozen because: line not found in design/FROZEN.md)' } + LiftsWhen = if ($lifts) { $lifts } else { '(Lifts when: line not found in design/FROZEN.md)' } + } +} + +# --------------------------------------------------------------------------------------------- +# The main entry point. +# --------------------------------------------------------------------------------------------- +function Invoke-DesignStateCheck { + param([Parameter(Mandatory)][string] $RepoPath, [string] $Repository) + + $contractPath = Join-Path $RepoPath 'design/20-contract.md' + $classListResult = Test-ClassListAgreement -ContractPath $contractPath + $invariantSet = Get-ContractInvariantIds -ContractPath $contractPath + + $graph = Read-DesignStateGraph -Path $RepoPath + + $couldNotEvaluate = [System.Collections.Generic.List[object]]::new() + if ($classListResult.CouldNotEvaluate) { $couldNotEvaluate.Add($classListResult.CouldNotEvaluate) } + if ($invariantSet.Failure) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'ContractListUnreadable' -Detail "$($invariantSet.Failure): $contractPath; UnrecordedArtifact's invariant half is uncomputed, not clean")) + } + + $blockingFindings = [System.Collections.Generic.List[object]]::new() + if ($classListResult.Finding) { $blockingFindings.Add($classListResult.Finding) } + + foreach ($f in $graph.Failures) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'RecordUnparseable' -Detail "$($f.Path):$($f.Line): $($f.Text)")) + } + + if ($graph.Root -eq '' -or $graph.Records.Count -eq 0) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'StateSetAbsent' -Detail 'design/state/ is missing or holds zero records')) + return New-DesignStateResult -Findings @() -Reported @() -CouldNotEvaluate @($couldNotEvaluate) -ExitCode 2 -LargestClosure $null -ReportLines @('StateSetAbsent: nothing to check.') + } + + $records = @($graph.Records) + $byId = @{} + foreach ($r in $records) { + if (-not $byId.ContainsKey($r.Id)) { $byId[$r.Id] = $r } + } + + $reportedFindings = [System.Collections.Generic.List[object]]::new() + + $blockingFindings.AddRange((Test-UnresolvedId -ById $byId -Records $records)) + $blockingFindings.AddRange((Test-AnchorMissing -Records $records -RepoPath $RepoPath)) + $blockingFindings.AddRange((Test-OwnerMismatch -Records $records)) + $blockingFindings.AddRange((Test-UnrecordedArtifact -Records $records -RepoPath $RepoPath -InvariantIds $invariantSet.Ids)) + $blockingFindings.AddRange((Test-RecordIdCollision -Records $records)) + $blockingFindings.AddRange((Test-DecisionAnchors -Records $records -LogPath (Join-Path $RepoPath 'design/90-decisions.md'))) + $blockingFindings.AddRange((Test-EnforcementUnevidenced -Records $records)) + + $globResult = Test-GlobDisagreement -RepoPath $RepoPath -ContractPath $contractPath + if ($globResult.CouldNotEvaluate) { $couldNotEvaluate.Add($globResult.CouldNotEvaluate) } + $blockingFindings.AddRange($globResult.Findings) + + $regionFiles = @((Get-DocumentGlobFiles -RepoPath $RepoPath) + (Get-CommandGlobFiles -RepoPath $RepoPath) | Sort-Object -Unique) + $regionResult = Get-MarkedRegions -RepoPath $RepoPath -Files $regionFiles + $blockingFindings.AddRange($regionResult.Findings) + $blockingFindings.AddRange((Test-RegionFormCollision -Inventory $regionResult.Inventory)) + + $projector = Invoke-Projector -RepoPath $RepoPath + if (-not $projector.Ran) { + $couldNotEvaluate.Add((New-CouldNotEvaluate -Reason 'ProjectorFailed' -Detail "$($projector.Detail); ProjectionStale is uncomputed, not clean")) + } else { + # S7.9. A working projector's -DryRun regions are compared, CRLF-normalised, against the + # tree's own copy of each region. Reporting clean here would be the I19/I20 pass this + # design forbids only when the projector itself could not run; once it can, "regenerate + # and compare" is exactly what this class exists to do. + $blockingFindings.AddRange((Test-ProjectionStale -Regions $projector.Regions -RepoPath $RepoPath)) + } + + $budget = Test-ClosureBudget -Records $records -ById $byId -RepoPath $RepoPath + $blockingFindings.AddRange($budget.Findings) + + $tracker = Test-TrackerClasses -Records $records -RepoPath $RepoPath -Repository $Repository + $reportedFindings.AddRange($tracker.Reported) + foreach ($cne in $tracker.CouldNotEvaluate) { $couldNotEvaluate.Add($cne) } + + $freeze = Get-FreezeMarker -RepoPath $RepoPath + $downgraded = 0 + $finalFindings = [System.Collections.Generic.List[object]]::new() + $finalReported = [System.Collections.Generic.List[object]]::new($reportedFindings) + + if ($freeze) { + foreach ($f in $blockingFindings) { + $finalReported.Add($f) + $downgraded++ + } + } else { + foreach ($f in $blockingFindings) { $finalFindings.Add($f) } + } + + $reportLines = [System.Collections.Generic.List[string]]::new() + if ($budget.Largest) { + $reportLines.Add("Largest closure: $($budget.Largest.Unit), $($budget.Largest.Bytes) bytes (ceiling $($script:ClosureBudgetBytes)), largest contributor $($budget.Largest.LargestContributor)") + } + if ($freeze) { + $reportLines.Add("Freeze active: $downgraded blocking finding(s) downgraded to reported.") + $reportLines.Add($freeze.FrozenBecause) + $reportLines.Add($freeze.LiftsWhen) + } + + $exitCode = if ($couldNotEvaluate.Count -gt 0) { 2 } elseif ($finalFindings.Count -gt 0) { 1 } else { 0 } + + New-DesignStateResult -Findings @($finalFindings) -Reported @($finalReported) -CouldNotEvaluate @($couldNotEvaluate) -ExitCode $exitCode -LargestClosure $budget.Largest -ReportLines @($reportLines) -DowngradedCount $downgraded +} + +function Write-DesignStateReport { + param([Parameter(Mandatory)] $Result) + + Write-Host "Findings ($($Result.Findings.Count)):" + foreach ($f in $Result.Findings) { Write-Host " [$($f.Class)] $($f.Subject): $($f.Detail)" } + + Write-Host "Reported ($($Result.Reported.Count)):" + foreach ($f in $Result.Reported) { Write-Host " [$($f.Class)] $($f.Subject): $($f.Detail)" } + + Write-Host "Could not evaluate ($($Result.CouldNotEvaluate.Count)):" + foreach ($c in $Result.CouldNotEvaluate) { Write-Host " [$($c.Reason)] $($c.Detail)" } + + foreach ($line in $Result.ReportLines) { Write-Host $line } + + Write-Host "Exit code: $($Result.ExitCode)" +} + +# Guards the invocation so this script's tests can dot-source it - the same shape +# Test-DesignDrift.ps1, Wait-PullRequestCheck.ps1 and Read-DesignState.ps1 already use. +if ($MyInvocation.InvocationName -ne '.') { + $result = Invoke-DesignStateCheck -RepoPath $Path -Repository $Repository + if (-not $Quiet) { Write-DesignStateReport -Result $result } + $result + exit $result.ExitCode +} diff --git a/tools/Update-DesignProjection.Tests.ps1 b/tools/Update-DesignProjection.Tests.ps1 new file mode 100644 index 0000000..52b0c78 --- /dev/null +++ b/tools/Update-DesignProjection.Tests.ps1 @@ -0,0 +1,387 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Update-DesignProjection.ps1 exits the process on real invocation, so these tests dot-source it + purely to reuse its functions and skip its own invocation block - the same guard shape + Test-DesignState.ps1, Read-DesignState.ps1 and Test-DesignDrift.ps1 already use. + + Every fixture below is written into $TestDrive under a throwaway root; the final Describe + block is explicit about reading this repository's own tree instead. +#> + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Update-DesignProjection.ps1' + . $script:ScriptPath -Path $TestDrive + + function New-StateFile { + param([Parameter(Mandatory)][string] $RelativePath, [Parameter(Mandatory)][string] $Content) + $full = Join-Path $TestDrive (Join-Path 'design/state' $RelativePath) + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Content -Encoding utf8NoBOM + $full + } + + function New-TreeFile { + param([Parameter(Mandatory)][string] $RelativePath, [Parameter(Mandatory)][string] $Content) + $full = Join-Path $TestDrive $RelativePath + New-Item -ItemType Directory -Path (Split-Path $full -Parent) -Force | Out-Null + Set-Content -LiteralPath $full -Value $Content -Encoding utf8NoBOM + $full + } + + function Clear-Drive { + Get-ChildItem $TestDrive -ErrorAction SilentlyContinue -Recurse -File | Remove-Item -Force -ErrorAction SilentlyContinue + Get-ChildItem $TestDrive -ErrorAction SilentlyContinue -Recurse -Directory | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue + } + + function New-UnitFixture { + New-StateFile -RelativePath 'units/command/track.md' -Content @' +# unit/command/track +Kind: command +Status: active +Anchor: .claude/commands/track.md +Binds: I28 + +## Owns +Syncs design/ into issues. +'@ + New-StateFile -RelativePath 'invariants/I28.md' -Content @' +# I28 +Kind: invariant +Status: active +Anchor: I28 +Owner: unit/command/track +Enforcement: instruction + +## Statement +GitHub is the authority. +'@ + } +} + +Describe 'Update-DesignProjection: rendering' { + + BeforeEach { Clear-Drive } + + It 'S7.1: renders the units projection as a table of id, kind, anchor' { + New-UnitFixture + $graph = Read-DesignStateGraph -Path $TestDrive + $content = (Get-UnitsProjectionContent -Records $graph.Records) -join "`n" + $content | Should -Match '\| `unit/command/track` \| command \| `\.claude/commands/track\.md` \|' + } + + It 'S7.1: bound-by lists the units that bind each invariant' { + New-UnitFixture + $graph = Read-DesignStateGraph -Path $TestDrive + $content = (Get-BoundByProjectionContent -Records $graph.Records) -join "`n" + $content | Should -Match '\| I28 \| `unit/command/track` \|' + } + + It 'S7.1: consumers, decision-affects and question-affects render an honest empty table with no records' { + New-UnitFixture + $graph = Read-DesignStateGraph -Path $TestDrive + (Get-ConsumersProjectionContent -Records $graph.Records) -join "`n" | Should -Match 'no contract records yet' + (Get-DecisionAffectsProjectionContent -Records $graph.Records) -join "`n" | Should -Match 'no decision records yet' + (Get-QuestionAffectsProjectionContent -Records $graph.Records) -join "`n" | Should -Match 'no question records yet' + } + + It 'S7.1: invariants renders Statement, Owner, Enforcement and Evidence from the record' { + New-UnitFixture + $graph = Read-DesignStateGraph -Path $TestDrive + $content = (Get-InvariantsProjectionContent -Records $graph.Records) -join "`n" + $content | Should -Match '\*\*I28\*\* \| GitHub is the authority\. \| `unit/command/track` \| instruction \| — \|' + } + + It 'S7.10: the agent projection renders from a WorkRef''s own fields and calls no gh' { + Mock -CommandName gh -MockWith { throw 'gh must never be called by the projector' } + $record = New-DesignRecord -Id 'work/7' -Kind 'WorkRef' -Path 'design/state/work/7.md' ` + -Scalars @{ Issue = '7'; Title = 'S7 — test slice'; MirroredAt = 'deadbeef' } ` + -Lists @{ Criteria = @('S7.1', 'S7.2') } -Prose @{} + $content = (Get-AgentProjectionContent -Record $record) -join "`n" + $content | Should -Match 'Run `/slice S7`\.' + $content | Should -Match 'S7 @ `deadbeef`' + $content | Should -Match 'S7\.1, S7\.2' + } + + It 'S14.8: outstanding renders an honest empty table with no WorkRef records' { + New-UnitFixture + $graph = Read-DesignStateGraph -Path $TestDrive + (Get-OutstandingProjectionContent -Records $graph.Records) -join "`n" | Should -Match 'no outstanding WorkRef records yet' + } + + It 'S14.8: outstanding renders an OPEN WorkRef''s rank, issue, title, criteria and mirror commit' { + $record = New-DesignRecord -Id 'work/57' -Kind 'WorkRef' -Path 'design/state/work/57.md' ` + -Scalars @{ Issue = '57'; Title = 'S14 — Work state'; State = 'OPEN'; Rank = '3'; MirroredAt = 'abc1234' } ` + -Lists @{ Criteria = @('S14.1', 'S14.2') } -Prose @{} + $content = (Get-OutstandingProjectionContent -Records @($record)) -join "`n" + $content | Should -Match '\| 3 \| #57 \| S14 — Work state \| S14\.1, S14\.2 \| `abc1234` \|' + } + + It 'S14.8: outstanding excludes a CLOSED WorkRef - closed work is not outstanding' { + $open = New-DesignRecord -Id 'work/1' -Kind 'WorkRef' -Path 'design/state/work/1.md' ` + -Scalars @{ Issue = '1'; Title = 'Open one'; State = 'OPEN'; Rank = '1'; MirroredAt = 'sha' } -Lists @{} -Prose @{} + $closed = New-DesignRecord -Id 'work/2' -Kind 'WorkRef' -Path 'design/state/work/2.md' ` + -Scalars @{ Issue = '2'; Title = 'Closed one'; State = 'CLOSED'; Rank = '2'; MirroredAt = 'sha' } -Lists @{} -Prose @{} + $content = (Get-OutstandingProjectionContent -Records @($open, $closed)) -join "`n" + $content | Should -Match 'Open one' + $content | Should -Not -Match 'Closed one' + } + + It 'S14.8: outstanding orders numeric ranks low-to-high, with a non-numeric rank sorting after every numeric one' { + $third = New-DesignRecord -Id 'work/30' -Kind 'WorkRef' -Path 'design/state/work/30.md' ` + -Scalars @{ Issue = '30'; Title = 'Third'; State = 'OPEN'; Rank = '5'; MirroredAt = 'sha' } -Lists @{} -Prose @{} + $first = New-DesignRecord -Id 'work/10' -Kind 'WorkRef' -Path 'design/state/work/10.md' ` + -Scalars @{ Issue = '10'; Title = 'First'; State = 'OPEN'; Rank = '1'; MirroredAt = 'sha' } -Lists @{} -Prose @{} + $last = New-DesignRecord -Id 'work/20' -Kind 'WorkRef' -Path 'design/state/work/20.md' ` + -Scalars @{ Issue = '20'; Title = 'Last'; State = 'OPEN'; Rank = 'milestone/9'; MirroredAt = 'sha' } -Lists @{} -Prose @{} + $content = (Get-OutstandingProjectionContent -Records @($third, $first, $last)) -join "`n" + (($content -split "`n") | Where-Object { $_ -match 'First|Third|Last' }) | Should -Be @( + '| 1 | #10 | First | — | `sha` |' + '| 5 | #30 | Third | — | `sha` |' + '| milestone/9 | #20 | Last | — | `sha` |' + ) + } +} + +Describe 'Update-DesignProjection: region location and refusal (S7.7, I29)' { + + It 'finds a well-formed bare region' { + $lines = @('a', '', 'body', '', 'b') + $loc = Find-BareRegion -Lines $lines -Id 'x' + $loc.Found | Should -BeTrue + $loc.StartIndex | Should -Be 1 + $loc.EndIndex | Should -Be 3 + } + + It 'reports Found = $false, Refuse = $false when no region exists for the id' { + $lines = @('a', 'b') + $loc = Find-BareRegion -Lines $lines -Id 'x' + $loc.Found | Should -BeFalse + $loc.Refuse | Should -BeFalse + } + + It 'S7.7: refuses a region with two start markers, naming RegionMalformed' { + $lines = @('', '', '') + $loc = Find-BareRegion -Lines $lines -Id 'x' + $loc.Refuse | Should -BeTrue + $loc.Reason | Should -Be 'RegionMalformed' + } + + It 'S7.7: refuses a region whose end precedes its start' { + $lines = @('', '') + $loc = Find-BareRegion -Lines $lines -Id 'x' + $loc.Refuse | Should -BeTrue + $loc.Reason | Should -Be 'RegionMalformed' + } + + It 'S7.6/I29: refuses (never writes) when the id is a declared region' { + $lines = @('', 'hand-written', '') + $loc = Find-BareRegion -Lines $lines -Id 'x' + $loc.Refuse | Should -BeTrue + $loc.Reason | Should -Be 'DeclaredRegion' + } +} + +Describe 'Update-DesignProjection: end-to-end write behaviour' { + + BeforeEach { + Clear-Drive + New-UnitFixture + New-TreeFile -RelativePath 'design/state-index.md' -Content @' +# Index + +## Units + + + + +## Bound by + + + + +## Consumers + + + + +## Decision affects + + + + +## Question affects + + + +'@ + New-TreeFile -RelativePath 'design/20-contract.md' -Content @' +# Contract + +## Invariants + + + + +Hand-authored tail, outside every region. +'@ + } + + It 'S7.2: -DryRun renders regions and writes nothing to disk' { + Push-Location $TestDrive + try { + & git init --quiet 2>$null + & git config user.email 'test@example.com' 2>$null + & git config user.name 'Test' 2>$null + & git add -A 2>$null + & git commit --quiet -m 'seed' 2>$null + + $before = & git status --short + $result = Invoke-DesignProjection -RepoPath $TestDrive -DryRun + $after = & git status --short + + $after | Should -Be $before + ($result.Regions | Where-Object { $_.Id -eq 'units' }).Content | Should -Match 'unit/command/track' + } finally { + Pop-Location + } + } + + It 'S7.4: a hand edit inside a region is gone after regeneration; a hand edit outside survives' { + $contractPath = Join-Path $TestDrive 'design/20-contract.md' + $lines = @(Get-Content -LiteralPath $contractPath) + $lines = $lines -replace '', "stale hand-written row`n" + Set-Content -LiteralPath $contractPath -Value ($lines -join "`n") -NoNewline -Encoding utf8NoBOM + + $null = Invoke-DesignProjection -RepoPath $TestDrive + $after = Get-Content -LiteralPath $contractPath -Raw + + $after | Should -Not -Match 'stale hand-written row' + $after | Should -Match 'Hand-authored tail, outside every region\.' + } + + It 'S7.3: regenerating twice produces byte-identical files' { + $null = Invoke-DesignProjection -RepoPath $TestDrive + $pass1 = Get-Content -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Raw + $null = Invoke-DesignProjection -RepoPath $TestDrive + $pass2 = Get-Content -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Raw + $pass2 | Should -Be $pass1 + } + + It 'S7.3: regenerating one region leaves every other region''s rendered output unchanged' { + $null = Invoke-DesignProjection -RepoPath $TestDrive + $before = Get-Content -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Raw + + # A second, unrelated record is added and regenerated - only bound-by's row set can grow; + # units' own render for the pre-existing record must not move. + New-StateFile -RelativePath 'decisions/2026-01-01-example.md' -Content @' +# decision/2026-01-01-example +Date: 2026-01-01 +Anchor: 2026-01-01 - example +Status: accepted + +## Claim +An example claim. +'@ + $null = Invoke-DesignProjection -RepoPath $TestDrive + $after = Get-Content -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Raw + + # The units table (unaffected by the new decision) is byte-identical between runs. + $unitsBefore = ($before -split "`n" | Select-String -Pattern 'unit/command/track') + $unitsAfter = ($after -split "`n" | Select-String -Pattern 'unit/command/track') + "$unitsAfter" | Should -Be "$unitsBefore" + } + + It 'S7.5: a document with no region for the target id is refused, not created' { + Remove-Item -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Force + New-TreeFile -RelativePath 'design/state-index.md' -Content "# Index`n`nNo regions here.`n" + + $result = Invoke-DesignProjection -RepoPath $TestDrive + (@($result.Refusals | Where-Object { $_.Id -eq 'units' -and $_.Reason -eq 'RegionMissing' })).Count | Should -Be 1 + + $after = Get-Content -LiteralPath (Join-Path $TestDrive 'design/state-index.md') -Raw + $after | Should -Not -Match ':start -->' + } + + It 'S7.6/I29: a declared region for the same id is refused and left untouched' { + $contractPath = Join-Path $TestDrive 'design/20-contract.md' + Set-Content -LiteralPath $contractPath -Value @' +# Contract + +## Invariants + + +hand-authored, never overwritten + +'@ -Encoding utf8NoBOM + + $before = Get-Content -LiteralPath $contractPath -Raw + $result = Invoke-DesignProjection -RepoPath $TestDrive + $after = Get-Content -LiteralPath $contractPath -Raw + + $after | Should -Be $before + (@($result.Refusals | Where-Object { $_.Id -eq 'invariants' -and $_.Reason -eq 'DeclaredRegion' })).Count | Should -Be 1 + } + + It 'S7.8: rendering into an empty region and into one already holding stale content produces identical bytes' { + $null = Invoke-DesignProjection -RepoPath $TestDrive + $freshRender = Get-Content -LiteralPath (Join-Path $TestDrive 'design/20-contract.md') -Raw + + # Re-seed with the SAME empty region, run again (empty -> rendered). + New-TreeFile -RelativePath 'design/20-contract.md' -Content @' +# Contract + +## Invariants + + + + +Hand-authored tail, outside every region. +'@ + $null = Invoke-DesignProjection -RepoPath $TestDrive + $fromEmpty = Get-Content -LiteralPath (Join-Path $TestDrive 'design/20-contract.md') -Raw + + # Re-seed with STALE content already inside the region, run again (stale -> rendered). + New-TreeFile -RelativePath 'design/20-contract.md' -Content @' +# Contract + +## Invariants + + +| this is stale content that does not match any record | + + +Hand-authored tail, outside every region. +'@ + $null = Invoke-DesignProjection -RepoPath $TestDrive + $fromStale = Get-Content -LiteralPath (Join-Path $TestDrive 'design/20-contract.md') -Raw + + $fromEmpty | Should -Be $freshRender + $fromStale | Should -Be $freshRender + } +} + +Describe 'Update-DesignProjection against this repository''s own tree' { + + BeforeAll { + $script:RepoRoot = Split-Path $PSScriptRoot -Parent + } + + It 'S7.2: -DryRun against the real repository writes nothing' { + $before = & git -C $script:RepoRoot status --short + $result = Invoke-DesignProjection -RepoPath $script:RepoRoot -DryRun + $after = & git -C $script:RepoRoot status --short + $after | Should -Be $before + $result.Refusals.Count | Should -Be 0 + } + + It 'S7.3: a real, non-DryRun run against this repository is already at its fixed point (idempotent)' { + $before = & git -C $script:RepoRoot status --short + $null = Invoke-DesignProjection -RepoPath $script:RepoRoot + $after = & git -C $script:RepoRoot status --short + # The real design/20-contract.md and design/state-index.md are committed already + # regenerated - a real run must not find anything to change. + $after | Should -Be $before + } +} diff --git a/tools/Update-DesignProjection.ps1 b/tools/Update-DesignProjection.ps1 new file mode 100644 index 0000000..d877356 --- /dev/null +++ b/tools/Update-DesignProjection.ps1 @@ -0,0 +1,371 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + The projector: renders design/state/ records into marked regions + (design/20-contract.md § tools/Update-DesignProjection.ps1). + +.DESCRIPTION + Reads design/state/ via Read-DesignState.ps1 and renders every projection in the minimum + set (design/20-contract.md § tools/Update-DesignProjection.ps1). + + Seven projections target a marked region in a tracked document and are written there: + `units`, `bound-by`, `consumers`, `decision-affects`, `question-affects` and `outstanding` + render into design/state-index.md; `invariants` renders into design/20-contract.md's own + § Invariants region. `outstanding` renders only `WorkRef` records whose `State` is `OPEN`, + ordered by `Rank` - it is a projection of the mirror, never a second read of the tracker + (I14: input is records, never a live gh call). `agent` has no document region - GitHub is + where an issue's agent block lives, this script never calls `gh`, so `agent` is rendered per + WorkRef record and returned to the caller only (S7.10). + + Writes only between the markers of a projected region (I18, I29): never a byte outside one, + never a new region, never a document with no region for the id, and never inside a + `:declared:` region. A region that is malformed, declared, or simply absent from its target + document is refused rather than repaired - the caller sees it in `.Refusals` and (on direct + invocation) as a non-zero exit, which is what makes ProjectorFailed fire honestly instead of + silently skipping the write. + + Idempotent and order-independent (I25): every projection is computed from records alone and + never from a document's current region content (I14), so regenerating twice, or in any + order, produces identical bytes. + +.PARAMETER Path + Repository root. Defaults to the current directory. + +.PARAMETER DryRun + Renders to the success stream (as JSON, one object per region, on direct invocation) and + writes nothing. This is the checker's entry point. + +.EXAMPLE + pwsh ./tools/Update-DesignProjection.ps1 -DryRun +#> +[CmdletBinding()] +param( + [string] $Path = (Get-Location).Path, + [switch] $DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:ReaderPath = Join-Path $PSScriptRoot 'Read-DesignState.ps1' +if (-not (Test-Path -LiteralPath $script:ReaderPath)) { + throw "tools/Read-DesignState.ps1 not found beside tools/Update-DesignProjection.ps1 at $script:ReaderPath" +} +. $script:ReaderPath -Path $Path + +# --------------------------------------------------------------------------------------------- +# Rendering. Every function below reads only from $Records (in-memory, from the reader) and +# returns an array of Markdown lines - never a document's own tree copy (I14). +# --------------------------------------------------------------------------------------------- +function Format-IdList { + param([string[]] $Ids) + if (-not $Ids -or $Ids.Count -eq 0) { return '—' } + ($Ids | Sort-Object -Unique | ForEach-Object { "``$_``" }) -join ', ' +} + +function Get-UnitsProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' -and $_.Scalars['Status'] -eq 'active' } | Sort-Object Id) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Id | Kind | Anchor |') + $lines.Add('|---|---|---|') + if ($units.Count -eq 0) { + $lines.Add('| _(no active unit records yet)_ | | |') + } + foreach ($u in $units) { + $lines.Add("| ``$($u.Id)`` | $($u.Scalars['Kind']) | ``$($u.Scalars['Anchor'])`` |") + } + ,@($lines) +} + +function Get-BoundByProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $invariants = @($Records | Where-Object { $_.Kind -eq 'Invariant' -and $_.Scalars['Status'] -eq 'active' } | Sort-Object { [int]($_.Id -replace '^I', '') }) + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Invariant | Bound by |') + $lines.Add('|---|---|') + if ($invariants.Count -eq 0) { + $lines.Add('| _(no invariant records yet)_ | |') + } + foreach ($inv in $invariants) { + $binders = @($units | Where-Object { $_.Lists.ContainsKey('Binds') -and $inv.Id -in $_.Lists['Binds'] } | ForEach-Object { $_.Id }) + $lines.Add("| $($inv.Id) | $(Format-IdList -Ids $binders) |") + } + ,@($lines) +} + +function Get-ConsumersProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $contracts = @($Records | Where-Object { $_.Kind -eq 'Contract' -and $_.Scalars['Status'] -eq 'active' } | Sort-Object Id) + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Contract | Consumers |') + $lines.Add('|---|---|') + if ($contracts.Count -eq 0) { + $lines.Add('| _(no contract records yet)_ | |') + } + foreach ($c in $contracts) { + $consumers = @($units | Where-Object { $_.Lists.ContainsKey('Consumes') -and $c.Id -in $_.Lists['Consumes'] } | ForEach-Object { $_.Id }) + $lines.Add("| $($c.Id) | $(Format-IdList -Ids $consumers) |") + } + ,@($lines) +} + +function Get-DecisionAffectsProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $decisions = @($Records | Where-Object { $_.Kind -eq 'Decision' } | Sort-Object Id) + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Decision | In force for |') + $lines.Add('|---|---|') + if ($decisions.Count -eq 0) { + $lines.Add('| _(no decision records yet)_ | |') + } + foreach ($d in $decisions) { + $affects = @($units | Where-Object { $_.Lists.ContainsKey('Live') -and $d.Id -in $_.Lists['Live'] } | ForEach-Object { $_.Id }) + $lines.Add("| $($d.Id) | $(Format-IdList -Ids $affects) |") + } + ,@($lines) +} + +function Get-QuestionAffectsProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $questions = @($Records | Where-Object { $_.Kind -eq 'Question' } | Sort-Object Id) + $units = @($Records | Where-Object { $_.Kind -eq 'Unit' }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Question | Blocks |') + $lines.Add('|---|---|') + if ($questions.Count -eq 0) { + $lines.Add('| _(no question records yet)_ | |') + } + foreach ($q in $questions) { + $affects = @($units | Where-Object { $_.Lists.ContainsKey('Questions') -and $q.Id -in $_.Lists['Questions'] } | ForEach-Object { $_.Id }) + $lines.Add("| $($q.Id) | $(Format-IdList -Ids $affects) |") + } + ,@($lines) +} + +function Get-InvariantsProjectionContent { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $invariants = @($Records | Where-Object { $_.Kind -eq 'Invariant' -and $_.Scalars['Status'] -eq 'active' } | Sort-Object { [int]($_.Id -replace '^I', '') }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| | Statement | Owner | Enforcement | Evidence |') + $lines.Add('|---|---|---|---|---|') + foreach ($inv in $invariants) { + $statement = ($inv.Prose['Statement'] -replace '\s*\n\s*', ' ').Trim() + $owner = $inv.Scalars['Owner'] + $enforcement = $inv.Scalars['Enforcement'] + $evidence = @(if ($inv.Lists.ContainsKey('Evidence')) { @($inv.Lists['Evidence'] | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } else { @() }) + $evidenceCell = if ($evidence.Count -eq 0) { '—' } else { ($evidence -join ', ') } + $lines.Add("| **$($inv.Id)** | $statement | ``$owner`` | $enforcement | $evidenceCell |") + } + ,@($lines) +} + +function Get-RankSortKey { + param([string] $Rank) + if ($Rank -match '^\d+$') { return [double]$Rank } + [double]::MaxValue +} + +function Get-OutstandingProjectionContent { + <# + Renders WorkRef records whose State is OPEN, ordered by Rank - a numeric Rank (project + position, or a bare issue number) sorts first and low-to-high; a non-numeric Rank + (`milestone/`) has no board position to compare against a numeric one, so it sorts + after every numeric Rank and ties are broken by issue number (S14.3 does not promise a + single total order across sources, only that Rank is never absent). + + A closed WorkRef is not "outstanding work" and is left out here - it stays in + design/state/work/ as a record, per I16, but this projection only ever renders the + mirror's live half. + #> + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Records) + $refs = @($Records | Where-Object { $_.Kind -eq 'WorkRef' -and $_.Scalars['State'] -eq 'OPEN' } | + Sort-Object -Property @{ Expression = { Get-RankSortKey -Rank $_.Scalars['Rank'] } }, @{ Expression = { [int]$_.Scalars['Issue'] } }) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('| Rank | Issue | Title | Criteria | Mirrored at |') + $lines.Add('|---|---|---|---|---|') + if ($refs.Count -eq 0) { + $lines.Add('| _(no outstanding WorkRef records yet)_ | | | | |') + } + foreach ($ref in $refs) { + $issue = $ref.Scalars['Issue'] + $title = $ref.Scalars['Title'] + $rank = $ref.Scalars['Rank'] + $criteria = @(if ($ref.Lists.ContainsKey('Criteria')) { @($ref.Lists['Criteria'] | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } else { @() }) + $criteriaCell = if ($criteria.Count -eq 0) { '—' } else { ($criteria -join ', ') } + $mirroredAt = $ref.Scalars['MirroredAt'] + $lines.Add("| $rank | #$issue | $title | $criteriaCell | ``$mirroredAt`` |") + } + ,@($lines) +} + +function Get-AgentProjectionContent { + <# + S7.10. Renders only what a WorkRef record actually carries - Issue, Title, Criteria, + MirroredAt. Today's hand-authored agent blocks also carry Depends on, Out of scope and + Blocked item text that has no field in the WorkRef schema (design/10-design.md § WorkRef); + this projection does not invent one, so its output is a smaller, honest render rather + than a full reconstruction of what is on GitHub today. + #> + param([Parameter(Mandatory)] $Record) + + $title = $Record.Scalars['Title'] + $sliceId = $null + if ($title -and $title -match '^\s*(S\d+)\b') { $sliceId = $Matches[1] } + + $lines = [System.Collections.Generic.List[string]]::new() + if ($sliceId) { + $lines.Add("Run ``/slice $sliceId``.") + } else { + $lines.Add("Run the work item for issue #$($Record.Scalars['Issue']).") + } + $lines.Add('') + + $pin = $Record.Scalars['MirroredAt'] + if ($sliceId -and -not [string]::IsNullOrWhiteSpace($pin)) { + $lines.Add("- **Scope and criteria:** ``design/30-slices.md`` § $sliceId @ ``$pin``") + } + $criteria = @(if ($Record.Lists.ContainsKey('Criteria')) { @($Record.Lists['Criteria'] | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } else { @() }) + if ($criteria.Count -gt 0) { + $lines.Add("- **Criteria mirrored:** $($criteria -join ', ')") + } + $lines.Add('') + $lines.Add('Stop conditions and procedure: `.claude/commands/slice.md`. Not restated here.') + ,@($lines) +} + +# --------------------------------------------------------------------------------------------- +# Region location. A bare (projected) region is exactly one start marker and one end marker for +# the given id, start before end. A `:declared:` marker for the same id is a refusal (I29): the +# projector never writes inside a declared region. More than one start, more than one end, or an +# end before its start is RegionMalformed - refused, not repaired (S7.7). +# --------------------------------------------------------------------------------------------- +function Find-BareRegion { + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Lines, [Parameter(Mandatory)][string] $Id) + + $startPattern = "" + $declaredStartPattern = "" + $endPattern = "" + $declaredEndPattern = "" + + $starts = [System.Collections.Generic.List[int]]::new() + $ends = [System.Collections.Generic.List[int]]::new() + $declaredHit = $false + + for ($i = 0; $i -lt $Lines.Count; $i++) { + $t = $Lines[$i].Trim() + if ($t -eq $declaredStartPattern -or $t -eq $declaredEndPattern) { $declaredHit = $true; continue } + if ($t -eq $startPattern) { $starts.Add($i) } + if ($t -eq $endPattern) { $ends.Add($i) } + } + + if ($declaredHit) { + return [pscustomobject]@{ Found = $false; Refuse = $true; Reason = 'DeclaredRegion'; Detail = "id '$Id' is a declared region; the projector never writes inside one" } + } + if ($starts.Count -eq 0 -and $ends.Count -eq 0) { + return [pscustomobject]@{ Found = $false; Refuse = $false } + } + if ($starts.Count -ne 1 -or $ends.Count -ne 1 -or $ends[0] -le $starts[0]) { + return [pscustomobject]@{ Found = $false; Refuse = $true; Reason = 'RegionMalformed'; Detail = "region '$Id' is unbalanced or nested" } + } + [pscustomobject]@{ Found = $true; Refuse = $false; StartIndex = $starts[0]; EndIndex = $ends[0] } +} + +function Set-RegionBody { + param([Parameter(Mandatory)][AllowEmptyString()][string[]] $Lines, [Parameter(Mandatory)][int] $StartIndex, [Parameter(Mandatory)][int] $EndIndex, [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]] $NewBodyLines) + $before = @($Lines[0..$StartIndex]) + $after = @($Lines[$EndIndex..($Lines.Count - 1)]) + ,@($before + @($NewBodyLines) + $after) +} + +# --------------------------------------------------------------------------------------------- +# The main entry point. Groups region targets by document so each document is read once and +# written at most once, in the same pass, per target. +# --------------------------------------------------------------------------------------------- +function Invoke-DesignProjection { + param([Parameter(Mandatory)][string] $RepoPath, [switch] $DryRun) + + $graph = Read-DesignStateGraph -Path $RepoPath + $records = @($graph.Records) + + $targets = @( + [pscustomobject]@{ Id = 'units'; Document = 'design/state-index.md'; Render = { Get-UnitsProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'bound-by'; Document = 'design/state-index.md'; Render = { Get-BoundByProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'consumers'; Document = 'design/state-index.md'; Render = { Get-ConsumersProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'decision-affects'; Document = 'design/state-index.md'; Render = { Get-DecisionAffectsProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'question-affects'; Document = 'design/state-index.md'; Render = { Get-QuestionAffectsProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'outstanding'; Document = 'design/state-index.md'; Render = { Get-OutstandingProjectionContent -Records $records } } + [pscustomobject]@{ Id = 'invariants'; Document = 'design/20-contract.md'; Render = { Get-InvariantsProjectionContent -Records $records } } + ) + + $regions = [System.Collections.Generic.List[object]]::new() + $refusals = [System.Collections.Generic.List[object]]::new() + + foreach ($doc in @($targets | Select-Object -ExpandProperty Document -Unique)) { + $full = Join-Path $RepoPath $doc + $docTargets = @($targets | Where-Object { $_.Document -eq $doc }) + + if (-not (Test-Path -LiteralPath $full)) { + foreach ($t in $docTargets) { + $refusals.Add([pscustomobject]@{ Document = $doc; Id = $t.Id; Reason = 'DocumentMissing'; Detail = "$doc does not exist" }) + } + continue + } + + $lines = @(Get-Content -LiteralPath $full) + $changed = $false + + foreach ($t in $docTargets) { + $newBody = & $t.Render + $loc = Find-BareRegion -Lines $lines -Id $t.Id + + if ($loc.Refuse) { + $refusals.Add([pscustomobject]@{ Document = $doc; Id = $t.Id; Reason = $loc.Reason; Detail = $loc.Detail }) + continue + } + if (-not $loc.Found) { + $refusals.Add([pscustomobject]@{ Document = $doc; Id = $t.Id; Reason = 'RegionMissing'; Detail = "no '$($t.Id)' region found in $doc" }) + continue + } + + $regions.Add([pscustomobject]@{ Document = $doc; Id = $t.Id; Content = ($newBody -join "`n") }) + $lines = Set-RegionBody -Lines $lines -StartIndex $loc.StartIndex -EndIndex $loc.EndIndex -NewBodyLines $newBody + $changed = $true + } + + if ($changed -and -not $DryRun) { + $text = (($lines -join "`n") + "`n") + Set-Content -LiteralPath $full -Value $text -NoNewline -Encoding utf8NoBOM + } + } + + foreach ($wr in @($records | Where-Object { $_.Kind -eq 'WorkRef' })) { + $content = ((Get-AgentProjectionContent -Record $wr) -join "`n") + $regions.Add([pscustomobject]@{ Document = $null; Id = 'agent'; Subject = $wr.Id; Content = $content }) + } + + [pscustomobject]@{ + Regions = @($regions) + Refusals = @($refusals) + } +} + +# Guards the invocation so this script's tests can dot-source it - the same shape +# Test-DesignState.ps1, Read-DesignState.ps1 and Test-DesignDrift.ps1 already use. +if ($MyInvocation.InvocationName -ne '.') { + $Path = (Resolve-Path -LiteralPath $Path).Path + $result = Invoke-DesignProjection -RepoPath $Path -DryRun:$DryRun + + if ($DryRun) { + $result.Regions | ConvertTo-Json -Depth 6 + } + foreach ($r in $result.Refusals) { + Write-Warning "Update-DesignProjection: refused '$($r.Id)' in $($r.Document): $($r.Reason) - $($r.Detail)" + } + + if ($result.Refusals.Count -gt 0) { exit 1 } + exit 0 +} diff --git a/tools/Update-WorkMirror.Tests.ps1 b/tools/Update-WorkMirror.Tests.ps1 new file mode 100644 index 0000000..93320b7 --- /dev/null +++ b/tools/Update-WorkMirror.Tests.ps1 @@ -0,0 +1,217 @@ +#Requires -Version 7.0 +#Requires -Modules Pester + +<# + Mocks the seams above gh and git - Get-OpenIssueList, Get-ProjectItemPositions, + Get-CurrentWorkMirrorSha - rather than the native commands themselves, the same reason + Test-DesignDrift.Tests.ps1 gives: both read a native exit code as part of their answer and a + Mock cannot set $LASTEXITCODE. + + Every state set is written into $TestDrive; none of these tests read this repository's own + design/state/work/, so they do not start failing when a real WorkRef is mirrored. +#> + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Update-WorkMirror.ps1' + $script:PreDotSourceErrorActionPreference = $ErrorActionPreference + . $script:ScriptPath + + function New-Issue { + param([int] $Number, [string] $Title, [string] $State = 'OPEN', [string] $Body = '', $Milestone = $null) + [pscustomobject]@{ number = $Number; title = $Title; state = $State; body = $Body; milestone = $Milestone } + } + + function New-RepoRoot { + $dir = Join-Path $TestDrive ([Guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $dir + } +} + +AfterAll { + $ErrorActionPreference = $script:PreDotSourceErrorActionPreference + Set-StrictMode -Off +} + +Describe 'Update-WorkMirror' { + + BeforeEach { + # Default: no project resolvable, so a test that never mentions Get-CurrentRepoOwnerName + # exercises the milestone/issue-number tiers rather than making a real `gh repo view` + # call. Tests that need the project tier override this in their own body. + Mock Get-CurrentRepoOwnerName { $null } + } + + Context 'the freeze gate (S14.5)' { + It 'does not run and says so when design/FROZEN.md exists' { + $repo = New-RepoRoot + New-Item -ItemType Directory -Path (Join-Path $repo 'design') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $repo 'design/FROZEN.md') -Value "# design/ is frozen`n`nFrozen because: testing`nLifts when: never`n" + Mock Get-OpenIssueList { throw 'must not be called while frozen' } + + $r = Invoke-WorkMirrorUpdate -RepoPath $repo + + $r.State | Should -Be 'Frozen' + $r.Written.Count | Should -Be 0 + Test-Path -LiteralPath (Join-Path $repo 'design/state/work') | Should -BeFalse + } + } + + Context 'gh unavailable (S14.4)' { + It 'reports could-not-evaluate and writes no mirror' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ Issues = @(); Failure = (New-WorkMirrorFailure -Reason 'GhUnavailable' -Detail 'gh exited 1') } } + + $r = Invoke-WorkMirrorUpdate -RepoPath $repo + + $r.State | Should -Be 'NotEvaluated' + $r.CouldNotEvaluate.Count | Should -Be 1 + $r.CouldNotEvaluate[0].Reason | Should -Be 'GhUnavailable' + $r.Written.Count | Should -Be 0 + Test-Path -LiteralPath (Join-Path $repo 'design/state/work') | Should -BeFalse + } + + It 'never writes an empty mirror on failure, even if design/state/work already holds records' { + $repo = New-RepoRoot + $workDir = Join-Path $repo 'design/state/work' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + Set-Content -LiteralPath (Join-Path $workDir '9.md') -Value "# work/9`nIssue: 9`nTitle: existing`nState: OPEN`nRank: 9`nMirroredAt: deadbee`nCriteria:`n" + Mock Get-OpenIssueList { [pscustomobject]@{ Issues = @(); Failure = (New-WorkMirrorFailure -Reason 'GhUnavailable' -Detail 'no auth') } } + + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + (Get-Content -LiteralPath (Join-Path $workDir '9.md') -Raw) | Should -Match 'MirroredAt: deadbee' + } + } + + Context 'writing records (S14.1, S14.2)' { + It 'writes a WorkRef record per open issue and nothing else' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 57 -Title 'S14 — Work state' -Body "### Done when`n- [ ] **S14.1** first`n- [x] **S14.2** second")) + Failure = $null + } } + Mock Get-ProjectItemPositions { $null } + Mock Get-CurrentWorkMirrorSha { 'abc1234' } + + $r = Invoke-WorkMirrorUpdate -RepoPath $repo + + $r.State | Should -Be 'Clean' + $r.Written.Count | Should -Be 1 + $file = Join-Path $repo 'design/state/work/57.md' + Test-Path -LiteralPath $file | Should -BeTrue + $text = Get-Content -LiteralPath $file -Raw + $text | Should -Match '# work/57' + $text | Should -Match 'Issue: 57' + $text | Should -Match 'Title: S14 — Work state' + $text | Should -Match 'State: OPEN' + $text | Should -Match 'MirroredAt: abc1234' + $text | Should -Match 'Criteria: S14\.1, S14\.2' + } + + It 'stamps MirroredAt even when a write changes no other field' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 57 -Title 'S14 — Work state' -Body "- [ ] **S14.1** first")) + Failure = $null + } } + Mock Get-ProjectItemPositions { $null } + Mock Get-CurrentWorkMirrorSha { 'sha0001' } + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + Mock Get-CurrentWorkMirrorSha { 'sha0002' } + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + $text = Get-Content -LiteralPath (Join-Path $repo 'design/state/work/57.md') -Raw + $text | Should -Match 'MirroredAt: sha0002' + } + + It 'writes never carry an Issue, Milestone or git side effect - fields are the closed WorkRef vocabulary only' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 3 -Title 'A story with no ids' -Body "- [ ] plain bullet, no bolded id")) + Failure = $null + } } + Mock Get-ProjectItemPositions { $null } + Mock Get-CurrentWorkMirrorSha { 'zzz9999' } + + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + $text = Get-Content -LiteralPath (Join-Path $repo 'design/state/work/3.md') -Raw + $text | Should -Match 'Criteria:\s*$' + } + } + + Context 'Rank degradation (S14.3)' { + It 'uses the project position when the issue is in the project' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 12 -Title 'In the project')) + Failure = $null + } } + Mock Get-CurrentRepoOwnerName { [pscustomobject]@{ Owner = 'boyank'; Name = 'AgentKit' } } + Mock Get-ProjectItemPositions { @{ 12 = 3 } } + Mock Get-CurrentWorkMirrorSha { 'sha' } + + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + (Get-Content -LiteralPath (Join-Path $repo 'design/state/work/12.md') -Raw) | Should -Match 'Rank: 3' + } + + It 'falls back to the milestone number when no project has the issue' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 12 -Title 'Milestoned' -Milestone ([pscustomobject]@{ number = 5; title = 'M1' }))) + Failure = $null + } } + Mock Get-ProjectItemPositions { $null } + Mock Get-CurrentWorkMirrorSha { 'sha' } + + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + (Get-Content -LiteralPath (Join-Path $repo 'design/state/work/12.md') -Raw) | Should -Match 'Rank: milestone/5' + } + + It 'falls back to the issue number when neither a project nor a milestone places it, and raises no finding' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 12 -Title 'Neither')) + Failure = $null + } } + Mock Get-ProjectItemPositions { $null } + Mock Get-CurrentWorkMirrorSha { 'sha' } + + $r = Invoke-WorkMirrorUpdate -RepoPath $repo + + (Get-Content -LiteralPath (Join-Path $repo 'design/state/work/12.md') -Raw) | Should -Match 'Rank: 12' + $r.State | Should -Be 'Clean' + } + + It 'never emits a WorkRef with an absent Rank' { + $repo = New-RepoRoot + Mock Get-OpenIssueList { [pscustomobject]@{ + Issues = @((New-Issue -Number 1 -Title 'a'), (New-Issue -Number 2 -Title 'b' -Milestone ([pscustomobject]@{ number = 9 }))) + Failure = $null + } } + Mock Get-ProjectItemPositions { @{ 1 = 1 } } + Mock Get-CurrentWorkMirrorSha { 'sha' } + + Invoke-WorkMirrorUpdate -RepoPath $repo | Out-Null + + foreach ($n in 1, 2) { + (Get-Content -LiteralPath (Join-Path $repo "design/state/work/$n.md") -Raw) | Should -Match 'Rank: \S+' + } + } + } + + Context 'invocation shape (S14.6)' { + It 'is the only script under tools/ that invokes gh issue create, gh label, gh milestone, or git commit' { + $text = Get-Content -LiteralPath $script:ScriptPath -Raw + $text | Should -Not -Match 'gh\s+issue\s+create' + $text | Should -Not -Match 'gh\s+label' + $text | Should -Not -Match 'gh\s+milestone' + $text | Should -Not -Match 'git\s+commit' + $text | Should -Not -Match 'git\s+push' + } + } +} diff --git a/tools/Update-WorkMirror.ps1 b/tools/Update-WorkMirror.ps1 new file mode 100644 index 0000000..d5ef7a0 --- /dev/null +++ b/tools/Update-WorkMirror.ps1 @@ -0,0 +1,275 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + The mirror generator: refreshes WorkRef records from the tracker + (design/20-contract.md § tools/Update-WorkMirror.ps1). + +.DESCRIPTION + Writes design/state/work/.md records and nothing else - never an issue, never a + label, never a milestone, never git (S14.1). `/track`'s alone; no other command invokes it + and no other command writes a WorkRef. + + One WorkRef per currently-open issue. `Rank` degrades rather than failing: the issue's + position in the per-repository GitHub Project when one places it, otherwise its milestone + number, otherwise the issue number itself - falling through is not a finding, and an + emitted WorkRef never lacks a Rank (S14.3). `Criteria` is read from `- [ ] ****` + checkbox lines in the issue body, the same shape every issue template in this kit uses; an + issue with none yields an empty list, not an absent field. + + `MirroredAt` is stamped with the current commit on every write, including a write that + changed no other field (S14.2) - that stamp is the mirror's only claim to currency, and + Test-DesignState.ps1's MirrorStale class is what a stale one costs (S14.7). + + Two ways this run does not touch the mirror at all: `design/FROZEN.md` present (S14.5, + because `/track` does not run during a freeze either), and `gh` missing or unauthenticated + (S14.4) - the second is could-not-evaluate, and never an empty mirror standing in for one + that could not be read. + +.PARAMETER RepoPath + Repository root. Defaults to the current directory. + +.PARAMETER Repository + owner/repo. Defaults to the current git remote, via gh's own resolution. + +.EXAMPLE + pwsh ./tools/Update-WorkMirror.ps1 +#> +[CmdletBinding()] +param( + [string] $RepoPath = (Get-Location).Path, + [string] $Repository +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Both gh-dependent helpers below read a native command's *exit code* as part of their answer, +# which $PSNativeCommandUseErrorActionPreference = $true (the 7.3+ default) would turn into a +# terminating error under Stop. Test-DesignDrift.ps1 and Test-DesignState.ps1 both assign this +# for the same reason; assigning it is inert on versions that predate the preference. +$PSNativeCommandUseErrorActionPreference = $false + +function New-WorkMirrorFailure { + param([Parameter(Mandatory)][string] $Reason, [Parameter(Mandatory)][string] $Detail) + [pscustomobject]@{ Reason = $Reason; Detail = $Detail } +} + +function New-WorkMirrorResult { + param( + [Parameter(Mandatory)][string] $State, + [object[]] $Written = @(), + [object[]] $CouldNotEvaluate = @() + ) + [pscustomobject]@{ + State = $State + Written = @($Written) + CouldNotEvaluate = @($CouldNotEvaluate) + } +} + +<# + Every checkbox under a "Done when" section in an issue template carries its id as the + entire bolded lead of the line - `- [ ] **S14.1** ...` - regardless of the id's own scheme + (slice criteria, or anything a future template invents). A bug or story issue with no ids + yields an empty list, which is the grammar's own distinction between "empty" and "absent" + (design/20-contract.md § The state set) - this always emits the field, never omits it. +#> +function Get-IssueCriteriaIds { + param([string] $Body) + if ([string]::IsNullOrWhiteSpace($Body)) { return @() } + + $ids = [System.Collections.Generic.List[string]]::new() + foreach ($line in ($Body -split "`r?`n")) { + if ($line -match '^\s*-\s*\[[ xX]\]\s*\*\*([^\*]+)\*\*') { + $ids.Add($Matches[1].Trim()) + } + } + @($ids) +} + +function Get-OpenIssueList { + param([string] $Repository) + + $ghArgs = @('issue', 'list', '--state', 'open', '--limit', '500', '--json', 'number,title,state,body,milestone') + if ($Repository) { $ghArgs += @('-R', $Repository) } + + try { + $json = & gh @ghArgs 2>$null + if ($LASTEXITCODE -ne 0) { + return [pscustomobject]@{ Issues = @(); Failure = (New-WorkMirrorFailure -Reason 'GhUnavailable' -Detail "gh exited $LASTEXITCODE") } + } + } catch { + return [pscustomobject]@{ Issues = @(); Failure = (New-WorkMirrorFailure -Reason 'GhUnavailable' -Detail $_.Exception.Message) } + } + + if ([string]::IsNullOrWhiteSpace(($json -join ''))) { + return [pscustomobject]@{ Issues = @(); Failure = $null } + } + + try { + $parsed = ($json -join "`n") | ConvertFrom-Json + } catch { + return [pscustomobject]@{ Issues = @(); Failure = (New-WorkMirrorFailure -Reason 'TrackerUnreadable' -Detail $_.Exception.Message) } + } + + [pscustomobject]@{ Issues = @($parsed); Failure = $null } +} + +<# + Best-effort only, per the same convention track.md already states for the `project` scope: + a repository with no matching project, or a `gh project` call that fails for any reason + (missing scope among them), is not a could-not-evaluate here - it is the ordinary case that + sends Rank to the next tier down (S14.3). Returns $null on any of those; a hashtable of + issue number -> 1-based board position otherwise. +#> +function Get-ProjectItemPositions { + param([Parameter(Mandatory)][string] $Owner, [Parameter(Mandatory)][string] $RepoName) + + try { + $projJson = & gh project list --owner $Owner --format json 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($projJson -join ''))) { return $null } + $projects = ($projJson -join "`n") | ConvertFrom-Json + } catch { + return $null + } + + $project = @($projects.projects) | Where-Object { $_.title -eq $RepoName } | Select-Object -First 1 + if (-not $project) { return $null } + + try { + $itemsJson = & gh project item-list $project.number --owner $Owner --format json 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($itemsJson -join ''))) { return $null } + $items = ($itemsJson -join "`n") | ConvertFrom-Json + } catch { + return $null + } + + $positions = @{} + $rank = 1 + foreach ($item in @($items.items)) { + if ($item.content -and $item.content.number) { + $positions[[int]$item.content.number] = $rank + } + $rank++ + } + $positions +} + +function Get-CurrentRepoOwnerName { + param([string] $Repository) + + if ($Repository -and $Repository.Contains('/')) { + $parts = $Repository -split '/', 2 + return [pscustomobject]@{ Owner = $parts[0]; Name = $parts[1] } + } + + try { + $json = & gh repo view --json owner,name 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($json -join ''))) { return $null } + $parsed = ($json -join "`n") | ConvertFrom-Json + } catch { + return $null + } + if (-not $parsed.owner -or -not $parsed.owner.login) { return $null } + [pscustomobject]@{ Owner = $parsed.owner.login; Name = $parsed.name } +} + +function Get-IssueRank { + param([Parameter(Mandatory)] $Issue, $ProjectPositions) + + if ($ProjectPositions -and $ProjectPositions.ContainsKey([int]$Issue.number)) { + return "$($ProjectPositions[[int]$Issue.number])" + } + if ($Issue.milestone -and $Issue.milestone.number) { + return "milestone/$($Issue.milestone.number)" + } + return "$($Issue.number)" +} + +function Get-CurrentWorkMirrorSha { + param([Parameter(Mandatory)][string] $RepoPath) + try { + Push-Location $RepoPath + $sha = (& git rev-parse HEAD 2>$null) + if ($LASTEXITCODE -ne 0) { return $null } + return $sha.Trim() + } finally { + Pop-Location + } +} + +function ConvertTo-WorkRefLines { + param([Parameter(Mandatory)] $Issue, [Parameter(Mandatory)][string] $Rank, [Parameter(Mandatory)][string] $Sha) + + $criteria = Get-IssueCriteriaIds -Body $Issue.body + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("# work/$($Issue.number)") + $lines.Add("Issue: $($Issue.number)") + $lines.Add("Title: $($Issue.title)") + $lines.Add("State: $($Issue.state)") + $lines.Add("Rank: $Rank") + $lines.Add("MirroredAt: $Sha") + $lines.Add("Criteria: $($criteria -join ', ')") + ,@($lines) +} + +<# + The main entry point. RepoPath scopes both the freeze check and where records land; + Repository (owner/repo) is passed through to gh exactly as Test-DesignDrift.ps1 does, and + left empty to let gh resolve the current remote itself. +#> +function Invoke-WorkMirrorUpdate { + param([Parameter(Mandatory)][string] $RepoPath, [string] $Repository) + + $frozenMarker = Join-Path $RepoPath 'design/FROZEN.md' + if (Test-Path -LiteralPath $frozenMarker) { + return New-WorkMirrorResult -State 'Frozen' + } + + $issueList = Get-OpenIssueList -Repository $Repository + if ($issueList.Failure) { + return New-WorkMirrorResult -State 'NotEvaluated' -CouldNotEvaluate @($issueList.Failure) + } + + $sha = Get-CurrentWorkMirrorSha -RepoPath $RepoPath + if (-not $sha) { + return New-WorkMirrorResult -State 'NotEvaluated' -CouldNotEvaluate @((New-WorkMirrorFailure -Reason 'ShallowCheckout' -Detail 'no history to resolve HEAD')) + } + + $repoInfo = Get-CurrentRepoOwnerName -Repository $Repository + $projectPositions = if ($repoInfo) { Get-ProjectItemPositions -Owner $repoInfo.Owner -RepoName $repoInfo.Name } else { $null } + + $workDir = Join-Path $RepoPath 'design/state/work' + if ($issueList.Issues.Count -gt 0 -and -not (Test-Path -LiteralPath $workDir)) { + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + } + + $written = [System.Collections.Generic.List[object]]::new() + foreach ($issue in $issueList.Issues) { + $rank = Get-IssueRank -Issue $issue -ProjectPositions $projectPositions + $lines = ConvertTo-WorkRefLines -Issue $issue -Rank $rank -Sha $sha + $file = Join-Path $workDir "$($issue.number).md" + $text = (($lines -join "`n") + "`n") + Set-Content -LiteralPath $file -Value $text -NoNewline -Encoding utf8NoBOM + $written.Add([pscustomobject]@{ Id = "work/$($issue.number)"; Path = $file }) + } + + New-WorkMirrorResult -State 'Clean' -Written @($written) +} + +# Guards the invocation so this script's tests can dot-source it - the same shape +# Test-DesignState.ps1, Read-DesignState.ps1 and Test-DesignDrift.ps1 already use. +if ($MyInvocation.InvocationName -ne '.') { + $RepoPath = (Resolve-Path -LiteralPath $RepoPath).Path + $result = Invoke-WorkMirrorUpdate -RepoPath $RepoPath -Repository $Repository + + switch ($result.State) { + 'Frozen' { Write-Host "design/FROZEN.md is present; the mirror is not refreshed." } + 'NotEvaluated' { foreach ($f in $result.CouldNotEvaluate) { Write-Warning "Update-WorkMirror: $($f.Reason) - $($f.Detail)" } } + 'Clean' { Write-Host "Wrote $($result.Written.Count) WorkRef record(s)." } + } + + $result + if ($result.State -eq 'NotEvaluated') { exit 2 } + exit 0 +} From 4952dacae89238c286411be746bbbf02b2c09433 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 12:13:14 +0300 Subject: [PATCH 4/6] Regenerate human docs for the W78-W83 tracker sync; refresh verify-local gate table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit engine/TODO.md and its compatibility pointer were stale against design/30-slices.md after the tracker sync — build/Test-Documentation.ps1 caught it. Also updated verify-local.md's gate-commands table: verify.yml no longer flags Test-Companion.ps1 or Test-DesignState.ps1 as CI gates. --- .claude/commands/verify-local.md | 9 ++++----- design/TODO.md | 12 ++++++------ docs/docs/engine/TODO.md | 24 ++++++++++++------------ 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.claude/commands/verify-local.md b/.claude/commands/verify-local.md index b405403..7ba6856 100644 --- a/.claude/commands/verify-local.md +++ b/.claude/commands/verify-local.md @@ -9,8 +9,6 @@ | `Consumer smoke` | `ci.yml` | Clear `consumer-smoke/{node_modules,package-lock.json,dist}`, then `npm run install:engine && npm run build && npm run smoke` | | `Parse-check PowerShell scripts` | `verify.yml` | Parse every `*.ps1` with `[System.Management.Automation.Language.Parser]::ParseFile`, as the step does | | `Run Pester tests` | `verify.yml` | `Invoke-Pester -Path tools -Output Detailed -PassThru` | -| `Validate the core/companion split` | `verify.yml` | `./tools/Test-Companion.ps1` | -| `Check the design state against the tree` | `verify.yml` | `./tools/Test-DesignState.ps1` | | `Validate Markdown links, terminology, and generated files` | `docs-ci.yml` | `./build/Test-Documentation.ps1` | | `Build documentation` | `docs-ci.yml` | `./docs.ps1 -BuildOnly` — needs Docker **and** an installed `docs.ps1` | | `Build and verify landing page` | `docs-ci.yml` | `npm --prefix src/engine run build; npm --prefix site run check` | @@ -19,9 +17,10 @@ | `Positive route and probe smoke` | `host-image.yml` | Build and run the host image, then `curl` `/`, `/roadmap/`, `/docs/`, `/health/live`, `/health/ready` → 200 and an unknown route → 404 | | `Negative fixture -- corrupted artifact must fail to start` | `host-image.yml` | `docker build -f tools/host-smoke/Dockerfile.negative-fixture …`; the run must exit non-zero | -Discover by reading the workflow files rather than trusting a memorized list; the seventeen rows -above describe this repository's gates as of the last sync, not a substitute for reading -`.github/workflows/*.yml` directly. +Discover by reading the workflow files rather than trusting a memorized list; the fifteen rows +above describe this repository's gates as of the last sync (2026-08-21), not a substitute for +reading `.github/workflows/*.yml` directly. `Test-Companion.ps1` and `Test-DesignState.ps1` are +no longer `# verification: true` flagged in `verify.yml` — worth running, but not required gates. Local discovery, beyond the workflow files: ```powershell diff --git a/design/TODO.md b/design/TODO.md index c5789ba..78eb487 100644 --- a/design/TODO.md +++ b/design/TODO.md @@ -208,19 +208,19 @@ ### [x] W77 — Tier 1 and Tier 2 as an Author-Facing Check {#w77} -### [ ] W78 — Localization Coverage and String Extraction {#w78} +### [x] W78 — Localization Coverage and String Extraction {#w78} -### [ ] W79 — What Changed Between Two Resolutions {#w79} +### [x] W79 — What Changed Between Two Resolutions {#w79} -### [ ] W80 — Seeing a Story Graph {#w80} +### [x] W80 — Seeing a Story Graph {#w80} ### Correctness Debt — The Tick Pipeline Runs Twenty Systems and Implements Fifteen -### [ ] W81 — Construction Finishes What `build` Starts {#w81} +### [x] W81 — Construction Finishes What `build` Starts {#w81} -### [ ] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} +### [x] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} -### [ ] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} +### [x] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} ### [ ] W84 — Incidents That Happen On Their Own {#w84} diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 9e0884e..13337d0 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -3019,7 +3019,7 @@ rather than paper over. `export-campaigns.ts` onto the shared catalogue; adding, tightening or relaxing any validation rule — this unit surfaces the checks that exist and authors none. -### [ ] W78 — Localization Coverage and String Extraction {#w78} +### [x] W78 — Localization Coverage and String Extraction {#w78} **Delivers:** A translator can be handed the exact list of lines a campaign needs, and a maintainer can see which campaigns are translated, which are half-translated and which are not @@ -3040,7 +3040,7 @@ of nine has a second locale. `src/engine/src/core/localization/` and `src/engine/src/core/registry/` are **read, not modified**. - **Depends on:** [W77](#w77), for the shared campaign catalogue. -- **Status:** Not started. +- **Status:** Done — [issue #326](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/326). - **Done when:** - W78.1 For a named campaign the tool emits the complete, sorted set of `LocKey`s its built campaign requires — the keys `BuiltCampaign.strings` carries — in a form a translator @@ -3065,7 +3065,7 @@ of nine has a second locale. [09 §9](09-clients.md#9-deferred) leaves to a client; making coverage a CI gate, which is the same policy decision W77 declines to take unilaterally. -### [ ] W79 — What Changed Between Two Resolutions {#w79} +### [x] W79 — What Changed Between Two Resolutions {#w79} **Delivers:** When two saves say they were played against different content, someone can find out what was actually different. The platform can already prove two content resolutions are not @@ -3086,7 +3086,7 @@ it rather than a widening of it. `src/engine/src/core/registry/packs.ts` and `src/engine/src/campaigns/stable-life-packs.ts` are **read, not modified**. - **Depends on:** [W58](#w58), [W71](#w71) — both done. -- **Status:** Not started. +- **Status:** Done — [issue #327](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/327). - **Done when:** - W79.1 Given two ordered pack sets the tool reports both `ResolutionId`s and, when they differ, an itemized difference following [11 §3](11-content-packs.md#3-resolution)'s two @@ -3113,7 +3113,7 @@ it rather than a widening of it. Adventures.Content ([W74](#w74)); pack discovery and loading from disk ([11 §8](11-content-packs.md#8-what-is-deferred)). -### [ ] W80 — Seeing a Story Graph {#w80} +### [x] W80 — Seeing a Story Graph {#w80} **Delivers:** Anyone looking at an authored adventure can see its shape — where it starts, where it branches, which endings hang off which route — instead of reconstructing it from several @@ -3129,7 +3129,7 @@ any of it. - **Touches:** a new script under `src/engine/scripts/`; `src/engine/package.json` scripts. - **Depends on:** [W77](#w77), for the shared campaign catalogue and for the Tier 2 warnings W80.4 cross-checks against. -- **Status:** Not started. +- **Status:** Done — [issue #328](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/328). - **Done when:** - W80.1 For a named story-graph campaign the tool emits a text graph — Mermaid, which needs no dependency to write — with one vertex per node, edges labelled by choice id, and @@ -3213,7 +3213,7 @@ but extracting a shared runner from a pipeline where a quarter of the systems ar extract the shape of the stubs. It becomes sliceable once W85 lands, and it is better for the wait: five more real systems is five more constraints on what the abstraction has to carry. -### [ ] W81 — Construction Finishes What `build` Starts {#w81} +### [x] W81 — Construction Finishes What `build` Starts {#w81} **Delivers:** Someone laying out a resort can put up a structure that takes time to build, watch their staff work on it, and then use it. Today starting one is a dead end — the site appears, no @@ -3237,7 +3237,7 @@ generated, never given effort, and never applied. `src/engine/fixtures/replay/`. - **Depends on:** [W45](#w45), [W46](#w46), [W47](#w47) — all done; they shipped the reducer, the construction-site state and the comparator registry. -- **Status:** Not started. +- **Status:** Done — [PR #345](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/345). - **Done when:** - W81.1 A building definition declaring construction work, placed through `build`, opens a site whose remaining work falls by the assigned staff member's `build` effort per tick on every @@ -3281,7 +3281,7 @@ generated, never given effort, and never applied. has no construction path at all; the shared `SystemPipeline` ([issue #270](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/270)). -### [ ] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} +### [x] W82 — A Kiosk That Ran Out Can Be Refilled {#w82} **Delivers:** A stall that sells a limited number of things can be restocked by staff, so running out is a setback rather than the permanent end of that building's usefulness. Today the running-out @@ -3299,7 +3299,7 @@ and system 13 also owns the non-wear operational status changes that no other sy `src/engine/src/kinds/world-graph/kind.ts`; `src/engine/src/campaigns/world-graph-mvp.ts`; a new fixture pair under `src/engine/fixtures/replay/`. - **Depends on:** [W81](#w81), for the systems 9 and 11 task-kind branches it establishes. -- **Status:** Not started. +- **Status:** Done — [PR #347](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/347). - **Done when:** - W82.1 A service product declaring initial units and a capacity is served down to zero, a restock candidate is generated with effort equal to the missing units and the priority the @@ -3328,7 +3328,7 @@ and system 13 also owns the non-wear operational status changes that no other sy name; supply chains, warehouses or delivery; pricing behaviour, which `set_price` already owns; cleanliness and wear ([W83](#w83)). -### [ ] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} +### [x] W83 — Buildings Get Dirty, Wear Out, and Break {#w83} **Delivers:** A resort that is left unattended degrades, and one that is looked after does not. Buildings get dirty from use and from litter, wear down over time, and eventually break and stop @@ -3350,7 +3350,7 @@ system is half named after. a new fixture pair under `src/engine/fixtures/replay/`. - **Depends on:** nothing — it shares no code path with [W81](#w81) or [W82](#w82) and may be taken before either. It is placed here because [W84](#w84) depends on it. -- **Status:** Not started. +- **Status:** Done — [PR #348](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/pull/348). - **Done when:** - W83.1 For one building receiving deltas from every source in the same tick, the five sources are applied in the contract's stated order — service, litter, incident, staff, policy — From 1919e938ea79995b581180b06b42f719ddfbed6f Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 12:24:50 +0300 Subject: [PATCH 5/6] verify: record this pass's gate report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 discovered gates: 9 passed, 1 failed (Pester — the kit's self-referential design/state/ tests, not applicable to this repo's design/ shape), 5 did not run (docs.ps1 absent, NUGET_GITHUB_TOKEN unavailable locally). See PR #359's Verified section for the rendered report. --- .claude/verify-report.json | 90 +++++++------------------------------- 1 file changed, 15 insertions(+), 75 deletions(-) diff --git a/.claude/verify-report.json b/.claude/verify-report.json index 75cb0bb..faee1e4 100644 --- a/.claude/verify-report.json +++ b/.claude/verify-report.json @@ -1,79 +1,19 @@ { "gates": [ - { - "name": "Typecheck", - "status": "Passed", - "detail": "npm --prefix src/engine run typecheck — tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json — completed with no errors." - }, - { - "name": "Lint", - "status": "Passed", - "detail": "npm --prefix src/engine run lint — eslint src scripts — completed with no errors." - }, - { - "name": "Test", - "status": "Passed", - "detail": "npm --prefix src/engine test — vitest run — Test Files 79 passed (79), Tests 1121 passed (1121), Duration 7.78s." - }, - { - "name": "Pack package", - "status": "Passed", - "detail": "cd src/engine; npm pack --silent — produced the-running-dev-game-engine-0.10.0.tgz with no errors." - }, - { - "name": "Inspect tarball", - "status": "Passed", - "detail": "tar -tzf the-running-dev-game-engine-0.10.0.tgz | wc -l => 311 entries. grep -c '^package/src/' => 0. grep -c 'tsconfig' => 0. grep -c '.test.' => 0. grep -c '^package/dist/' => 309. No src/, no tsconfig*.json, no .test.* artifacts, package/dist/ present." - }, - { - "name": "Consumer smoke", - "status": "Passed", - "detail": "Cleared consumer-smoke/{node_modules,package-lock.json,dist}; npm run install:engine (packed the-running-dev-game-engine-0.10.0.tgz, added 5 packages); npm run build (tsc -p tsconfig.json, no errors); node dist/smoke.js exited with code 0." - }, - { - "name": "Parse-check PowerShell scripts", - "status": "Passed", - "detail": "[System.Management.Automation.Language.Parser]::ParseFile over every *.ps1 outside node_modules/consumer-smoke — Checked 21 files, 0 with errors." - }, - { - "name": "Run Pester tests", - "status": "Passed", - "detail": "Invoke-Pester -Path tools -Output Detailed -PassThru — Tests completed in 29.56s. Tests Passed: 94, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0." - }, - { - "name": "Validate Markdown links, terminology, and generated files", - "status": "Passed", - "detail": "./build/Test-Documentation.ps1 — 'Human-documentation drift check passed across 18 generated engine page(s), compatibility pointers, and the guide.' 'Documentation checks passed across 123 Markdown file(s).' Exit code 0." - }, - { - "name": "Build documentation", - "status": "DidNotRun", - "reason": "docs.ps1 is not present in this checkout (it is generated by Invoke-SetupDocs, not committed). A Docker-based host image build was also attempted for other gates and its docs stage (RUN pwsh /template/scripts/docs-build.ps1) was cancelled when a sibling stage failed, so no local run of this gate's underlying build reached completion. Deferred to the 'Build documentation' step of the docs-ci.yml CI check on the pull request." - }, - { - "name": "Build and verify landing page", - "status": "Passed", - "detail": "npm --prefix src/engine run build (already produced by other gates) then npm --prefix site run check — oxlint clean; tsc -b clean; vitest run: Test Files 2 passed (2), Tests 15 passed (15); test:browser: Test Files 1 passed (1), Tests 11 passed (11); test:build: vite build succeeded (8 modules, dist output written), verify-build.mjs reported both entry points contain required static metadata and bundles are free of Node-only runtime references; test:merge: verify-merge.mjs reported 'Package-backed merge proven: /, /roadmap/ and the protected docs/ subtree all verified, and the top-level docs/ guard rejects a bad landing build.'" - }, - { - "name": "Merge landing page into documentation build", - "status": "DidNotRun", - "reason": "Requires a completed production docs build in artifacts/docs, which needs docs.ps1 (not present in this checkout, see 'Build documentation'). Deferred to the corresponding docs-ci.yml CI check on the pull request." - }, - { - "name": "Test the host", - "status": "DidNotRun", - "reason": "dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/SubZeroDev.GameEngine.Host.Tests.csproj needs NUGET_GITHUB_TOKEN for the sibling-repo (SubZeroDev.Platform.Hosting) NuGet feed; this environment variable is not set locally. Deferred to the 'Test the host' step of host-image.yml on the pull request." - }, - { - "name": "Positive route and probe smoke", - "status": "DidNotRun", - "reason": "Requires the built host image (docker build -t subzerodev-gameengine-host:smoke .). Docker daemon is available and the build was attempted, but the host-build stage's dotnet restore of SubZeroDev.Platform.Hosting failed: 'error : Value cannot be null or empty string. (Parameter 'username')' because /run/secrets/nuget_github_actor and /run/secrets/nuget_github_token are not present locally (NUGET_GITHUB_TOKEN not set). Deferred to host-image.yml on the pull request." - }, - { - "name": "Negative fixture -- corrupted artifact must fail to start", - "status": "DidNotRun", - "reason": "Requires the base image subzerodev-gameengine-host:smoke as --build-arg BASE_IMAGE, which did not build locally (see 'Positive route and probe smoke' — missing NUGET_GITHUB_TOKEN for the sibling-repo NuGet feed). Deferred to host-image.yml on the pull request." - } + { "name": "Typecheck", "status": "Passed", "detail": "npm --prefix src/engine run typecheck — exit 0" }, + { "name": "Lint", "status": "Passed", "detail": "npm --prefix src/engine run lint — exit 0" }, + { "name": "Test", "status": "Passed", "detail": "npm --prefix src/engine test (vitest) — Test Files 79 passed (79), Tests 1127 passed (1127)" }, + { "name": "Pack package", "status": "Passed", "detail": "cd src/engine; npm pack --silent — produced the-running-dev-game-engine-0.10.0.tgz, exit 0" }, + { "name": "Inspect tarball", "status": "Passed", "detail": "tar -tzf the-running-dev-game-engine-0.10.0.tgz: no src/, no tsconfig*.json, no .test.* entries; package/dist/ present" }, + { "name": "Consumer smoke", "status": "Passed", "detail": "consumer-smoke: cleared node_modules/package-lock.json/dist, then npm run install:engine && npm run build && npm run smoke — all three exited 0" }, + { "name": "Parse-check PowerShell scripts", "status": "Passed", "detail": "Parsed every tools/*.ps1 with [System.Management.Automation.Language.Parser]::ParseFile — 0 parse errors across all files" }, + { "name": "Run Pester tests", "status": "Failed", "detail": "Invoke-Pester -Path tools -Output Detailed -PassThru: FailedCount 12, PassedCount 241, TotalCount 253. All 12 failures are in the newly kit-synced Read-DesignState.Tests.ps1/Test-DesignState.Tests.ps1/Test-CIWorkflow.Tests.ps1/Update-DesignProjection.Tests.ps1 — self-referential tests (Split-Path $PSScriptRoot -Parent) that validate the agent-kit's own dogfooded design/state/ per-unit-record mechanism and its verify.yml shape against whatever repo they are copied into. Failing tests: S4.6 (unit/command/track and unit/document/agents-md exist and their closures are complete), the 'Run Pester tests' step carries a GH_TOKEN env test, 'this repository's own table and its own enumeration agree', S5.6 (the real run names a largest closure), S5.12, S7.9, S12.5, S12.6 Describe block (BeforeAll/AfterAll failed), S16.1/S16.2, S16.5, S17.2, S18.6, and Update-DesignProjection's S7.2 real-repo DryRun test. This repository's design/ uses 00-brief/10-design/20-contract/30-slices/90-decisions, not the kit's own design/state/ per-unit-record scheme, so these fail structurally (StateSetAbsent) rather than from a regression — the same class of gap Test-DesignState.ps1 itself reports as exit code 2 when run directly against this repo." }, + { "name": "Validate Markdown links, terminology, and generated files", "status": "Passed", "detail": "./build/Test-Documentation.ps1 — Human-documentation drift check passed across 18 generated engine page(s), compatibility pointers, and the guide. Documentation checks passed across 123 Markdown file(s)." }, + { "name": "Build documentation", "status": "DidNotRun", "reason": "docs.ps1 is absent from this checkout — CLAUDE.md states it is installer-generated (Invoke-SetupDocs), not committed, and installing it was out of scope for this change. Needs Docker and an installed docs.ps1; neither precondition was set up this session." }, + { "name": "Build and verify landing page", "status": "Passed", "detail": "npm --prefix src/engine run build (tsc) exit 0, then npm --prefix site run check: format:check/lint/typecheck/test/test:browser/test:build/test:merge all passed — Test Files 1 passed (1), Tests 11 passed (11) for test:browser; 'Both built HTML entry points contain their required static metadata, and 3 bundle(s) are free of Node-only runtime references'; 'Package-backed merge proven' for test:merge" }, + { "name": "Merge landing page into documentation build", "status": "DidNotRun", "reason": "npm --prefix site run merge needs a completed docs build in artifacts/docs, which the 'Build documentation' gate above did not produce (docs.ps1 absent)." }, + { "name": "Test the host", "status": "DidNotRun", "reason": "dotnet test src/host/SubZeroDev.GameEngine.Host.Tests requires NUGET_GITHUB_TOKEN for the sibling-repo NuGet feed; not set in this local environment." }, + { "name": "Positive route and probe smoke", "status": "DidNotRun", "reason": "Requires a built host image, which itself depends on restoring packages from the sibling-repo NuGet feed (NUGET_GITHUB_TOKEN, unavailable locally)." }, + { "name": "Negative fixture -- corrupted artifact must fail to start", "status": "DidNotRun", "reason": "Same dependency as 'Positive route and probe smoke': requires a built host image, blocked on the missing NUGET_GITHUB_TOKEN." } ] } From 8b008aacb0644f27062e058265362afecc1f7728 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 21 Aug 2026 19:06:17 +0300 Subject: [PATCH 6/6] Kit sync: pull kit HEAD 811bc96, keep local design-state self-test fix Cores taken outright: install-all.md, install-code-review-agent.md, kit-sync.md pick up the phase-4-step-8 wording sync from kit commits 1eca3dc/adcc76c/1bcfe36. The kit's own #109/#110 fix for the design-state self-tests bundles an S12.7 regression-guard block that assumes design/state exists in whatever repo the file runs in - this repo doesn't have it, so taking that file verbatim would fail S12.7's own assertion here. Kept this repo's existing equivalent -Skip: fix (already recorded in design/90-decisions.md, 2026-08-21) instead of overwriting it with the kit's file. kit.json commit bumped to 811bc96; syncedCommit already advanced by Sync-Kit.ps1. --- .claude/commands/install-all.md | 4 +-- .claude/commands/install-code-review-agent.md | 2 +- .claude/commands/kit-sync.md | 2 +- .claude/kit.json | 6 ++-- design/90-decisions.md | 31 +++++++++++++++++++ tools/Read-DesignState.Tests.ps1 | 10 ++++-- tools/Test-CIWorkflow.Tests.ps1 | 13 ++++++-- tools/Test-DesignState.Tests.ps1 | 18 ++++++++--- tools/Update-DesignProjection.Tests.ps1 | 10 ++++-- 9 files changed, 78 insertions(+), 18 deletions(-) diff --git a/.claude/commands/install-all.md b/.claude/commands/install-all.md index 9d18ab4..192f331 100644 --- a/.claude/commands/install-all.md +++ b/.claude/commands/install-all.md @@ -68,9 +68,9 @@ This is the mechanical backstop for everything above — the phase-2 rules say w ## Phase 3 — What must not happen, in any target -Same list as `INSTALL.md`'s, and unattended does not relax it — if anything it matters more, since nothing here waits for a human to notice a mistake before it repeats across the next repository: +`INSTALL.md`'s list, and unattended does not relax it — if anything it matters more, since nothing here waits for a human to notice a mistake before it repeats across the next repository. **One entry is tightened rather than inherited**, and it is the first: -- No commit, no push, no pull request, in any target. +- **No commit, no push, no pull request, in any target** — this is stricter than `INSTALL.md` phase 4 step 8, which delivers on a feature branch. Attended, one target, that branch is reviewable before anything else happens to it. Unattended, across every sibling repository, it is a pull request per repo that nobody asked for, opened faster than anyone can read them, on repositories whose forks this pass has already declined to answer. The write surface below is the whole of what an unattended pass leaves behind, and it stops at the working tree. - No `git add -A`, `git add .`, or bare-directory add — this command does not stage anything at all. - No deletion without approval, including proposed `agent.md` prunes — leave those unpruned and listed, not silently applied. - No write to a target's `settings.json`, `settings.local.json`, or `launch.json` beyond the (skipped, per phase 2) `SessionEnd` and `UserPromptSubmit` hooks. diff --git a/.claude/commands/install-code-review-agent.md b/.claude/commands/install-code-review-agent.md index 1d95661..af41e67 100644 --- a/.claude/commands/install-code-review-agent.md +++ b/.claude/commands/install-code-review-agent.md @@ -57,7 +57,7 @@ Write `/.github/workflows/claude-code-review.yml` with the chosen trigge - Whether the workflow file was created, and its path - Whether the GitHub App is already installed on the target (`gh api /repos/{owner}/{repo}/installation` if reachable; otherwise say it could not be checked) — if not, give the user the install URL and ask them to do it and confirm back - Whether `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) already exists as a repository secret (`gh secret list` — this shows names only, never values, so checking existence is safe) — if not, give the user the exact `gh secret set ` command to run **themselves**, in their own terminal -- **Do not commit, push, or open a pull request.** This command stops at the report for sign-off, the same as `/install` and `/kit-sync` — a workflow file that grants CI a repository secret is not something this command lands unattended. +- **Do not commit, push, or open a pull request.** This command stops at the report for sign-off. `/install` and `/kit-sync` no longer do (`INSTALL.md` phase 4 step 8) and this one still does, on its own reason rather than theirs: a workflow file that grants CI a repository secret is not something to land before the user has confirmed the App install and the secret exist. ## Never diff --git a/.claude/commands/kit-sync.md b/.claude/commands/kit-sync.md index b7049a0..b45ff00 100644 --- a/.claude/commands/kit-sync.md +++ b/.claude/commands/kit-sync.md @@ -69,7 +69,7 @@ Everything `INSTALL.md` phase 3 already requires, plus: - Force-push, reset, or discard uncommitted work in `~/.agent-kit`. It is shared across every repository that runs this command. - Hardcode a source URL as a fallback. Absent `kit.json` means asking, once. - Write, rewrite, or delete this repository's `.claude/commands/*-local.md`. They are the reason a routine sync can take every core outright; a sync that edited them would be reconciling the very thing the split moved out of its way. -- Commit or push anything in *this* repository — same as `/install`, this stops at the phase 3 report, and applies only after sign-off. +- Commit to, or push to, *this* repository's default branch. Delivery is `INSTALL.md` phase 4 step 8's feature branch and pull request, unchanged by syncing from a branch — this command adds nothing to it and does not restate it. ## Re-run diff --git a/.claude/kit.json b/.claude/kit.json index 4d10077..a7726ca 100644 --- a/.claude/kit.json +++ b/.claude/kit.json @@ -1,7 +1,7 @@ { "source": "https://github.com/The-Running-Dev/SubZeroDev.AgentKit", "branch": "main", - "commit": "e9e21bf15b7e00c93ec5ad6f8fab2748671dea1d", + "commit": "811bc96abe0bf6df0d1716c2f02cf311951b256d", "installed": "2026-08-21", - "syncedCommit": "e9e21bf15b7e00c93ec5ad6f8fab2748671dea1d" -} + "syncedCommit": "811bc96abe0bf6df0d1716c2f02cf311951b256d" +} \ No newline at end of file diff --git a/design/90-decisions.md b/design/90-decisions.md index 5f6a084..3133d8b 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -954,3 +954,34 @@ Rejected: **Leave the core edited and skip the sync for this file** — keeps th to receive any future `verify.md` update without repeating this exact reconciliation. Reversibility: cheap — the companion is one file under a declared category; deleting it reverts to the kit's own default gate discovery. + +### 2026-08-21 — The same kit sync's design-state self-tests are skipped, not satisfied +Context: the 693fa16 kit sync also brought `tools/Test-DesignState.Tests.ps1`, +`tools/Read-DesignState.Tests.ps1`, `tools/Update-DesignProjection.Tests.ps1` and +`tools/Test-CIWorkflow.Tests.ps1`. Each carries a Describe block asserting "against this +repository's own tree" — literal content of `~/.agent-kit`'s own `design/state/` (9 Contract +records, `design/state-index.md`, a specific decision file, a `Check the design state against +the tree` CI step with `GH_TOKEN`). That block is true of the kit repository, which has adopted +its own design-state mechanism, but this repository has not: the 2026-08-19 kit entry, "Design +state becomes addressable records...", scoped the mechanism as "proven on this repository's +[the kit's] own `design/` as its first and only migration; the eighteen installed targets get a +compatibility promise and are not migrated." The self-tests were never audited against that +promise, so `verify.yml`'s "Run Pester tests" step (`# verification: true`) went red here with +12 failures plus one `BeforeAll`/`AfterAll` abort (S12.6, `Remove-Item design/state` on a tree +that has none), none of which are a defect in this repository's own content. +Chosen: guard each self-referential `Describe`/`It` with `-Skip:` computed at Pester discovery +time from whether `design/state/` (or, for `Test-CIWorkflow.Tests.ps1`, the "Check the design +state against the tree" workflow step) exists in this repository — false and unevaluated rather +than a false pass or fail, mirroring how `Test-DesignState.ps1` itself already treats an +installed target's missing `design/state/` (S12.6: `StateSetAbsent`, exit 2, never a silent 0). +Rejected: **Adopt design-state here to satisfy the tests** — turns a CI fix into an unscoped +feature adoption the user did not ask for, against "one unit at a time." **Exclude these four +`.Tests.ps1` files from `Sync-Kit.ps1`'s copy** — leaves the underlying kit defect (a test suite +not scoped for its own compatibility promise) unfixed upstream, and silently drops future +coverage this repository's tools/*.ps1 counterparts still need. **Delete the self-referential +blocks entirely** — loses real regression coverage the kit repository itself relies on when this +file is later resynced there. +Reversibility: cheap here — four `-Skip:` guards, reverted by deleting them. Not yet applied +upstream: `~/.agent-kit`'s own copies of these four files carry the same unguarded blocks, so an +unmodified future `/kit-sync` will overwrite this fix. Worth raising with the kit maintainer +directly rather than resolving unattended from this repository. diff --git a/tools/Read-DesignState.Tests.ps1 b/tools/Read-DesignState.Tests.ps1 index 18ea99d..f63d857 100644 --- a/tools/Read-DesignState.Tests.ps1 +++ b/tools/Read-DesignState.Tests.ps1 @@ -9,9 +9,15 @@ Every fixture below is written into $TestDrive as design/state/... under a throwaway root; none of these tests (other than the S4.6 closure checks, which are explicit about reading the - real one) read this repository's own design/state/. + real one) read this repository's own design/state/. The S4.6 block asserts on this repo's + *own* adopted design-state content, so it is skipped (never a false pass or fail) on a + repository the kit's compatibility promise (design/90-decisions.md, 2026-08-19) says is not + migrated: no design/state/ at all. #> +$script:ReadDesignStateSelfTestRoot = Split-Path $PSScriptRoot -Parent +$script:SkipReadDesignStateSelfTests = -not (Test-Path (Join-Path $script:ReadDesignStateSelfTestRoot 'design/state')) + BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot 'Read-DesignState.ps1' $script:PreDotSourceErrorActionPreference = $ErrorActionPreference @@ -219,7 +225,7 @@ Affects: unit/command/track } } -Describe 'Read-DesignState against this repository''s own state set' { +Describe 'Read-DesignState against this repository''s own state set' -Skip:$script:SkipReadDesignStateSelfTests { BeforeAll { $script:RepoRoot = Split-Path $PSScriptRoot -Parent diff --git a/tools/Test-CIWorkflow.Tests.ps1 b/tools/Test-CIWorkflow.Tests.ps1 index 7f381a7..9e49d85 100644 --- a/tools/Test-CIWorkflow.Tests.ps1 +++ b/tools/Test-CIWorkflow.Tests.ps1 @@ -8,16 +8,25 @@ already does - so it needs the same GH_TOKEN. Without it, an unauthenticated gh turns S12.5 into a could-not-evaluate (TrackerUnavailable) rather than a check of anything this step is meant to gate. + + This regression only applies once the kit's design-state tracking is adopted here and + verify.yml has grown the "Check the design state against the tree" step this test compares + against - the kit's own compatibility promise (design/90-decisions.md, 2026-08-19) does not + migrate that step to installed targets, so its absence here is not a divergence to report. #> +$script:CIWorkflowPath = Join-Path (Split-Path $PSScriptRoot -Parent) '.github/workflows/verify.yml' +$script:SkipCIWorkflowGhTokenTest = -not (Test-Path $script:CIWorkflowPath) -or + -not (Select-String -LiteralPath $script:CIWorkflowPath -Pattern '- name: Check the design state against the tree' -Quiet) + Describe 'CI workflow: the Run Pester tests step is authenticated (#79)' { BeforeAll { - $script:WorkflowPath = Join-Path (Split-Path $PSScriptRoot -Parent) '.github/workflows/verify.yml' + $script:WorkflowPath = $script:CIWorkflowPath $script:Lines = Get-Content -LiteralPath $script:WorkflowPath } - It 'the "Run Pester tests" step carries a GH_TOKEN env, the same as "Check the design state against the tree"' { + It 'the "Run Pester tests" step carries a GH_TOKEN env, the same as "Check the design state against the tree"' -Skip:$script:SkipCIWorkflowGhTokenTest { $stepIndex = ($script:Lines | Select-String -Pattern '- name: Run Pester tests').LineNumber $stepIndex | Should -Not -BeNullOrEmpty diff --git a/tools/Test-DesignState.Tests.ps1 b/tools/Test-DesignState.Tests.ps1 index c823fdd..2388372 100644 --- a/tools/Test-DesignState.Tests.ps1 +++ b/tools/Test-DesignState.Tests.ps1 @@ -7,9 +7,15 @@ Test-DesignDrift.ps1, Wait-PullRequestCheck.ps1 and Read-DesignState.ps1 already use. Every fixture below is written into $TestDrive under a throwaway root; the final Describe - block is explicit about reading this repository's own tree instead. + block is explicit about reading this repository's own tree instead - it and the other + self-referential blocks below assert on this repo's *own* adopted design-state content, so + they are skipped (never a false pass or fail) on a repository the kit's compatibility + promise (design/90-decisions.md, 2026-08-19) says is not migrated: no design/state/ at all. #> +$script:DesignStateSelfTestRoot = Split-Path $PSScriptRoot -Parent +$script:SkipDesignStateSelfTests = -not (Test-Path (Join-Path $script:DesignStateSelfTestRoot 'design/state')) + BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot 'Test-DesignState.ps1' . $script:ScriptPath -Path $TestDrive @@ -776,7 +782,7 @@ trailing prose $result.CouldNotEvaluate.Detail | Should -Match 'GlobTableNotFound' } - It 'this repository''s own table and its own enumeration agree' -Tag 'NearMiss','GlobDisagreement' { + It 'this repository''s own table and its own enumeration agree' -Tag 'NearMiss','GlobDisagreement' -Skip:$script:SkipDesignStateSelfTests { $repo = Split-Path $PSScriptRoot -Parent $result = Test-GlobDisagreement -RepoPath $repo -ContractPath (Join-Path $repo 'design/20-contract.md') $result.CouldNotEvaluate | Should -BeNullOrEmpty @@ -1043,7 +1049,7 @@ Binds: I999 } } -Describe 'Test-DesignState against this repository''s own tree' { +Describe 'Test-DesignState against this repository''s own tree' -Skip:$script:SkipDesignStateSelfTests { BeforeAll { $script:RepoRoot = Split-Path $PSScriptRoot -Parent @@ -1300,13 +1306,15 @@ Binds: I999 } } -Describe 'S12.6: a checkout with design/state/ removed' { +Describe 'S12.6: a checkout with design/state/ removed' -Skip:$script:SkipDesignStateSelfTests { BeforeAll { # A real copy of this repository, minus design/state/ - the shape every installed target # has by construction, since nothing under the kit's own design/ is on INSTALL.md's # artifact list. Built from the tree rather than from a fixture so that "the state set is - # absent" is asserted against a checkout that is otherwise complete. + # absent" is asserted against a checkout that is otherwise complete. Requires this repo to + # itself have design/state/ to strip - guarded by $script:SkipDesignStateSelfTests same as + # the "against this repository's own tree" block above. $script:S12RepoRoot = Split-Path $PSScriptRoot -Parent $script:S12Checkout = Join-Path $TestDrive 'checkout-without-state' New-Item -ItemType Directory -Path $script:S12Checkout -Force | Out-Null diff --git a/tools/Update-DesignProjection.Tests.ps1 b/tools/Update-DesignProjection.Tests.ps1 index 52b0c78..65a96ac 100644 --- a/tools/Update-DesignProjection.Tests.ps1 +++ b/tools/Update-DesignProjection.Tests.ps1 @@ -7,9 +7,15 @@ Test-DesignState.ps1, Read-DesignState.ps1 and Test-DesignDrift.ps1 already use. Every fixture below is written into $TestDrive under a throwaway root; the final Describe - block is explicit about reading this repository's own tree instead. + block is explicit about reading this repository's own tree instead - it asserts on this + repo's *own* adopted design-state content, so it is skipped (never a false pass or fail) on + a repository the kit's compatibility promise (design/90-decisions.md, 2026-08-19) says is + not migrated: no design/state/ at all. #> +$script:DesignProjectionSelfTestRoot = Split-Path $PSScriptRoot -Parent +$script:SkipDesignProjectionSelfTests = -not (Test-Path (Join-Path $script:DesignProjectionSelfTestRoot 'design/state')) + BeforeAll { $script:ScriptPath = Join-Path $PSScriptRoot 'Update-DesignProjection.ps1' . $script:ScriptPath -Path $TestDrive @@ -362,7 +368,7 @@ Hand-authored tail, outside every region. } } -Describe 'Update-DesignProjection against this repository''s own tree' { +Describe 'Update-DesignProjection against this repository''s own tree' -Skip:$script:SkipDesignProjectionSelfTests { BeforeAll { $script:RepoRoot = Split-Path $PSScriptRoot -Parent