diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cbd42b..7caeb92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,19 @@ jobs: shell: pwsh run: ./tests/hooks/run-conformance.ps1 -SelfTest + # --- Contract-lint fixtures: same posture as the hook conformance pair + # above. One pwsh runner drives BOTH linter implementations per case, so + # parity is asserted rather than inferred from two green jobs. No `if:` + # guard - every OS runs both, which is what makes windows-latest cover the + # CRLF path (*.md is deliberately not pinned to LF at the repo root) ----- + - name: Contract lint fixtures (bash vs PowerShell) + shell: pwsh + run: ./tests/contract-lint/run-selftest.ps1 + + - name: Contract lint self-test (dead-linter detection) + shell: pwsh + run: ./tests/contract-lint/run-selftest.ps1 -SelfTest + # --- Install -> uninstall round-trip (CLAUDE.md sandbox recipe) -------- - name: Install -> uninstall round-trip (bash) if: runner.os == 'Linux' || runner.os == 'macOS' @@ -131,6 +144,12 @@ jobs: for area in commands agents hooks templates skills; do test ! -d "$base/$area/sd" || { echo "FAIL: $area/sd survived uninstall"; exit 1; } done + remaining="$(find "$base" -type f)" + if [ -n "$remaining" ]; then + echo "FAIL: files survived uninstall:" + echo "$remaining" + exit 1 + fi rm -rf "$base" echo "round-trip OK" @@ -151,5 +170,7 @@ jobs: foreach ($area in 'commands','agents','hooks','templates','skills') { if (Test-Path "$base/$area/sd") { Write-Error "FAIL: $area/sd survived uninstall"; exit 1 } } + $remaining = Get-ChildItem -LiteralPath $base -Recurse -File -Force + if ($remaining) { Write-Error "FAIL: files survived uninstall:`n$($remaining.FullName -join "`n")"; exit 1 } Remove-Item -Recurse -Force $base Write-Host 'round-trip OK' diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml new file mode 100644 index 0000000..5a8d2fe --- /dev/null +++ b/.github/workflows/e2e-nightly.yml @@ -0,0 +1,43 @@ +name: E2E behavioral eval (nightly) + +# Headless behavioral eval harness for commands and gates (SW-27). Drives real +# `claude -p` sessions - real API cost and multi-minute wall clock - so this +# is deliberately NOT wired into ci.yml's push/pull_request job. See +# tests/e2e/README.md for what each scenario asserts, the isolation model, +# and the permission-mode pitfall (acceptEdits silently ignores a hook's +# deny - see that file before changing --permission-mode here). + +on: + schedule: + - cron: '17 3 * * *' # 03:17 UTC daily - avoid the top-of-hour stampede + workflow_dispatch: {} + +jobs: + e2e: + name: e2e (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install claude CLI + run: npm install -g @anthropic-ai/claude-code + + - name: Run e2e suite + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + shell: pwsh + run: ./tests/e2e/run-e2e.ps1 + + - name: Run e2e self-test (guard-neutering detection) + if: always() + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + shell: pwsh + run: ./tests/e2e/run-e2e.ps1 -SelfTest diff --git a/CHANGELOG.md b/CHANGELOG.md index 812e20b..9361853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,480 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`## Quickstart` section in `README.md`** (SW-8) - a numbered path (install -> `/sd:setup` -> + `/sd:feature `, with the bundled fixture as the fallback for readers with no project handy) + so a new reader reaches their first spec-approval gate without piecing the flow together from + separate sections. See the README restructure under **Changed** below for where this section + finally landed - it absorbed the install commands outright. Also adds a star / "using this at + work" call-to-action to `## Support`, and nine GitHub topics plus a repo description fix (the + command count had drifted from what's on disk) via `gh repo edit`. +- **`## Spawned specs` in the feature, bug, refactor, and perf spec templates** (SW-42) - follow-up + work discovered mid-spec previously had nowhere to land except prose, where it evaporated. The + RCA template's reserved-ID table (`Reserved ID | Type | Title | Owner`) is now the one convention + across every spec type, not two. Each affected workflow's close-out **prompts** for the section + when the retro names deferred work - a prompt, not a gate: gate counts are unchanged, since + hard-gating hygiene would tax every spec for a minority's benefit. + - **The section ships with no `<<...>>` token.** It is filled at close-out, i.e. after + `approved`, so an author-fill placeholder there would be an `SL010` BLOCK on every spec that + deferred nothing. Header + separator is the empty state, and it is also `SL090`'s trigger. + - **`SL090`, the first 🟡 SUGGEST rule** - a `done` spec whose body names deferred work with an + empty spawned-specs table. Advisory, never a failure, and its trigger vocabulary is a closed + phrase list rather than a judgement call, because an advisory that fires on a hunch is noise. + `SL091`-`SL099` open the close-out-hygiene band. + - **A reserved ID is not a registry entry.** Documented alongside the index consistency rules: + it gets an `.specs/index.md` row only once its directory exists. Writing the row first + manufactures the ghost row `SL032` exists to catch - the fourth of the four real-world + follow-ups that motivated this change was exactly that. +- **`/sd:port` - the fidelity-first port pipeline** (SW-41) - the orchestration story that wires the + rest of the port epic (SW-37 skill, SW-38 template/snapshot layout, SW-39 extraction mode, SW-40 + parity gate) into one command: bridge/extract -> freeze -> host survey -> fidelity tables -> pin + behavior -> plan -> execute batched -> justified-diff parity -> close-out. Ten phases, six gates, + four of them HARD (donor set frozen, fidelity tables complete, behavior pinned, justified-diff + parity) with no override path; the other two (plan approval, per-batch tests) are ordinary + approvals. `--scope` is always explicit - Phase 0 asks when it is omitted rather than inferring + it, because scope selects the Phase 5 pinning mechanism. `--from` selects topology (a bridged + cross-repo contract artifact vs an in-repo path/symbol) without changing anything downstream. + - **Port policy stays Layer 2.** Phase 0 reads a `Port policy` heading from the host's + `.specs/constitution.md` and always states the effective policy in its output, including the + fallback (structural mirror, per `sd-port-fidelity`) when the host declares nothing - the + engine supplies the mechanism, never a hardcoded posture. + - **Behavior pinning is scope-dependent and gate-verified.** `endpoint` gets a contract test + suite runnable against both donor and host; `module` gets characterization tests through an + interface-typed construction seam, so re-pointing donor -> host changes exactly one factory + method and assertion bodies stay byte-identical; `feature` uses whichever the surface allows; + `pattern` skips pinning (no donor instance) but the gate still proves the host production tree + is unmodified via an empty `git diff` / `git status --porcelain`, not a good-faith claim. + - **A port-specific complexity metric.** The existing decompose thresholds count impacted files + and layers, which trip on nearly every port by construction (a port's file count equals the + donor's). Phase 6 instead counts deviation-table rows requiring adaptation - the quantity that + actually scales with how much judgment the work needs - and records the rationale in + `01-plan.md`. + - **The anti-drift mechanism lives in the task block, not the gate.** Every port task's `Pattern + refs` cites a snapshot member range (`04-artifacts/source/:-`), never prose + and never a host sibling, and `Acceptance` carries the licensed-deviation ID list. Anything not + on that list is reproduced as-is. A task missing either is a planning defect, refused before + execution rather than caught only at the parity gate. + - **Lifecycle divergence, deliberate.** Unlike `/sd:feature` and `/sd:refactor`, `abort` never + jumps a port spec to `archived` - it leaves the spec at its current state so re-invoking resumes + exactly there, since a partially-frozen or partially-pinned port has no clean "give up" shortcut + the way an unstarted feature does. + - **`WORKFLOW_TYPE = port` added to `sd-implementer`** - neither `feature` (allows new public API + freely) nor `refactor` (forbids new public API, requires `INVARIANTS`) was the right constraint + set for reproducing a donor's structure under a licensed-deviation list, so this is a genuine + fifth mode, not a reuse of an existing one. + - **`port` joins the prompt-router keyword map** (`backport`, `port from`, `port the`, `donor + repo`, `mirror from`, `replicate from` - deliberately multi-word phrases; a bare `"port"` would + fire on "support", "report", "portal"), shipped in both hook implementations plus the + `project-config.template.json` default. + - **`contractLint.budgets.skillsBytes` raised 12377 -> 12412** - `skills/sd-port-fidelity/SKILL.md` + picked up two small cross-references to `/sd:port`'s phases (the freeze step, the fidelity-table + author) replacing prose that pointed at "a documented manual step until the port pipeline + lands"; the ratchet moves with it. + - **Deliberately NOT built**: `--sync` / re-port drift detection, multi-donor ports, and editing a + host project's build/lint/coverage configuration to exclude the snapshot - the command warns + about tooling that globs `.specs/`, it never edits. + - **Known gaps carried forward**: no `SL06x` rule machine-checks the port task-block contract + (Pattern refs range + licensed-deviation list) - it is enforced by Phase 6 refusing to execute a + defective block, not by `/sd:spec validate`; and `spec-gate`/`subagent-retro` still do not + recognize the `PORT-` prefix (only `prompt-router`'s keyword routing landed this round) - see + `docs/troubleshooting.md`. `PROJECT-SNAPSHOT.md` does not exist in this repo and never has (see + the historical note below); nothing in this change introduces it. +- **Port parity adjudication: `port-parity` TASK_TYPE on `sd-reviewer`, the parity diff artifact, + and the parity gate** (SW-40) - the enforcement half of the port epic and the only mechanism in + it that can see logic drift, structural mismatch, or silent simplification; test-green and + contract compliance are blind to all three, which left the fidelity rules from SW-37 as + honour-system prose. The main thread writes `04-artifacts/parity/`: one unified diff per + non-`omit` path mapping row, an all-deletion diff for a row whose host file is absent, an + all-addition diff for a changeset file with no row at all, plus `INDEX.md` listing them. + `sd-reviewer` consumes `INDEX.md` as `DIFF_REF` and classifies every hunk with + `sd-port-fidelity`'s vocabulary, now five classes rather than four. `overreached` is the new one - + a deviation row covers the hunk but the hunk changes more than that row's `Host form` states - + and it is the class a rubber stamp hides in. Two whole-artifact checks join it: member + completeness, reported as a count with each absent row named, and path conformance, one BLOCK per + unmapped changeset file. A `justified` hunk is a PASS and is deliberately NOT written up, so a + real BLOCK cannot drown in a list of accepted diffs. `templates/specs/port.template.md`'s fixed + AC-1 gains `overreached` and a `Host form`-covers-the-hunk clause: reworded to track the skill's + vocabulary, never renumbered, because the number is what every fidelity finding anchors to. + - **Diff generation stays on the main thread, enforced by the tool allowlist** - `sd-reviewer` + gains no `Bash` and no write tool and stays in `contractLint.readOnlyAgents` (CL201). The + reviewer that cannot produce the diff also cannot fix what the diff shows; adjudicating from a + file it did not write is the entire structural guarantee. + - **The `/sd:verify` overlap, decided before any checking logic was written** - member + completeness and path conformance stay with the reviewer and `/sd:verify` is untouched, no new + `VF0xx` rule. Rationale recorded in `docs/architecture.md`: `/sd:verify` decides everything from + `00-spec.md` and `02-tasks.md` with fixed regex shapes, and neither check can be decided that + way - one needs a member boundary recognized in an arbitrary host language, the other needs a + changeset input `/sd:verify` does not take. + - **Deliberately NOT built**: semantic equivalence checking, which is the behavior-pinning + phase's job rather than the diff's, and auto-generation of deviation rows from unexplained + hunks, which would let the diff justify itself and turn the gate into a rubber stamp. The gate + stays HARD with exactly two resolutions - revert the host toward the snapshot, or add a + deviation row whose group and citation hold up and re-run - and no override. + - **Known gap**: the pipeline command that would generate the parity artifacts and host the gate + is SW-41. Until it lands, diff generation is a documented manual step (AC-1 asks for it + documented, not automated) and the new mode is invoked by no command, so contract-lint reports + a `CL101` WARN for it, joining the ones `sd-code-explorer` and `sd-reviewer` already carry. + Recorded in `docs/troubleshooting.md`. +- **`examples/port-parity-fixture/`** (SW-40) - matched `clean/` and `broken/` port trees over a + toolchain-free plain-text donor, following `spec-lint-fixture`'s convention: a README table of + expected findings and `` comments naming each defect. `broken/` seeds one + defect per BLOCK class plus both whole-artifact checks, and carries deviations that are correctly + cited and applied in `clean/` but exceeded in `broken/` - `overreached`, not a fourth unrelated + deviation, demonstrates the negative. Seed markers live in the spec rather than in the ported + files, because a comment line inside a host file is itself an `extra` hunk and would seed a + defect the table does not claim. Members are separated by an unchanged padding block so every + seed lands in its own diff hunk under the "at least 3 lines of context" convention, rather than + merging adjacent changes into one hunk that would need two classifications. Not run in CI, for + the same reason `spec-lint-fixture` is not: the adjudicator is a prompt, and a script able to run + it would be a second copy of the rules. +- **`contractLint.budgets.skillsBytes` raised 10656 -> 12377** - `skills/sd-port-fidelity/SKILL.md` + absorbed the parity artifact layout, the fifth hunk class, both whole-artifact checks and the + gate, and takes over as its area's ratchet-setter from `sd-spec-templates`. `agentsBytes` is + unchanged on purpose: `agents/reviewer.md` grows to roughly 10.5k against the 15232 ceiling + `agents/spec-architect.md` still sets, so the sixth task type needed no headroom. +- **`port-extract` TASK mode on `sd-code-explorer`, invoked from `/sd:explore --port`** (SW-39) - + the donor-side extraction half of the port workflow, consuming `sd-port-fidelity` (SW-37) and + feeding `PORT` spec authoring (SW-38). Eight fixed sections (Entry surface, Output surface, + Member closure, Complement set, Collaborators, Non-obvious invariants, Dead paths on this entry + point, Precedent conventions), each `file:line`-cited or explicitly `None found (searched: + ...)` - replacing the prior free-form `/sd:explore` prose contract whose gaps a host + implementer filled by invention. Member closure's `Donor path`/`Ordinal`/`Member` columns carry + over verbatim into the host's Member manifest table. Explorer's tool allowlist is unchanged (no + write tool added) - `/sd:explore` itself, not the agent, computes `source_commit` (`git + rev-parse HEAD`, with an explicit `dirty` sentence instead of a misleading sha when `git status + --porcelain` is non-empty), hashes, and copies donor files into + `.specs/_explorations/-/source/` plus a `MANIFEST.md` in the exact + `sd-port-fidelity` "Snapshot artifacts" format when `--snapshot contract+source` is passed - + matching how `impact-map`'s output is appended by the caller rather than written by the agent. + Output is stack-agnostic and donor-only; the host side of the bridge (copying the produced + folder into a `PORT` spec's `04-artifacts/source/`) stays a manual/scripted step, and reading a + donor from a host-rooted session stays out of scope - both per SW-39. The consumer that turns + this into an end-to-end pipeline is SW-41. +- **`PORT` spec prefix, `port.template.md`, and snapshot artifact layout** (SW-38) - the authoring + half of the port workflow, consuming `sd-port-fidelity` (SW-37). `PORT--` joins + `spec.prefixes` and every enumeration site (`commands/spec.md`, `commands/release.md`, + `agents/spec-architect.md`, `sd-retro-lessons`, docs). `templates/specs/port.template.md` adds six + mandatory sections (Behavioral contract, Behavioral invariants, Path mapping table, Member + manifest, Deviation table, Spawned specs) plus five provenance frontmatter fields (`scope`, + `source_repo`, `source_commit`, `source_license`, `snapshot`); the three table schemas are reused + verbatim from `sd-port-fidelity` rather than the ticket's own prose, which described a different, + stale external precedent (`FEAT-details-translation-builder`, not present anywhere in this repo). + Snapshot layout (`04-artifacts/source/` + `MANIFEST.md`, per-file donor path/commit/hash/member + ranges) is documented in `sd-port-fidelity`'s new "Snapshot artifacts" section, which is also the + defined input a later drift check can consume. `/sd:spec validate` gains the `SL080`-`SL083` + port-integrity band. PORT is release-eligible, maps to CHANGELOG `Added`, and triggers a MINOR + bump like a feature. + - **Deviation from the ticket's "adds a glob" wording**: `paths.protected` matching in both + `spec-gate` hooks is exact-string only, with no glob engine on either platform. Freezing a + snapshot instead enumerates every file under `04-artifacts/source/` plus `MANIFEST.md` as + individual literal `paths.protected` entries - the mechanism is reused exactly as shipped, with + zero hook changes (AC-9 is satisfied to the letter). + - **Known gap, deferred**: the `(FEAT|BUG|REF|PERF|RCA)` prefix regex is hardcoded across + `spec-gate`, `subagent-retro`, and `prompt-router` (both platforms) and does not read + `spec.prefixes`. A `PORT-` spec is therefore invisible to in-progress-spec detection, lesson + scoping, and context injection until those hooks are updated - out of scope here (no AC in this + story requires a working end-to-end port pipeline; that pipeline is SW-41). Documented in the + new template, in `sd-port-fidelity`, and in `docs/troubleshooting.md`. + - No `.ps1` or `.sh` file was modified (AC-9). +- **`contractLint.budgets` raised for the SW-38 wiring** - `commandsBytes` 25978 -> 29664 + (`commands/spec.md` gained the port-spec validate rules and subsection, including a follow-up fix + so `SL080` also catches a literal `none` value), `agentsBytes` 14671 -> 15232 + (`agents/spec-architect.md` gained the sixth workflow type and port-specific inputs), + `skillsBytes` 9134 -> 10656 (`sd-spec-templates` and `sd-port-fidelity` both grew; the latter is + now the ratchet-setter for its area). Real, reviewed growth from a new spec type landing across + three files that each already sat at their prior ceiling, not a reflex to a red run. +- **`sd-port-fidelity` skill** (SW-37) - cross-project port policy, promoted into a skill because + two agents need the same rule body: `sd-spec-architect` authors the deviation table and the port + task blocks, `sd-reviewer` judges whether a diff hunk is justified. Defines structural mirror as + the default posture, the four-group deviation allowlist (compiler/namespace/assembly, host + constitution, host precedent, agreed behavior-parity fix) with a required citation per group, the + five anti-simplification rules that reach the implementer through task `Acceptance` rather than + through the skill, completeness conditions for the three gate tables (path mapping, member + manifest, deviation table) phrased as counting and matching predicates a gate can evaluate + without judgement, and the closed four-class hunk vocabulary (`justified` / `unjustified` / + `missing` / `extra`). Wired into `agents/spec-architect.md` and `agents/reviewer.md` only; the + consumers that enforce it are SW-38 (port spec template), SW-39 (donor extraction), SW-40 + (justified-diff artifact and reviewer adjudication) and SW-41 (the pipeline itself). Fidelity + findings anchor to the port spec's mandatory fidelity acceptance criterion, which is already a + legal code anchor - `sd-severity-taxonomy`'s Anchors table is deliberately left untouched. +- **`contractLint.budgets.agentsBytes` raised 14454 -> 14671** - `agents/spec-architect.md` was the + ratchet-setter and sat at the ceiling exactly, so the two-line SW-37 wiring (one `skills:` entry, + one `Pattern refs protocol` item) could not land without moving it. Real, reviewed growth on the + repo's largest agent, not a reflex to a red run; CL500 is unsuppressible on line 1. +- **Threshold calibration machinery** (SW-31) - `spec-gate` (`hooks/bash/spec-gate.sh`, + `hooks/powershell/spec-gate.ps1`) now infers a completed Gate Complexity split from `index.md`'s + own state (a `FEAT-X` row archived alongside a registered `FEAT-X-` child) and records it as + a new `gate:"complexity"`/`decision:"split"` metrics event - this repo's first metric for a gate + that is otherwise decided as model-executed prose. Recorded only when the `index.md` edit is + actually allowed through, never on a `block` exit, so the count under-reports on any project that + leaves `index.md` protected (the default). `/sd:status --calibration` (new optional flag, + default invocation's read contract unchanged) reports task/layer/file distributions from spec + artifacts alongside the new split count, framed as `insufficient data (n=)` below the + CONTRIBUTING re-calibration trigger. `docs/adr/0004-threshold-calibration.md` records this run's + verdict (insufficient data on every threshold at the current n=1 corpus) and the deliberately + declined scope (full trip-rate instrumentation); `templates/project-config.template.json` now + marks `retroStaleMinutes`, `debounceMinutes`, and `maxLessons` as unmeasured judgement calls, + matching the existing `maxSizeKb` caveat. CONTRIBUTING names the re-calibration ritual (every 20 + closed specs, or each minor release). + +- **Install-time version stamp** (SW-29) - `install/install.ps1` / `install/install.sh` now write + `specwright-version.txt` into every installed `/sd/` root, parsed at install time from the + newest dated `## [x.y.z] - ` heading in `CHANGELOG.md` - the same source `versionClaims` + already treats as canonical - so an installed engine can finally report which version it is + without a second version literal anywhere in the installer. LF, no BOM, US-ASCII, byte-identical + whichever installer writes it; a repeat install reports the stamp `identical` and skips it, and + `uninstall.ps1` / `uninstall.sh` remove it for free since it lives inside the `sd/` directory they + already delete recursively. Check 5 in `scripts/validate.ps1` / `scripts/validate.sh` now asserts + the stamp's presence, encoding, content and no-op/refresh behavior; the CI round-trip's install -> + uninstall step now verifies no engine-written file survives anywhere under the base path, not just + that the `sd/` directories are gone. + +- **`/sd:setup` reads the version stamp and reports engine/config drift** (SW-29). Phase 0 now + loads the installed `specwright-version.txt`; Phase 1.5's batch drift-check compares it against + `.claude/project-config.json`'s `version` field and generalizes the missing-field check into a + full template diff, so a project scaffolded under an older engine sees every gap, not just the + two fields the check used to hardcode. `version` is the one field the Apply step is allowed to + overwrite outside the project-specific preserve-list, since it is engine-tracked. Fresh scaffolds + (Phase 6) now stamp the real installed engine version instead of the template's literal `1.0.0`. + Also removes the dead `$schema` URL from `project-config.template.json` - no schema was ever + published at that path, and the org name in the URL didn't even match the real repo + (`Developzone` vs `developzoneio`); the drift-check now flags any leftover `$schema` key for + removal instead of a rewrite. + +- **`tests/e2e/`: headless behavioral eval harness for commands and gates** (SW-27). Where the rest + of `scripts/`/`tests/` proves the engine's *assets* reference each other correctly, this drives + real `claude -p` (headless) sessions against a throwaway copy of `examples/fixture-project` / + `examples/spec-lint-fixture/broken` and asserts on **produced artifacts** (files, frontmatter, + status values) rather than transcript wording - the first mechanism that proves the engine + *behaves* correctly end-to-end. 5 scenarios: `/sd:setup` fresh-scaffold, `/sd:feature` happy path + to a passing `06-verify.md`, spec-gate denying a code edit with no in-progress spec, spec-gate's + verify-gate denying an unverified close-out, and `/sd:spec validate` surfacing the seeded `SL0xx` + corpus. `run-e2e.ps1` (single pwsh runner, same posture as `tests/hooks/run-conformance.ps1` / + `tests/contract-lint/run-selftest.ps1`) sandboxes each run via a fresh "fake home" with the engine + installed into it through the installer's own `-BasePath` flag; `-SelfTest` re-runs the two + negative scenarios against a neutered spec-gate hook and asserts the harness notices. Not wired + into per-PR `ci.yml` - runs nightly / on manual dispatch via `.github/workflows/e2e-nightly.yml`. + `tests/e2e/README.md` documents the isolation model, prerequisites, cost, and two findings from + building it: `--permission-mode acceptEdits` silently overrides a `PreToolUse` hook's deny (only + `dontAsk` with no `--allowedTools` override actually respects one), and spec-gate's matcher covers + `Edit`/`Write`/`MultiEdit` only, not `Bash`-mediated file writes. + +- **`examples/fixture-project/`** (SW-30) - a tiny, runnable, non-.NET (plain Node.js) example + project: pre-scaffolded `CLAUDE.md`, `.specs/constitution.md`, and `.claude/project-config.json`, + plus `.specs/FEAT-todo-priority/`, a complete, real `/sd:feature` run (spec through verify) + committed as the worked example. Proves stack-agnosticism by demonstration instead of assertion + alone - the first non-.NET spec run through the engine. Root `README.md` Quickstart, compatibility + matrix, and Documentation list updated to point at it; `examples/README.md` and + `docs/walkthrough.md` reconciled to stop promising a fixture that didn't exist yet. Closes SW-8's + overlapping "runnable examples/ fixture" acceptance criterion by reference. + +- **Check 8: cross-file contract lint** (`scripts/contract-lint.ps1` / `scripts/contract-lint.sh`, + SW-26 wave 1). Where Check 7 guards inventory, Check 8 guards the relationships between commands, + agents and skills. 17 rules across three bands: `CL0xx` reference resolution, `CL3xx` gate + integrity, `CL9xx` suppression hygiene. Deterministic file ops, no subagent, TSV on stdout, exit + `2` when it cannot run. Wired into both validators and into CI on all three OSes. +- `contractLint` subtree in `specwright.manifest.json`: scan scope, the rule registry (the single + source of every rule's severity, so a BLOCK/WARN divergence between the twins is structurally + impossible), declared gate contracts, spec artifact names, skill-consumer escapes and the CL305 + override vocabulary. Each linter carries a registry parity guard that exits `2` when the rules it + dispatches and the registry disagree. +- Gate counts are now published claims: `contractLint.gates..hard` seeds Check 7 quantities, + giving `README <- manifest` there and `manifest <- disk` in CL302, hence transitively + `README == disk`. 21 new `docClaims` plus a `N hard gates` claim phrase. +- `tests/contract-lint/` fixture suite - a minimal valid mini-engine plus one overlay per rule, five + false-positive guards and one must-still-bite case. Goldens pin a seed marker, never a line + number. `run-selftest.ps1` drives both implementations in one process so parity is asserted, and + `-SelfTest` proves the harness detects a linter that reports nothing. +- `docs/contract-lint.md` - rule catalogue, suppression syntax, manifest surface, and why a declared + gate count belongs in a manifest that otherwise stores no counts. `CONTRIBUTING.md` gained a + matching section. +- Machine-readable `Inputs (required): ...` / `Inputs (optional): ...` declarations under every + TASK/mode/workflow-type heading in `agents/*.md` (22 sections across `code-explorer`, `debugger`, + `implementer`, `reviewer`, `spec-architect`; `docs-writer` has no mode dispatch) - prerequisite + for the invocation-contract validator in SW-26 (SW-25). Format documented in `CONTRIBUTING.md` + under "Agents". No agent behaviour changed. + +### Changed +- **`README.md` cut from 390 to 282 lines (-28%) with no claim dropped.** The restructure below + fixed the *order* of the page but not its *volume*; a reader still scrolled past a lot of + repetition to reach the call to action. The redundancy was concentrated, not scattered: + - **`## Architecture highlights` was a near-copy of `## Why spec-driven?`** - three of its four + bullets restated hard gates, the cost model, and stack-agnosticism verbatim. The section is + gone; its one distinct idea (the three layers) is now a sentence in `## Why spec-driven?`, and + the 18-line ASCII diagram was dropped because `docs/architecture.md` already carries it. + - **`## Features` was a table of contents for the two tables beneath it** - all 14 command names + printed there and again in `## Commands`, all 6 agent names there and again in + `## Agents and skills`. The rows stay (they anchor `docClaims`); only the duplicated cell + contents were replaced with a pointer. + - **Fifteen `---` rules cost ~30 lines to draw a line GitHub already draws** under every `h2`. + All removed. + - Also: the spec-folder tree showed five spec types where one plus a note conveys the same shape, + and the YAML frontmatter snippet was dropped (it duplicates `docs/architecture.md`). + - **The install and uninstall blocks stay split per platform, on purpose.** Collapsing each pair + into a single fenced block was tried and reverted: it put PowerShell inside a ` ```bash ` fence, + forced the reader to parse trailing comments to find their own line, and - worst - chained the + preview into the real run with `&&`, so `--dry-run` output scrolled past and the install + happened anyway. A dry run you cannot read is not a dry run. Four blocks, ~19 lines more, + correct. +- **`README.md` restructured to lead with evidence rather than inventory.** The page opened with a + feature count and asked the reader to take 330 lines of tables on trust before showing a single + line of output. It now opens with `## What it looks like` - a real Gate 1 spec-approval STOP and + the real Gate 3 reviewer verdict (`0 BLOCK / 0 WARN / 5 SUGGEST / 7 PASS`, `18/18` tests), both + transcribed from the committed `FEAT-todo-priority` run in `examples/fixture-project/`, not + invented for the README. + - **`## How this differs from prompt-level discipline`** names the four structural properties - + gates halt the phase, `spec-gate` denies `Edit`/`Write` at the `PreToolUse` layer rather than in + the prompt, the reviewer's allowlist contains no write tools, and specs are subagent inputs + rather than write-ups. Previously the README asserted discipline without saying what enforces it. + - **The two install sections are one.** `## Quickstart` now carries the install commands inline + plus an explicit requirements line; the old `## Quick install` becomes + `## Install options and uninstall` and keeps only the advanced path. The reader no longer + bounces between two sections that pointed at each other. + - **The agent and skill tables shrank to a summary plus a link.** Tool allowlists, the + command -> agent routing map, and the nine-row skill catalogue already lived in + `docs/architecture.md` verbatim; the README kept a duplicate that could drift. Only the role + and model columns stay. + - Adds a release badge, surfaces `examples/port-parity-fixture/` and `examples/spec-lint-fixture/` + (previously unmentioned anywhere in the README), and replaces the unsourced "typical feature run + ~$2-3" with a pointer to `/sd:status`, which reports the reader's own cost from their metrics log. +- **Two new `docClaims` entries** for the README's prose subagent and skill counts. Both new + sentences tripped Check 7's undeclared-claim scan, which is the intended behaviour - the numbers + are now derived from disk like every other published count. + +### Fixed +- **`README.md`'s BMAD acknowledgement pointed at `https://github.com/`** - a placeholder URL that + had shipped since the section was written. Now links to `bmad-code-org/BMAD-METHOD`. +- **`README.md`'s compatibility matrix claimed "Latest as of Jan 2026"** for the Claude Code CLI row, + seven months stale. The roadmap's "**Planned** - nothing queued right now" bullet read as a + stalled project against a repo that had shipped through v1.5.0; the section now leads with the + shipped release and drops the empty bucket. +- Two em dashes in the README's skills table, the only two in a file that uses `-` roughly 200 times. +- `docs/architecture.md` listed four items against a gate count of three for `/sd:feature`, one of + them naming a per-task review gate removed when the workflow moved to batch review. Found by + writing CL302, fixed before the linter landed. +- `commands/setup.md`'s detected-facts gate had no literal `STOP` (the nearest one belonged to the + migration gate above it), and neither setup gate offered a machine-readable option set. Both were + real CL300/CL301 violations on disk. +- Audit of every `commands/*.md` invocation site against the new declarations turned up three + drifted contracts, now corrected: `/sd:bug`'s hypothesis-verify loop omitted `EVIDENCE_DIR` from + its `sd-debugger` `TASK = verify` call, so verification evidence had nowhere to be saved + (`commands/bug.md`); `/sd:rca` passes `MODE = incident` to `sd-debugger`'s `enumerate` task, a + token the agent never declared (`agents/debugger.md`); `/sd:feature`'s batch review passes + `PLAN_REF` to `sd-reviewer`'s `holistic` task, likewise undeclared (`agents/reviewer.md`). + `agents/spec-architect.md`'s `create` mode also documented an `INCIDENT_DETAILS` input no command + has ever set - removed, since `/sd:rca` fills those fields interactively, not via a token. +- **Check 8 wave 2: `CL1xx` invocation contract** (SW-26 wave 2 / SW-32), making the SW-25 `Inputs + (required|optional):` declarations load-bearing. Five rules: `CL100` (BLOCK) an invocation sets + `TASK`/`WORKFLOW_TYPE`/`TASK_TYPE` to a mode the target agent never declared; `CL101` (WARN) an + agent declares a mode no command ever invokes; `CL102` (BLOCK) an invocation omits a required + input; `CL103` (WARN) an invocation passes a token the mode declares nowhere; `CL104` (BLOCK) two + agent files share a frontmatter `name:`. Two new indices in both linters - a mode-declaration + table built from `agents/*.md` mode headings, and an invocation-token table built by scanning each + `commands/*.md` invocation forward to the next heading, the next invocation, or the next + top-level numbered step, whichever comes first. Five new fixture cases plus a row each in + `tests/contract-lint/README.md` and `docs/contract-lint.md`. + +### Fixed +- `commands/refactor.md`'s characterization-test sub-loop invoked `sd-implementer` with + `WORKFLOW_TYPE = refactor` but never passed `INVARIANTS`, the one field that mode's constraint set + actually reads - a live instance of the exact defect class `CL1xx` exists to catch (found while + building it, per SW-32). +- `agents/code-explorer.md`'s `TASK = standalone` mode never declared the `GITNEXUS_AVAILABLE` input + its own "Always do first" step reads and `/sd:explore` always passes. +- Two documented `sd-spec-architect` `TASK = plan` scoped-re-plan invocations + (`commands/feature.md`, `commands/refactor.md`) legitimately pass `REPLAN_SCOPE`/`REVISION` + instead of the mode's `SPEC`/`IMPACT` - suppressed with `CL102` reasons citing the "Scoped + re-plan" sub-path in `agents/spec-architect.md`, since the declaration has no syntax for an + either/or required set. +- **Check 8 wave 3a: `CL2xx` role and tool integrity** (SW-33), four rules over the agent role + contract: `CL200` (BLOCK) an agent with no write tool is + instructed to write, append or create; `CL201` (BLOCK) an agent listed in the new + `contractLint.readOnlyAgents` declares a write tool anyway; `CL202` (WARN) an `mcp__*` name in + scan scope is absent from the new `contractLint.knownMcpTools`; `CL203` (WARN) an agent's own + frontmatter declares a tool its own body never mentions. A write tool is exactly + `Write`/`Edit`/`MultiEdit` - `Bash` deliberately does not count, a scope decision recorded in + `docs/contract-lint.md`. Two new indices in both linters: a per-agent `tools:` frontmatter table + (feeds `CL200`/`CL201`/`CL203`) and an `mcp__*` token scan across scan scope (feeds `CL202`). + `readOnlyAgents` seeded with the three agents already lacking a write tool + (`sd-code-explorer`/`sd-reviewer`/`sd-debugger`); `knownMcpTools` seeded with the twelve real + `mcp__*` names currently declared across `agents/*.md`. Five new fixture cases (one per rule plus + a false-positive guard proving `CL200` ignores negated and third-person uses of its verbs) plus a + row each in `tests/contract-lint/README.md` and `docs/contract-lint.md`. + +### Fixed +- `tests/contract-lint/fixtures/_base/agents/keeper.md` declared `Grep` in its `tools:` line but + never mentioned it in its own body - would have tripped `CL203` on the fixture suite's own base + tree the moment the rule shipped. Five older fixture overlays (`cl002`, `cl007`, `cl101`, `cl102`, + `cl103`) had the same latent gap in their own copies of the demo agent, found the same way. +- **Check 8 wave 3b: `CL4xx` stack-agnostic prose + `CL306`** (SW-34), four rules closing the two + promises wave 1 deferred: `CL400` (BLOCK) a hardcoded stack command token outside a + `<>`, a fenced example, or a suppression comment; `CL401` (WARN, permanent) the same + for a language/framework name - a language name in prose is often legitimate, so this one never + promotes; `CL402` (BLOCK) a hardcoded absolute filesystem path in scan scope; `CL306` (BLOCK) a + HARD gate's prose + describes an escape hatch with no `contract-lint: allow CL306` comment nearby - the prose half of + `CL305` that wave 1 deferred for exactly this reason. `CL306` deliberately excludes whatever + `CL305` already governs (the option-set parenthetical and backtick-led option bullets), so the two + rules cover disjoint territory instead of double-firing on the same line. No new manifest surface + for per-gate exceptions: `CL306` reuses the existing suppression-comment convention rather than + adding a second mechanism that would say the same thing. Two new hand-maintained vocabulary lists + in `specwright.manifest.json` (`contractLint.stackTokens.{commands,languages}` and + `gateProseEscapeTokens`), same category as `overrideOptionTokens`. The hardcoded MSSQL/C#/ + TypeScript references this ticket originally described were already genericized by an earlier + commit; the real findings this wave turned up in the current tree were all illustrative or + multi-stack-heuristic uses of stack vocabulary (enumerated manifest-filename lists in + `commands/setup.md`, forbidden-example prose in `commands/verify.md`, `agents/spec-architect.md` + and `skills/sd-retro-lessons/SKILL.md`, and a settings.json path-pattern description in + `commands/setup.md`) - each annotated with a `contract-lint: allow` comment rather than rewritten, + since rewriting them would have deleted correct stack-agnostic design, not fixed a bug. Nine new + fixture cases (four must-fire, five false-positive guards) plus a row each in + `tests/contract-lint/README.md` and `docs/contract-lint.md`. +- **Check 8 wave 4: `CL5xx` file budgets** (SW-35), one rule closing the "prompt files only ever + grow" gap: `CL500` (WARN, permanent) a file exceeds the new `contractLint.budgets.Bytes` + ceiling for its scan-scope area. The finding reports how far over budget the file is, not a bare + "over budget". The byte count is normalized (sum of each line's byte length off the same per-line + cache every other rule reads, plus one separator per boundary), never a raw disk read - scan-scope + `*.md` is `text=auto` and checks out CRLF on Windows but LF on Linux CI, so a raw byte count would + make `CL500` disagree with itself across platforms for identical content (confirmed: + `commands/spec.md` is 25979 bytes as a git blob, 26521 bytes on a native Windows checkout). Three + new manifest keys (`contractLint.budgets.commandsBytes/agentsBytes/skillsBytes`), each ratcheted + to today's largest file in that area so the repo passes clean by construction and every later hit + is real growth. Two new fixture cases (one must-fire, one false-positive guard at the budget + boundary) plus a row each in `tests/contract-lint/README.md` and `docs/contract-lint.md`. + +### Changed +- **SW-26 promotion: `CL200`/`CL306`/`CL400` WARN -> BLOCK.** All three shipped WARN with an + explicit "promotes to BLOCK in a follow-up commit once it has run clean for a release" clause. + As of 2026-07-31 the engine tree has zero findings for all three under both implementations, so + the promotion in `specwright.manifest.json` is now live (severity is registry-driven, so no rule + logic changed). `CL201`/`CL402` were already BLOCK; `CL202`/`CL203`/`CL401`/`CL500` stay WARN by + design and do not promote. Five existing `` suppressions change from + silencing a WARN to being load-bearing for a green CI: `commands/bug.md` and + `commands/release.md` (`CL306`), `commands/setup.md` (two) and `commands/verify.md` (`CL400`). + +### Fixed +- Check 7 could not see version/release-state claims at all - its entire vocabulary + (`docClaims`/`claimPhrases`) is built around integer counts derived from disk, so a stale + version string had nothing to trip it (SW-28). `ROADMAP.md` had said `Current released version: + **1.3.0**` since before the `1.4.0` release, unnoticed through every green Check 7 run since, + and its `## Planned` section claimed "nothing queued right now" while `[Unreleased]` carried + eight real entries - a release's worth of built-but-uncut work. A new `versionClaims` array in + `specwright.manifest.json` closes this: entries are `{file, pattern}` (no `equals` - there is no + disk-derived quantity for a version, so the expected value is always the newest dated + `## [x.y.z] - ` heading in `CHANGELOG.md`, computed once per run). `scripts/validate.sh`/ + `.ps1` gained a matching check, deliberately independent of Check 6's existing + `next_header`/`$nextHeader` variables - those resolve to whatever line sits directly below + `[Unreleased]`, which is the first bullet rather than a heading in the normal (non-just-released) + state, so reusing them would have passed on bash and silently done nothing on PowerShell. + `ROADMAP.md` now reads `1.5.0` and its `## Planned` section points at `[Unreleased]` instead of + claiming an empty queue. `selftest-docs.{sh,ps1}` grow from 6 scenarios to 7: the new one plants + a wrong version in a sandboxed `ROADMAP.md` and asserts the validator names both the wrong value + and the true one from `CHANGELOG.md`, using the same derive-don't-hardcode discipline SW-20 + established for corruption targets. The ticket's secondary finding (a hand-maintained + `PROJECT-SNAPSHOT.md` needing generation or trimming) does not apply - that file does not exist + in this repo and never has. + ## [1.5.0] - 2026-07-23 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 07ace7b..82c59a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Note: `templates/CLAUDE.template.md` is the template `/sd:setup` scaffolds into # Sandbox install test (run before any PR touching install/hooks/commands/agents) .\install\install.ps1 -BasePath C:\temp\sd-test -Get-ChildItem C:\temp\sd-test\commands\sd\ # expect 13 .md files +Get-ChildItem C:\temp\sd-test\commands\sd\ # expect 14 .md files .\install\uninstall.ps1 -BasePath C:\temp\sd-test -Force # round-trip: removes the 5 sd\ dirs Remove-Item -Recurse -Force C:\temp\sd-test # cleanup ``` @@ -38,17 +38,27 @@ bash -n hooks/bash/spec-gate.sh grep -nP "[^\x00-\x7F]" hooks/powershell/*.ps1 install/*.ps1 ``` +```powershell +# Cross-file contract lint (Check 8) - run it directly while editing prompts +bash scripts/contract-lint.sh --root . # exit 0 clean, 1 on BLOCK, 2 cannot run +.\scripts\contract-lint.ps1 -Root . + +# Fixture suite. Drives BOTH implementations in one process, so parity is asserted +.\tests\contract-lint\run-selftest.ps1 +.\tests\contract-lint\run-selftest.ps1 -SelfTest # proves the harness notices a dead linter +``` + Every PR adds a line under `## [Unreleased]` in `CHANGELOG.md` (Keep a Changelog / SemVer). ## Repo structure → install targets | Source | Installs to | Contents | |---|---|---| -| `commands/` | `~/.claude/commands/sd/` | 13 slash commands (`/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`, `/sd:verify`, `/sd:status`) | +| `commands/` | `~/.claude/commands/sd/` | 14 slash commands (`/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:port`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`, `/sd:verify`, `/sd:status`) | | `agents/` | `~/.claude/agents/sd/` | 6 subagents (`sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer`) | | `hooks/powershell/` + `hooks/bash/` | `~/.claude/hooks/sd/` | 3 hooks × 2 platforms (`prompt-router`, `spec-gate`, `subagent-retro`) | -| `templates/` | `~/.claude/templates/sd/` | 4 setup templates + 5 spec templates in `specs/` | -| `skills/` | `~/.claude/skills/sd/` | 8 rule packs, one folder per skill with `SKILL.md` | +| `templates/` | `~/.claude/templates/sd/` | 4 setup templates + 6 spec templates in `specs/` | +| `skills/` | `~/.claude/skills/sd/` | 9 rule packs, one folder per skill with `SKILL.md` | Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespace comes from frontmatter `name:` and the `sd/` install subfolder. The namespace exists for collision avoidance and clean uninstall — never use bare names when assets reference each other. @@ -58,6 +68,7 @@ Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespac - **Agents** declare frontmatter: `name`, `description`, `color`, `model`, minimal `tools` allowlist, and a `skills:` list. Tool allowlists enforce roles structurally — the reviewer has no write tools, so it *cannot* auto-fix. Heavy reasoning agents (architect, debugger, reviewer) use `sonnet`; mechanical agents (explorer, implementer) use `haiku`. - **Skills** are shared rule packs loaded into agent context via frontmatter reference. A rule used by multiple agents (e.g. `sd-evidence-citation`, used by 3) lives in one `SKILL.md`, never copy-pasted into agent bodies. - **Hooks** inject context (`prompt-router` on UserPromptSubmit, `subagent-retro` on SubagentStop) or guard edits (`spec-gate` on PreToolUse blocks code edits with no in-progress spec). `spec-gate` denials emit a dual-format JSON object carrying both the new schema (`hookSpecificOutput.permissionDecision: "deny"`) and the legacy schema (`decision: "block"`) for CLI version compatibility. `spec-gate` and `subagent-retro` also *record*: metadata-only events (spec ID, phase, decision - never a path) appended to `.specs/_metrics/events.jsonl`, opt-out via `hooks.metrics.enabled: false`. +- **The manifest guards two different things.** `specwright.manifest.json`'s `areas`/`docClaims` guard *inventory* (Check 7: does a number in the docs match disk?) and derive every count from disk. Its `contractLint` subtree guards *relationships* (Check 8: does this command invoke an agent that exists, does this gate halt, does this workflow declare the gate count it has?). Inventory is always derived; a gate count is a declared contract and is written down on purpose — `docs/contract-lint.md` states the test that separates the two. Adding a lint rule means four edits (registry, both linters, a fixture, the doc table), and each edge is guarded by a different mechanism, so it cannot be half-done. - **Spec artifacts** (`.specs//00-spec.md` … `05-retro.md`) are the input contract between agents, not after-the-fact docs. Spec templates intentionally leave cross-phase fields empty, marked with a `<>` token (plus an explanatory `` comment) — workflows enforce sequencing through those empty fields. Do not pre-fill them. ## Hard rules when editing @@ -67,7 +78,8 @@ Source filenames are unprefixed (`agents/reviewer.md`); the `sd-`/`sd:` namespac 3. **Model fields are aliases only** (`sonnet`, `haiku`, `opus`, `inherit`) — never full model IDs. 4. **Stack-agnostic, no exceptions.** Commands and agents must not contain hardcoded stack commands (`dotnet test`, `npm test`) or language assumptions; reference `commands.test` etc. from `project-config.json`. An agent that hardcodes a stack is a bug. 5. **Minimal tool allowlists.** Read-only agents never get `Write`; add a tool only if the role requires it. -6. **Templates** use `<>` for user-filled fields and stay short. Spec templates also +6. **Gates are machine-checked.** A gate heading must halt (a literal `STOP` inside its block) and offer a machine-readable option set — a slash-separated parenthetical like `(yes / revise / abort)`, or two or more top-level `- ` bullets. A HARD gate must not *list* an override as a choice; describing one in prose is fine. Changing how many gates a workflow has is a deliberate two-file edit: the heading and `contractLint.gates` in the manifest. +7. **Templates** use `<>` for user-filled fields and stay short. Spec templates also use `<>` for cross-phase fields that Phase N must fill from measured evidence — the two forms have opposite rules (author-fill must be gone by `approved`; phase-deferred must still be there), and `/sd:spec validate` enforces both. Never pre-fill a `<>`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3c18f4..53ccc02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ per-file-type guidelines, and how to test changes locally. - [Project goals and non-goals](#project-goals-and-non-goals) - [Repo layout](#repo-layout) - [The manifest](#the-manifest) +- [Threshold re-calibration](#threshold-re-calibration) - [PR process](#pr-process) - [Per-file-type guidelines](#per-file-type-guidelines) - [Commands (`commands/*.md`)](#commands-commandsmd) @@ -41,13 +42,13 @@ per-file-type guidelines, and how to test changes locally. ``` specwright/ - commands/ # 13 slash commands (markdown with frontmatter) + commands/ # 14 slash commands (markdown with frontmatter) agents/ # 6 subagent definitions (markdown with frontmatter) hooks/ powershell/ # 3 PowerShell hooks bash/ # 3 bash hooks (parity with PowerShell) templates/ # 4 setup templates - specs/ # 5 spec templates + specs/ # 6 spec templates install/ # install.ps1 + install.sh + install/README.md docs/ # architecture, usage, walkthrough, troubleshooting examples/ # demo references @@ -79,10 +80,16 @@ What this means in practice: - **Writing intentionally historical docs** (superseded counts as a past-state record): put the path in `historicalExclusions`. `docs/history/`, `docs/superpowers/` and `CHANGELOG.md` are already excluded. Never "fix" their numbers to match today's disk state. +- **Publishing the current released version**: add a `versionClaims` entry (`file` + a `pattern` + with one capture group, no `equals`). It is checked against the newest dated + `## [x.y.z] - ` heading in `CHANGELOG.md`, not against a manifest quantity - CHANGELOG is + the single source of truth for "what version is released." Unlike `docClaims`, there is no + undeclared-claim scan for version strings yet: a version claim in a doc that is not listed here + is not caught. Two constraints on `pattern`: it must be valid in **both** POSIX ERE (bash `[[ =~ ]]`) and .NET (PowerShell), so use `[0-9]` rather than `\d` and avoid lookarounds; and it is matched -**case-sensitively** on both platforms. +**case-sensitively** on both platforms. This applies to `versionClaims` patterns too. `scripts/selftest-docs.{ps1,sh}` proves Check 7 still bites, by corrupting a throwaway copy of the repo and asserting the validator catches it. CI runs it on all three OSes. @@ -98,6 +105,73 @@ Check 7 needs `jq` on Unix and **fails loudly without it**. This is the opposite below (hooks exit `0` silently when `jq` is missing so they never block a user on their own bugs) - a validator that skipped itself for a missing tool would turn CI green while checking nothing. +### Contract lint (Check 8) + +Where Check 7 guards *inventory*, Check 8 guards the **relationships between** the prompt files: +which agent a command invokes, which skill an agent loads, which template a prompt reads, how many +hard gates a workflow declares. It is a script, not a prompt - `scripts/contract-lint.{ps1,sh}`, +configured entirely from the manifest's `contractLint` subtree. Full rule catalogue and rationale: +[`docs/contract-lint.md`](docs/contract-lint.md). + +Run it directly while iterating: + +```bash +bash scripts/contract-lint.sh --root . +``` +```powershell +.\scripts\contract-lint.ps1 -Root . +``` + +Exit `0` means no BLOCK findings, `1` means at least one, and **`2` means it could not run at all** +(missing manifest, missing `jq`, or the registry parity guard tripped). Check 8 treats `2` as a +failure for the same reason Check 7 refuses to skip itself. + +**Suppressing a finding.** Rarely, a violation is correct on purpose. Put a comment on the offending +line or the line above it, naming the rule and giving a real reason: + +```text + +``` + +Three things constrain that escape hatch, and all three are enforced: + +- **The reason is mandatory.** Under ten non-separator characters fails as CL900. "`- x`" is not a + reason. +- **The rule id must exist.** A typo fails as CL901 rather than silently suppressing nothing. +- **It must actually suppress something.** A suppression that outlives the finding it was written + for fails as CL902 - the same anti-rot posture as Check 7's vacuous-claim rule. + +A suppression can never suppress CL900, CL901 or CL902; that would be a self-authorizing loophole. + +**Adding a rule** means four edits, and skipping any one of them fails CI: a `contractLint.rules` +registry entry, a rule function in *both* implementations, a fixture case under +`tests/contract-lint/` whose `expected.json` names the rule, and a row in `docs/contract-lint.md`. +Each edge of that square is guarded by a different mechanism - the linters' own registry parity +guard, and invariants C and D in `tests/contract-lint/run-selftest.ps1`. + +`tests/contract-lint/run-selftest.ps1` is the fixture suite. Like the hook conformance harness it is +a single pwsh script by design: it runs both implementations in one process, so parity is asserted +rather than inferred. `-SelfTest` swaps in a linter that reports nothing and asserts the harness +notices. + +--- + +## Threshold re-calibration + +Every hardcoded threshold in this repo (Gate Complexity's tasks/layers/files limits, +`retroStaleMinutes`, `debounceMinutes`, `maxLessons`, `metrics.maxSizeKb`, the perf gate's noise +floor) started as an estimate, not a measurement - see `docs/adr/0004-threshold-calibration.md`. +Re-run the calibration pass **every 20 closed specs, or at each minor release, whichever comes +first**: + +1. Run `/sd:status --calibration` against the accumulated `.specs/index.md` and + `.specs/_metrics/events.jsonl`. +2. For each threshold, record the verdict - keep, change, or insufficient data - in a new ADR under + `docs/adr/`. "Insufficient data" is a legitimate, expected outcome at a thin corpus size; do not + change a threshold without a stated measurement behind it. +3. Where a threshold's rationale in `templates/project-config.template.json` is still a judgement + call (no measured basis), leave its `_..._use` caveat in place rather than removing it. + --- ## PR process @@ -189,6 +263,32 @@ skills: - Agent must read `CLAUDE.md` and `constitution.md` at runtime. No hardcoded stack assumptions (no `cs`, `csproj`, `dotnet`, etc. literal references unless they come from project config). - Every finding cites `file:line`. No prose without citations. +**Machine-readable input declarations.** Under every heading that selects a distinct agent +behavior by field value (`## Mode N: TASK = `, `## Task type: ```, `### `TASK = +``, `### `WORKFLOW_TYPE = ``), add two lines immediately before the existing prose: + +``` +Inputs (required): SPEC, IMPACT +Inputs (optional): MODE, REPLAN_SCOPE +``` + +- Tokens are `UPPER_SNAKE`, comma-separated, exactly the identifiers the calling command sets - + never a paraphrase. +- Write `none` explicitly when a mode has no required (or no optional) inputs. Silence is not an + assertion - an omitted line reads as "not yet documented," not as "empty." +- The existing prose `Inputs: ...` line (with parenthetical caveats, cross-references, etc.) stays + below unchanged - the two new lines are additive, for tooling to grep, not a replacement for the + explanatory prose. +- When adding or changing an agent invocation in `commands/*.md`, cross-check it against the + target mode's declared inputs: a token the command passes that the mode doesn't declare, or a + required token the mode declares that the command omits, is a real defect - fix the mismatch (add + the missing token to the declaration or the invocation, whichever is actually correct) rather than + leaving the two out of sync. +- This cross-check is machine-enforced: Check 8's `CL100`-`CL104` rules (`docs/contract-lint.md`) + parse both sides and BLOCK on a mode mismatch or a missing required input. A legitimate mismatch + the declaration can't express (an either/or required set, for example) gets a + `` suppression, not a silent gap. + ### Hooks Hooks come in pairs. If you change `hooks/powershell/foo.ps1`, you also update `hooks/bash/foo.sh`. The repo CI runs `scripts/validate.{ps1,sh}`, which refuses PRs where the pair drifts (along with the other engine-invariant checks). diff --git a/README.md b/README.md index 6ad466e..34ab1ed 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,134 @@ # specwright -> **Spec-driven development workflows for Claude Code.** -> 13 slash commands, 6 specialized subagents, 3 guard-rail hooks, 9 templates, 8 reusable skills - all under the `sd:` namespace, stack-agnostic, cross-platform, and ready to drop into any project. +> **Claude Code cannot touch your code until a spec is approved.** +> 14 slash commands, 6 specialized subagents, 3 guard-rail hooks, 10 templates, 9 reusable skills - all under the `sd:` namespace, stack-agnostic, cross-platform, and ready to drop into any project. +[![Release](https://img.shields.io/github/v/release/developzoneio/specwright)](https://github.com/developzoneio/specwright/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Claude Code](https://img.shields.io/badge/Claude%20Code-compatible-blue)](https://docs.claude.com/en/docs/claude-code) [![Cross-platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#compatibility-matrix) +[![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-ff5e5b?logo=ko-fi&logoColor=white)](https://ko-fi.com/developzone) ---- +## What it looks like + +You ask for a feature. Nothing gets written yet - the workflow drafts a spec and **stops**: + +``` +> /sd:feature todo-priority + +Phase 1 sd-spec-architect -> .specs/FEAT-todo-priority/00-spec.md + + Why A todo carries no urgency signal, so every caller that needs + ordering keeps that data outside the library's own validation. + Success criteria AC-1 .. AC-9, each requiring a file:line or test citation + Out of scope 6 items, incl. "no setPriority path in this iteration" + Constitution 1.1 Layer rules OK / 2.3 Error handling OK + 3 Quality bars: integration test MANDATORY + 6 Forbidden: 1 exception, scoped and recorded + + Gate 1 - Spec approval [STOP] + + Approve spec FEAT-todo-priority? (yes / refine / abort) +_ +``` + +That STOP is not a suggestion. Until you answer, the `spec-gate` hook denies every `Edit` / `Write` +call at the tool layer - the model cannot quietly start coding while you are still reading. + +When the work is done, the review gate is just as blunt: + +``` + Gate 3 - Integration + review pass [STOP] + + Tests 18/18 pass (8 original + 6 Domain + 4 Application), original 8 unedited + Review 0 BLOCK / 0 WARN / 5 SUGGEST / 7 PASS + S1 .specs/constitution.md:143 - DTO bullet now inconsistent + with the refreshed Aggregate-root bullet at :141 + + All clean for FEAT-todo-priority? (yes / address findings / abort) +_ +``` + +Every number above is real and committed. Read the whole run - spec, plan, tasks, decisions, retro, +verify - at [`examples/fixture-project/.specs/FEAT-todo-priority/`](examples/fixture-project/.specs/FEAT-todo-priority/). ## Why spec-driven? -Most AI coding assistants are great at producing diff-shaped output. They are less great at remembering **why** a change was made, **what** invariants must hold, or **whether** the fix even addressed the right cause. `specwright` enforces a thin layer of discipline: +Most AI coding assistants are great at producing diff-shaped output. They are less great at +remembering **why** a change was made, **what** invariants must hold, or **whether** the fix even +addressed the right cause. `specwright` adds a thin layer of discipline: every non-trivial change +starts with a written spec, workflows refuse to advance without explicit approval, and every +artifact lands in `.specs//` as durable, searchable memory. -- Every non-trivial change starts with a written spec. -- Workflows have **hard gates** - they refuse to proceed without explicit approval. -- Every artifact lands in `.specs//` as durable, searchable memory. -- Heavy reasoning (spec, debug, review) uses `sonnet`; mechanical work (read-only nav, single-task execution) uses `haiku`. Cost stays sane. +It runs on three layers: a generic engine installed once into `~/.claude/`, per-project context in +each repo's `CLAUDE.md` and `.specs/constitution.md`, and the Claude Code session where the two +meet. The engine never changes per project - which is what lets one `/sd:feature` workflow run on +.NET, Node, Python, Go, or Rust. -The system is **stack-agnostic**. Agents read `CLAUDE.md` and `constitution.md` at runtime; there are no hardcoded language or framework assumptions. +### How this differs from prompt-level discipline ---- +Plenty of tools ask the model nicely to plan first. Four things here are structural instead: -## Features +- **Gates halt the workflow.** Silence is not approval - the phase does not advance without an explicit answer. HARD gates (bug reproduction, perf baseline) have no override path at all. +- **The block lives outside the prompt.** `spec-gate` is a `PreToolUse` hook: with no in-progress spec, `Edit` / `Write` is denied by the CLI, not discouraged by instructions. A prompt can be argued with; a tool-level deny cannot. +- **The reviewer physically cannot auto-fix.** Its tool allowlist contains no write tools, so findings must route back through a fresh implementer call. +- **Specs are inputs, not write-ups.** `00-spec.md` through `06-verify.md` are what each subagent is handed on invocation - so they cannot rot into documentation nobody reads. -| Capability | What you get | -|---|---| -| **13 slash commands** | `/sd:feature`, `/sd:bug`, `/sd:rca`, `/sd:refactor`, `/sd:perf`, `/sd:spec`, `/sd:explore`, `/sd:review`, `/sd:setup`, `/sd:release`, `/sd:adr`, `/sd:verify`, `/sd:status` | -| **6 specialized subagents** | `sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer` | -| **3 cross-platform hooks** | `prompt-router`, `spec-gate`, `subagent-retro` (PowerShell + bash) | -| **9 templates** | 4 setup templates + 5 spec templates (feature / bug / refactor / perf / rca) | -| **8 reusable skills** | `sd-severity-taxonomy`, `sd-hypothesis-tree`, `sd-atomic-task-format`, `sd-evidence-citation`, `sd-spec-templates`, `sd-pattern-discipline`, `sd-retro-lessons`, `sd-replan-loop` | -| **Cross-platform installer** | `install.ps1` for Windows, `install.sh` for macOS/Linux. Content-hash dedup, timestamped backups, dry-run mode | -| **MCP-friendly** | Tooled out of the box for Atlassian, Context7, sequential-thinking, GitNexus, your project's database MCP, Playwright, Tavily | -| **Stack-agnostic** | Works for .NET, Node, Python, Go, Rust, anything with a `CLAUDE.md` | -| **Cost-aware** | Sonnet for reasoning, Haiku for execution. Typical feature run ~$2-3 | +Full architecture, including the cost model: [`docs/architecture.md`](docs/architecture.md). ---- +## Quickstart -## Quick install +Your first spec in under 5 minutes. You need the +[Claude Code CLI](https://docs.claude.com/en/docs/claude-code) installed and authenticated, plus +PowerShell 5.1+ on Windows or bash 4+ on macOS/Linux. -**Windows (PowerShell 5.1+):** -```powershell +**1. Install the engine.** Run the preview first, read what it plans to write, then install. + +macOS / Linux: + +```bash git clone https://github.com/developzoneio/specwright.git cd specwright -.\install\install.ps1 -DryRun # preview -.\install\install.ps1 # install to $env:USERPROFILE\.claude +./install/install.sh --dry-run # preview only - writes nothing +./install/install.sh # install to ~/.claude ``` -**macOS / Linux (bash 4+):** -```bash +Windows: + +```powershell git clone https://github.com/developzoneio/specwright.git cd specwright -./install/install.sh --dry-run # preview -./install/install.sh # install to ~/.claude +.\install\install.ps1 -DryRun # preview only - writes nothing +.\install\install.ps1 # install to $env:USERPROFILE\.claude ``` -Then in a real project: +**2. Scaffold a project.** + ``` cd claude > /sd:setup ``` -That's it. `/sd:setup` will scaffold `CLAUDE.md`, `.specs/`, and `.claude/project-config.json` interactively. - -See [`install/README.md`](install/README.md) for advanced options. - -### Uninstall +Generates `CLAUDE.md`, `.specs/` (constitution + index) and `.claude/project-config.json`. Idempotent. -```powershell -.\install\uninstall.ps1 -DryRun # preview -.\install\uninstall.ps1 # remove the five sd/ engine directories -``` - -```bash -./install/uninstall.sh --dry-run # preview -./install/uninstall.sh # remove the five sd/ engine directories -``` +**3. Run a real change.** `/sd:feature ` walks spec -> impact -> plan -> execute -> review, +stopping at Gate 1 for your sign-off. No project handy? The bundled +[`examples/fixture-project/`](examples/fixture-project/) is already scaffolded, zero dependencies. -Per-project artifacts (`.specs/`, `.claude/`, project `CLAUDE.md`) remain untouched; see -[`install/README.md`](install/README.md#uninstall) for details. +## Features ---- +| Capability | What you get | +|---|---| +| **14 slash commands** | 6 spec-producing workflows + 8 utilities - see the Commands table below | +| **6 specialized subagents** | architect, explorer, debugger, implementer, reviewer, docs-writer | +| **3 cross-platform hooks** | `prompt-router`, `spec-gate`, `subagent-retro` (PowerShell + bash) | +| **10 templates** | 4 setup templates + 6 spec templates (feature / bug / refactor / perf / rca / port) | +| **9 reusable skills** | Shared rule packs loaded from agent frontmatter, never copy-pasted per agent | +| **Cross-platform installer** | Content-hash dedup, timestamped backups, dry-run mode | +| **MCP-friendly** | Atlassian, Context7, sequential-thinking, GitNexus, your database MCP, Playwright, Tavily | +| **Stack-agnostic** | .NET, Node, Python, Go, Rust - anything with a `CLAUDE.md` | +| **Cost-aware** | Sonnet for reasoning, Haiku for execution. `/sd:status` reports your own cost from the local metrics log | ## Commands @@ -93,6 +139,7 @@ Per-project artifacts (`.specs/`, `.claude/`, project `CLAUDE.md`) remain untouc | `/sd:rca ` | Workflow | 3 | Incident analysis. **Output is the spec - no code change.** | | `/sd:refactor ` | Workflow | 6 | Coverage-gated restructure: requires >=80% coverage before touching code | | `/sd:perf ` | Workflow | 8 | Baseline-first optimization: measure -> hypothesize -> apply -> remeasure -> keep or revert | +| `/sd:port --from --scope ` | Workflow | 6 | Fidelity-first port: bridge -> freeze -> tables -> pin -> execute -> justified-diff parity | | `/sd:spec ` | Utility | - | Spec registry: list, show, status, link, archive, revive, search, validate, stats | | `/sd:explore ` | Utility | - | Read-only code navigation, single subagent call, optional save | | `/sd:review [path / "recent" / "spec ID"]` | Utility | - | Standalone constitution-compliance review with severity tags | @@ -102,121 +149,59 @@ Per-project artifacts (`.specs/`, `.claude/`, project `CLAUDE.md`) remain untouc | `/sd:verify ` | Utility | - | Verify criterion -> task -> test traceability; writes the close-out gate artifact | | `/sd:status` | Utility | - | Read-only summary of the metrics log + spec registry: in progress, gate activity, friction | ---- - -## Agents - -| Agent | Model | Tools (minimal allowlist) | Role | -|---|---|---|---| -| `sd-spec-architect` | sonnet | Read, Write, Edit, Grep, Glob, Atlassian MCP, Context7 MCP | Create / refine specs, plans, and tasks. Constitution-aware. | -| `sd-code-explorer` | haiku | Read, Grep, Glob, GitNexus MCP | Read-only navigation. Every finding cites `file:line`. | -| `sd-debugger` | sonnet | Read, Grep, Glob, Bash, sequential-thinking, GitNexus, Tavily, Context7 | Hypothesis-tree investigation. Distinguishes proximate vs root cause. | -| `sd-implementer` | haiku | Read, Write, Edit, MultiEdit, Grep, Glob, Bash, Context7 | Executes ONE atomic task. Scope-disciplined, no opportunism. | -| `sd-reviewer` | sonnet | Read, Grep, Glob, sequential-thinking, GitNexus | Severity-tagged review: BLOCK / WARN / SUGGEST / PASS. | -| `sd-docs-writer` | sonnet | Read, Write, Glob, Grep | Authors one MADR-style ADR from a spec's decisions. Writes only the ADR file. | +Command-by-command reference with worked examples: [`docs/usage.md`](docs/usage.md). -All models use **portable aliases** (`sonnet`, `haiku`) so they auto-update. +## Agents and skills ---- +Commands do not do the work themselves - they orchestrate 6 subagents, each with a focused role and a **minimal tool allowlist** that enforces that role structurally. -## Skills +| Agent | Model | Role | +|---|---|---| +| `sd-spec-architect` | sonnet | Creates and refines specs, plans, and atomic tasks. Constitution-aware. | +| `sd-code-explorer` | haiku | Read-only navigation. Every finding cites `file:line`. | +| `sd-debugger` | sonnet | Hypothesis-tree investigation. Distinguishes proximate from root cause. | +| `sd-implementer` | haiku | Executes ONE atomic task. Scope-disciplined, no opportunism. | +| `sd-reviewer` | sonnet | Severity-tagged review: BLOCK / WARN / SUGGEST / PASS. **No write tools.** | +| `sd-docs-writer` | sonnet | Authors one MADR-style ADR from a spec's decisions. Writes only the ADR file. | -Skills are shared markdown rules that agents reference via frontmatter. They live in `~/.claude/skills/sd/` (one folder per skill, each with a `SKILL.md`). Pulling rules out of agent bodies and into skills keeps agent prompts smaller and lets multiple agents share the same canonical rule without copy-paste drift. +Models use **portable aliases** (`sonnet`, `haiku`) so they auto-update - never a pinned model ID. -| Skill | Used by | Purpose | -|---|---|---| -| `sd-severity-taxonomy` | `sd-reviewer` | BLOCK / WARN / SUGGEST / PASS severity rules and the mandatory review output format. | -| `sd-hypothesis-tree` | `sd-debugger` | Enumerate-and-verify protocol with the 5 mental models, score formula, and proximate-vs-root "why" ladder. | -| `sd-atomic-task-format` | `sd-spec-architect`, `sd-implementer` | The atomic task block (11 required fields, including `Pattern refs`), canonical enums (`Step type`, `Complexity`, `Reversibility`), and atomicity rules. | -| `sd-evidence-citation` | `sd-code-explorer`, `sd-debugger`, `sd-reviewer`, `sd-docs-writer` | Citation discipline — every finding cites `file:line`. Snippet length, grouping, and what counts as evidence. | -| `sd-spec-templates` | `sd-spec-architect` | Per-template authoring rules (feature / bug / refactor / perf / rca), including which cross-phase fields to leave empty. | -| `sd-pattern-discipline` | `sd-spec-architect`, `sd-implementer`, `sd-reviewer` | Pattern discovery and adherence — new code mirrors cited precedents (`Pattern refs`); existing utilities are reused, not duplicated. | -| `sd-replan-loop` | `sd-spec-architect` (frontmatter); `/sd:feature`, `/sd:refactor`, `/sd:spec validate` (read at runtime) | Sanctioned mid-execution re-plan: the HARD Gate Re-plan, the append-only `## Revisions` log, and the `Revised-by` marker that keeps adaptivity from violating immutability. | - -Agents declare the skills they apply via a `skills:` list in their frontmatter, e.g.: - -```yaml ---- -name: sd-reviewer -skills: - - sd-severity-taxonomy - - sd-evidence-citation ---- -``` +Cross-cutting rules live in **9 skills**: markdown rule packs that agents load via a `skills:` list in their frontmatter, rather than copy-pasting the same rule into every agent body that needs it. ---- +Full tool allowlists, the command -> agent routing map, and the skill catalogue: +[`docs/architecture.md`](docs/architecture.md). ## Spec-driven structure -Every project that adopts `specwright` ends up with: +A spec is not documentation you write afterwards. It is the **input contract** handed to every +subagent invocation. `/sd:setup` scaffolds this: ``` / CLAUDE.md # Thin orchestrator (points to .specs/) .claude/ - project-config.json # Machine-readable config (paths, models, MCP, hooks) + project-config.json # Paths, models, MCP, hooks - machine-readable settings.json # Claude Code hook wiring - .specs/ + .specs/ # Everything below is durable, greppable memory constitution.md # Architectural rules + conventions + quality bars index.md # Registry of all specs with lifecycle states - _explorations/ # Scratchpad for /sd:explore saves - _reviews/ # Scratchpad for /sd:review saves - _adr/ # Architecture decision records from /sd:adr + _explorations/ _reviews/ _adr/ # /sd:explore, /sd:review, /sd:adr output FEAT-INV-2501/ # One folder per spec 00-spec.md # Why / What / Success criteria / Constitution check 01-plan.md # Implementation plan 02-tasks.md # Atomic tasks with Files / Layer / Acceptance 03-decisions.md # Impact analysis from sd-code-explorer - 04-artifacts/ # Evidence: logs, queries, traces, screenshots, ticket snapshots + 04-artifacts/ # Evidence: logs, queries, traces, ticket snapshots 05-retro.md # Post-execution retro - BUG-1247/ - ... - REF-extract-pricing-20260112/ - ... - PERF-search-endpoint-20260114/ - ... - RCA-payment-outage-20260108/ - ... -``` - -A spec is not "documentation you write afterwards". It is the **input contract** to every subagent invocation. - ---- - -## Architecture highlights - + BUG-1247/ REF-... PERF-... RCA-... PORT-... # same shape, one folder each ``` -+--------------------------------------------------------------+ -| User scope (~/.claude/) installed once | -| commands/sd/ agents/sd/ hooks/sd/ | -| templates/sd/ skills/sd/ | -+--------------------------------------------------------------+ - | - v -+--------------------------------------------------------------+ -| Project scope (/) per-project context | -| CLAUDE.md .claude/ .specs/ | -+--------------------------------------------------------------+ - | - v -+--------------------------------------------------------------+ -| Runtime (Claude Code session) | -| main thread <-> subagents <-> hooks | -+--------------------------------------------------------------+ -``` - -- **3 layers** - generic engine (user scope), per-project context (project scope), live conversation (runtime). The engine never changes per project; context comes from `CLAUDE.md` + `constitution.md` + `project-config.json`. -- **Hard gates** - workflows refuse to proceed without explicit approval at named checkpoints (spec approval, reproduction confirmed, baseline measured, plan approval, review pass, etc.). -- **Cost-aware models** - `sonnet` for reasoning agents (architect, debugger, reviewer); `haiku` for execution agents (implementer, explorer). Override per-task when needed. -- **Stack-agnostic** - the same `/sd:feature` workflow runs on .NET, Node, Python, Go, or Rust. Agents read project context at runtime. - -Full architecture: [`docs/architecture.md`](docs/architecture.md). ---- +`PORT-` specs add one thing: `04-artifacts/source/`, a frozen donor snapshot whose `MANIFEST.md` +records each file's donor path, commit, hash and member line ranges, protected once frozen. ## MCP integrations -`specwright` is designed around the MCP servers most useful for spec-driven work. None are required; agents fall back gracefully. +None are required - agents fall back gracefully. Configure per project in `.claude/project-config.json` under `mcp`. | MCP server | Used by | Purpose | |---|---|---| @@ -228,56 +213,69 @@ Full architecture: [`docs/architecture.md`](docs/architecture.md). | **Playwright** | optional | E2E reproduction for `/sd:bug` | | **Tavily** | `sd-debugger` | Web search for error signatures / library issues | -Configure per project in `.claude/project-config.json` under the `mcp` section. - ---- - ## Compatibility matrix | Component | Tested on | Notes | |---|---|---| -| Claude Code CLI | Latest as of Jan 2026 | Hook contract: `UserPromptSubmit`, `PreToolUse`, `SubagentStop` | -| Windows 11 | PowerShell 5.1 and 7.x | PS 5.1 reads UTF-8 as CP1252 - hooks are pure ASCII | -| macOS 13+ | bash 4+ via Homebrew | `stat -f %m` syntax supported | -| Ubuntu 22.04+ | bash 5 | `stat -c %Y` syntax supported | +| Claude Code CLI | Latest as of Aug 2026 | Hook contract: `UserPromptSubmit`, `PreToolUse`, `SubagentStop` | +| OS | Windows 11 (PS 5.1 + 7.x), macOS 13+, Ubuntu 22.04+ | PS 5.1 reads UTF-8 as CP1252, so hooks are pure ASCII; bash hooks branch `stat -f %m` vs `stat -c %Y` | | jq | 1.6+ | Optional. Bash hooks exit 0 if missing. | -| .NET stack | ASP.NET Core 8 | Stack-agnostic - .NET is just one example | -| Node stack | Node 20+ / TS 5+ | Same `/sd:feature` workflow | -| Python stack | 3.11+ / FastAPI / Django | Same `/sd:feature` workflow | +| Node stack | Node 20+ (plain JS) | Demonstrated end-to-end in [`examples/fixture-project/`](examples/fixture-project/) - a real `/sd:feature` run, committed, not just asserted. TS not yet exercised. | +| .NET / Python stacks | ASP.NET Core 8, Python 3.11+ | Same workflow - not yet demonstrated with a committed example | ---- +## Install options and uninstall + +The [Quickstart](#quickstart) covers the common path; for custom install roots, selective areas and +backup behavior see [`install/README.md`](install/README.md). Uninstalling removes the five `sd/` +engine directories and leaves every per-project artifact (`.specs/`, `.claude/`, `CLAUDE.md`) alone: + +macOS / Linux: + +```bash +./install/uninstall.sh --dry-run # preview only - removes nothing +./install/uninstall.sh # remove the five sd/ engine directories +``` + +Windows: + +```powershell +.\install\uninstall.ps1 -DryRun # preview only - removes nothing +.\install\uninstall.ps1 # remove the five sd/ engine directories +``` ## Documentation -- [`docs/architecture.md`](docs/architecture.md) - 3-layer design, lifecycle, cost model +- [`docs/architecture.md`](docs/architecture.md) - 3-layer design, agent routing, skills, lifecycle, cost model - [`docs/usage.md`](docs/usage.md) - Command-by-command reference with examples -- [`docs/walkthrough.md`](docs/walkthrough.md) - End-to-end fictional project demo +- [`docs/walkthrough.md`](docs/walkthrough.md) - End-to-end demo on a fictional project (illustrative prose) - [`docs/troubleshooting.md`](docs/troubleshooting.md) - Common issues and fixes +- [`docs/contract-lint.md`](docs/contract-lint.md) - How cross-file contracts are machine-checked - [`install/README.md`](install/README.md) - Install guide and options -- [`CONTRIBUTING.md`](CONTRIBUTING.md) - PR process and dev guidelines -- [`CHANGELOG.md`](CHANGELOG.md) - Release notes -- [`ROADMAP.md`](ROADMAP.md) - Planned and exploratory work +- [`CONTRIBUTING.md`](CONTRIBUTING.md), [`CHANGELOG.md`](CHANGELOG.md), [`ROADMAP.md`](ROADMAP.md) ---- +**Runnable examples:** [`fixture-project/`](examples/fixture-project/) - committed worked spec; +[`port-parity-fixture/`](examples/port-parity-fixture/) - `/sd:port` fidelity gates; +[`spec-lint-fixture/`](examples/spec-lint-fixture/) - malformed specs `/sd:spec validate` must catch. ## Roadmap -Forward-looking work lives in [`ROADMAP.md`](ROADMAP.md). Highlights: +Shipped work is in [`CHANGELOG.md`](CHANGELOG.md); the latest release is +[v1.5.0](https://github.com/developzoneio/specwright/releases). Next up, per [`ROADMAP.md`](ROADMAP.md): +GitHub Issue auto-fetch (`gh issue view`) to match the existing JIRA snapshot path, plus - exploratory - +local-only, opt-in usage analytics. Have a workflow you wish existed? +[Open an issue](https://github.com/developzoneio/specwright/issues/new) - the roadmap follows what people actually hit. -- **Near-term** - GitHub Issue auto-fetch (`gh issue view`) to match the existing JIRA snapshot path. -- **Planned** - nothing queued right now. -- **Exploratory** - local-only, opt-in usage analytics. +## Support -Shipped work is in [`CHANGELOG.md`](CHANGELOG.md). +Enjoying `specwright`? A coffee goes a long way toward keeping it maintained - +[buy me one on Ko-fi](https://ko-fi.com/developzone). Thank you! ---- +A star on the repo helps others find it. Using `specwright` at work? [Open an issue](https://github.com/developzoneio/specwright/issues/new) and let us know - we'd love to list you. -## License +## License and acknowledgements MIT. See [`LICENSE`](LICENSE). ---- - -## Acknowledgements - -Inspired by the spec-driven discipline of long-running software teams, and by the [BMAD method](https://github.com/) for structuring AI-assisted workflows. Built on top of [Claude Code](https://docs.claude.com/en/docs/claude-code) by Anthropic. +Inspired by the spec-driven discipline of long-running software teams, and by the +[BMAD method](https://github.com/bmad-code-org/BMAD-METHOD) for structuring AI-assisted workflows. +Built on [Claude Code](https://docs.claude.com/en/docs/claude-code) by Anthropic. diff --git a/ROADMAP.md b/ROADMAP.md index bcf6eab..f0b8a2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ Planned and exploratory work for specwright. Shipped items live in [`CHANGELOG.m this file is forward-looking only and intentionally non-binding - priorities shift as the engine is dogfooded on real projects. -Current released version: **1.3.0** (see the [changelog](CHANGELOG.md) for what shipped). +Current released version: **1.5.0** (see the [changelog](CHANGELOG.md) for what shipped). ## Near-term @@ -19,8 +19,9 @@ Small, well-scoped items targeted at the next minor release. Larger items that each warrant a full `/sd:feature` spec before building. -_Nothing queued right now - the last two Planned items (`/sd:setup` codebase scan, `sd-docs-writer` -agent) shipped; see [`CHANGELOG.md`](CHANGELOG.md)._ +_Nothing new proposed right now. Substantial work is already specced and built, queued for the +next version cut - see the `[Unreleased]` section of [`CHANGELOG.md`](CHANGELOG.md) for what +that is._ ## Exploratory diff --git a/agents/code-explorer.md b/agents/code-explorer.md index ad2d7da..fc90439 100644 --- a/agents/code-explorer.md +++ b/agents/code-explorer.md @@ -1,7 +1,7 @@ --- name: sd-code-explorer color: cyan -description: Read-only code navigation. Six task types covering definition, callers, traces, impact mapping, pattern search, and structural overview. Every finding cites file:line. Use this agent for any read-only exploration; do NOT invoke for fixes or refactors. +description: Read-only code navigation. Seven task types covering definition, callers, traces, impact mapping, pattern search, structural overview, and donor-side port extraction. Every finding cites file:line. Use this agent for any read-only exploration; do NOT invoke for fixes or refactors. model: haiku tools: Read, Grep, Glob, mcp__gitnexus__query, mcp__gitnexus__context, mcp__gitnexus__impact, mcp__gitnexus__list_repos skills: @@ -26,12 +26,18 @@ You are the code explorer for specwright. You navigate codebases and report find ### `TASK = standalone` +Inputs (required): DETECTED_INTENT, QUERY +Inputs (optional): GITNEXUS_AVAILABLE + Inputs: `DETECTED_INTENT` (one of `definition`, `callers`, `trace`, `impact`, `pattern`, `structure`), `QUERY` (free-form). Route internally based on `DETECTED_INTENT`. Use the matching sub-routine below. Output is markdown grouped by file with citations. ### `TASK = impact-map` +Inputs (required): SPEC, OUTPUT_TARGET +Inputs (optional): none + Inputs: `SPEC` (path to `00-spec.md`), `OUTPUT_TARGET` (informational - typically `03-decisions.md`; identifies which file the caller will append your output to). @@ -92,6 +98,9 @@ Structure of the returned analysis (the caller appends this verbatim): ### `TASK = callers` +Inputs (required): none +Inputs (optional): SYMBOL, QUERY + Inputs: `SYMBOL` or `QUERY`. GitNexus-first: `mcp__gitnexus__impact` with `target: SYMBOL`, `direction: upstream`. Fall back: `Grep` for invocation patterns (`SymbolName(`, `\.SymbolName\(`). @@ -100,6 +109,9 @@ Output: list of `file:line` with the calling context (one line of code). ### `TASK = definition` +Inputs (required): none +Inputs (optional): SYMBOL, QUERY + Inputs: `SYMBOL` or `QUERY`. GitNexus-first: `mcp__gitnexus__context` with the symbol name (pass `file_path` to disambiguate if multiple candidates are returned). Fall back: `Grep` for definition markers (e.g. `class SymbolName`, `def SymbolName`, `function SymbolName`, `interface SymbolName`). @@ -108,6 +120,9 @@ Output: `file:line` + 5-line snippet showing the definition. ### `TASK = trace` +Inputs (required): ENTRY_POINT +Inputs (optional): DEPTH + Inputs: `ENTRY_POINT` (symbol or `file:line`), optional `DEPTH` (default 2). GitNexus-first: `mcp__gitnexus__impact` with `target: ENTRY_POINT`, `direction: downstream`, `maxDepth: DEPTH`. Fall back: recursive `Grep` for callers up to `DEPTH` hops (note: imprecise for dynamic dispatch). @@ -116,6 +131,9 @@ Output: indented tree with `file:line` at each node. ### `TASK = pattern` +Inputs (required): QUERY +Inputs (optional): none + Inputs: `QUERY`. Refine the query into a grep-friendly pattern. Use `Grep` (preferred for raw text patterns; GitNexus is for symbols, not arbitrary text). @@ -124,12 +142,142 @@ Output: grouped by file when >5 hits in one file. Limit total to 50 results; tel ### `TASK = structure` +Inputs (required): none +Inputs (optional): PATH + Inputs: `PATH` (directory) or none (project root). Use `Glob` to list files, `mcp__gitnexus__query` (goal naming the directory) for a symbol overview (or top-of-file `Read` for the first 30 lines). Output: tree of directories + files + top-level symbols per file. +### `TASK = port-extract` + +Inputs (required): ENTRY_POINT, SCOPE +Inputs (optional): GITNEXUS_AVAILABLE + +Inputs: `ENTRY_POINT` (symbol, route, or `file:line` naming the donor-side extraction target), +`SCOPE` (one of `endpoint`, `module`, `feature`, `pattern` - matches a host port spec's `scope` +frontmatter field). + +Runs as a donor-side session only. This mode describes ONE project - the one this session is +rooted in. Never read, infer, or mention a second (host) project's `CLAUDE.md`, constitution, or +file paths; that boundary is load-bearing, not a style preference. + +Behavior: +1. Resolve `ENTRY_POINT` to its definition. GitNexus-first: `mcp__gitnexus__context`. Fall back: + `Grep` for definition markers. +2. Walk the eight sections below in the fixed order shown. Each is a fixed shape, not free-form: + fill every section, or write exactly `None found (searched: ).` when a section + is genuinely empty - never omit a section and never fold one section's findings into another. +3. Member closure: GitNexus-first: `mcp__gitnexus__impact` with `target: ENTRY_POINT`, + `direction: downstream`, no depth cap - the closure must be complete, not sampled. Complement + set: `direction: upstream` on each member found, keep only callers outside `ENTRY_POINT`'s own + tree. Collaborators: `mcp__gitnexus__context` per member for its dependency edges. Fall back + for all three: recursive `Grep`/`Glob` (imprecise for dynamic dispatch, DI-container + resolution, and reflection-based lookups - name this caveat in your output when GitNexus is + disabled, per "Always do first"). +4. Do not attempt to write files - your tool allowlist has no `Write`/`Edit` by design. Return + the eight sections as your final output. The calling command captures it as the donor-side + contract file, adds a `source_commit` line it computes itself, and - only when the caller + requests `snapshot: contract+source` - copies the donor files named in Member closure and + Complement set alongside it. That copy is a plain file operation the command performs; you are + never asked to perform it. + +Output: + +```markdown +## Port extraction (sd-code-explorer) + +### Entry surface + +- Route / entry point: `file:line` +- Verb / trigger: +- Parameters: + + | Name | Type | Source | Required | + |---|---|---|---| + | | | | | + +- Auth requirement: `file:line` , or `None found (searched: + ).` + +### Output surface + +- Result shape: `file:line` +- Success codes: `file:line` +- Error codes: `file:line` +- Error body shape: `file:line` , or `None found (searched: ).` + +### Member closure + +Every member transitively reachable from `ENTRY_POINT`. `Donor path`, `Ordinal`, and `Member` +carry over verbatim into the host's Member manifest table. + +| Donor path | Ordinal | Member | file:line | Lines | +|---|---|---|---|---| +| | <1-based, contiguous within this Donor path> | | | | + +`None found (searched: ).` only when `ENTRY_POINT` itself could not be resolved - +report that as a failure, never as an empty closure. + +### Complement set + +Members of the same type(s) touched above, reachable ONLY from a different entry point - +explicitly out of this extraction's scope. + +| Donor path | Member | file:line | Reachable only via | +|---|---|---|---| +| | | | | + +`None found (searched: ).` when nothing in scope is out of scope. + +### Collaborators + +Every dependency `ENTRY_POINT` acquires - injected, service-located, or statically acquired - +tied to the specific reachable code path that requires it. + +| Collaborator | Acquired via | Required by (file:line) | Needed for | +|---|---|---|---| +| | | | | + +`None found (searched: ).` when `ENTRY_POINT` acquires no external collaborators. + +### Non-obvious invariants + +Donor behavior a straight re-implementation gets wrong: order-sensitive mutation, +prefix/substring matching, load-bearing defaults, soft-success paths, exceptions that must +propagate. + +| file:line | Invariant | +|---|---| +| | | + +`None found (searched: ).` states what was checked - this section is never +silently empty. + +### Dead paths on this entry point + +Branches reachable from `ENTRY_POINT` that current donor inputs cannot trigger, so the host +decides explicitly whether to strip or reproduce them. + +| file:line | Branch | Why unreachable | +|---|---|---| +| | | | + +`None found (searched: ).` when every branch in Member closure is live. + +### Precedent conventions + +Sampled, never assumed - `Glob` the donor directories touched by Member closure, `Read` up to 3 +sibling files per directory, state the observed pattern with evidence (same discipline as +`impact-map`'s "Precedents & conventions"). + +- File naming: (evidence: `file`, `file`) +- Symbol naming: (evidence: `file:line`) +- Test placement: (evidence: `file` -> `test file`) +``` + --- ## Output discipline diff --git a/agents/debugger.md b/agents/debugger.md index c77ee78..4baf23c 100644 --- a/agents/debugger.md +++ b/agents/debugger.md @@ -24,7 +24,10 @@ You are the debugger for specwright. You hypothesize, verify with evidence, and ## Task type: `enumerate` -Inputs: `SPEC_REF`, optionally `REPRODUCTION`, `EVIDENCE_DIR`. +Inputs (required): SPEC_REF +Inputs (optional): REPRODUCTION, EVIDENCE_DIR, MODE + +Inputs: `SPEC_REF`, optionally `REPRODUCTION`, `EVIDENCE_DIR`, `MODE` (e.g. `incident` from `/sd:rca`). Goal: produce 4–8 ranked hypotheses using the **sd-hypothesis-tree** skill (5 mental models, scoring formula `(L×I)/C`, ranked table output format). @@ -34,6 +37,9 @@ Use `mcp__sequential-thinking__sequentialthinking` to structure reasoning. If yo ## Task type: `verify` +Inputs (required): HYPOTHESIS, EVIDENCE_DIR +Inputs (optional): none + Inputs: `HYPOTHESIS` (one entry from the tree), `EVIDENCE_DIR` (for saving artifacts). Goal: produce a CONFIRMED / REJECTED / INCONCLUSIVE verdict for ONE hypothesis, following the **sd-hypothesis-tree** skill's verdict format and proximate-vs-root ladder. @@ -51,7 +57,12 @@ Evidence discipline follows the **sd-evidence-citation** skill: ## Task type: `hotspot-analysis` -Inputs: `SPEC_REF`, `SUB_MODE` (`A` or `B`), `BASELINE_ARTIFACT` (mode A) or `HOTSPOT` (mode B). +Inputs (required): SUB_MODE +Inputs (optional): SPEC_REF, BASELINE_ARTIFACT, HOTSPOT + +Inputs: `SPEC_REF`, `SUB_MODE` (`A` or `B`), `BASELINE_ARTIFACT` (mode A) or `HOTSPOT` (mode B). Only +sub-mode A's caller (`/sd:perf`) passes `SPEC_REF`/`BASELINE_ARTIFACT`; sub-mode B's caller passes +only `HOTSPOT`. ### Sub-mode A: identify hotspots diff --git a/agents/implementer.md b/agents/implementer.md index 9b9d0c5..b019b0f 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -1,7 +1,7 @@ --- name: sd-implementer color: green -description: Executes ONE atomic task per invocation. Scope-disciplined - edits only files declared in TASK_DETAILS.Files. Workflow-specific constraints for feature/bug/refactor/perf. Main thread can override to sonnet model for complex tasks. +description: Executes ONE atomic task per invocation. Scope-disciplined - edits only files declared in TASK_DETAILS.Files. Workflow-specific constraints for feature/bug/refactor/perf/port. Main thread can override to sonnet model for complex tasks. model: haiku tools: Read, Write, Edit, MultiEdit, Grep, Glob, Bash, mcp__context7__resolve-library-id, mcp__context7__query-docs skills: @@ -68,28 +68,61 @@ If `TASK_DETAILS` is missing, malformed, or vague ("update the service") -> STOP The main thread passes `WORKFLOW_TYPE`. Apply the matching constraint set. ### `WORKFLOW_TYPE = feature` + +Inputs (required): TASK_DETAILS, SPEC_REF +Inputs (optional): IMPACT_REF + - New behavior is expected. - New tests in `Test` field expected to be authored as part of the task. - Public API additions are fine if the spec calls for them. ### `WORKFLOW_TYPE = bug` + +Inputs (required): TASK_DETAILS, SPEC_REF, ROOT_CAUSE +Inputs (optional): IMPACT_REF + - Read `ROOT_CAUSE` field. Your fix must address THIS cause, not the symptom. - Fix is MINIMAL. Smallest diff that makes the failing test pass. - A failing test was authored BEFORE this invocation (in `/sd:bug` Phase 4). Run it FIRST to confirm it fails. Apply fix. Run it AGAIN to confirm it passes. - NO new public API. NO refactor. NO reformatting. ### `WORKFLOW_TYPE = refactor` + +Inputs (required): TASK_DETAILS, SPEC_REF, INVARIANTS +Inputs (optional): IMPACT_REF + - Read `INVARIANTS` field. Verify each one after every edit. - Behavior-preserving. NO new public API. NO new feature. NO behavior change. - All existing tests must continue to pass without test modifications. (Renames of test names are OK if they mirror renames in production code; behavior changes in tests are NOT OK.) - If you discover an invariant cannot be preserved under the planned approach, STOP and surface. ### `WORKFLOW_TYPE = perf` + +Inputs (required): TASK_DETAILS, SPEC_REF, CONSTRAINTS +Inputs (optional): IMPACT_REF + - Read `CONSTRAINTS` field. Correctness is non-negotiable. - ONE optimization per invocation. If the hypothesis bundles two ideas, split before invoking. - Functional tests must continue to pass without modification. - If you can achieve the optimization only by changing observable behavior, STOP and surface - that needs a spec amendment. +### `WORKFLOW_TYPE = port` + +Inputs (required): TASK_DETAILS, SPEC_REF +Inputs (optional): IMPACT_REF + +- The snapshot member range cited in `Pattern refs` is the specification. Reproduce it + structurally: file layout, member set, member order, symbol names, local variable names, step + order, attribute order, log message text. +- Apply ONLY the deviation IDs the task's `Acceptance` licenses. Anything not on that list is + reproduced as-is - no rename, no reorder, no simplification, no opportunistic fix, even where + another form reads better. +- A donor defect is reproduced, not fixed. Note it in your summary. +- If the snapshot form cannot work in the host without a change the task does not license, STOP + and return `STATUS = needs-clarification` naming the conflict. A missing deviation row is a + planning defect, never something to invent. +- A task whose `Step type` is `test` writes only test files; the production tree stays untouched. + --- ## Step-by-step procedure @@ -140,6 +173,8 @@ Do this in your head (or via sequential-thinking if complex). Do not produce a p - [ ] For bug: fix targets root cause, not symptom. - [ ] For refactor: invariants verified. - [ ] For perf: ONE change applied; correctness tests still pass. +- [ ] For port: only the licensed deviation IDs were applied; everything else matches the cited + snapshot member range. Return to main thread with: file list edited, test results, one-line summary. diff --git a/agents/reviewer.md b/agents/reviewer.md index 5bd419d..b749a90 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -1,13 +1,14 @@ --- name: sd-reviewer color: purple -description: Severity-tagged compliance review. Five task types covering per-task, holistic, standalone, bug-fix-final, and perf-final review. Every finding cites file:line and a constitution §section. Never auto-fixes, never prescribes exact code. +description: Severity-tagged compliance review. Six task types covering per-task, holistic, standalone, bug-fix-final, perf-final, and port-parity review. Every finding cites file:line and a constitution §section. Never auto-fixes, never prescribes exact code. model: sonnet tools: Read, Grep, Glob, mcp__sequential-thinking__sequentialthinking, mcp__gitnexus__impact skills: - sd-severity-taxonomy - sd-evidence-citation - sd-pattern-discipline + - sd-port-fidelity --- You are the reviewer for specwright. You verify that code matches the spec, the constitution, and the conventions. You tag findings by severity. You do not auto-fix. You do not write exact fix code (suggest direction; let the implementer decide). You cite `file:line` and the constitution `§N.M` on every finding. @@ -18,7 +19,7 @@ You are the reviewer for specwright. You verify that code matches the spec, the 1. **Read `.specs/constitution.md`** in full. Every applicable rule must be on your mind during review. 2. **Read `CLAUDE.md`** for conventions, forbidden patterns, quality bars. -3. **Read the `SPEC_REF`** if provided (`00-spec.md`). For bug review, read `ROOT_CAUSE`. For refactor review, read `INVARIANTS`. For perf review, read `Constraints` and `Results log`. +3. **Read the `SPEC_REF`** if provided (`00-spec.md`). For bug review, read `ROOT_CAUSE`. For refactor review, read `INVARIANTS`. For perf review, read `Constraints` and `Results log`. For port-parity review, read the port spec's three fidelity tables and then the `DIFF_REF` index. 4. Read the `TASK_TYPE` field. 5. Read the `CHANGED_FILES` (and the diff if available). @@ -33,11 +34,15 @@ Key reminders: - Every finding cites `file:line` (see **sd-evidence-citation** skill). - If a section has zero findings, write `_No findings._` — never omit the section. - Pattern findings (see **sd-pattern-discipline** skill): deviation from an explicit `Pattern refs` entry is WARN, anchored to the task block. Convention drift with no Pattern ref and no constitution anchor is SUGGEST. Never BLOCK solely because a task lacks a `Pattern refs` field — the field is required on every task, but a missing one is a spec-authoring defect that `/sd:spec validate` reports as `SL060` (WARN), not a defect in the code you are reviewing. +- Port fidelity findings (see **sd-port-fidelity** skill): on a changeset produced from a port spec, classify every hunk with that skill's closed five-class vocabulary — four of the five classes are BLOCK — and anchor each finding to the port spec's fidelity acceptance criterion, citing the deviation-table row, or its absence, as the supporting evidence. The `port-parity` task type is the full form, with a diff artifact and both whole-artifact checks; when you are invoked in another task type and there is no diff artifact, apply the vocabulary inside that task type instead. --- ## Task type: `per-task` +Inputs (required): TASK_REF, CHANGED_FILES, SPEC_REF +Inputs (optional): none + Inputs: `TASK_REF`, `CHANGED_FILES`, `SPEC_REF`. Checklist: @@ -54,7 +59,11 @@ Scope: just this task. ## Task type: `holistic` -Inputs: `SPEC_REF` (refactor), `INVARIANTS`, `CHANGED_FILES` (all batches). +Inputs (required): SPEC_REF, CHANGED_FILES +Inputs (optional): INVARIANTS, PLAN_REF + +Inputs: `SPEC_REF`, `CHANGED_FILES` (all batches), optionally `INVARIANTS` (refactor) or `PLAN_REF` +(feature - informational context, not itself checked against a checklist item). Checklist (in addition to per-task items applied across the union of changes): - [ ] Each invariant in `INVARIANTS` is verified. @@ -71,6 +80,9 @@ This is broader scope - look for emergent issues that per-task review missed. ## Task type: `standalone` +Inputs (required): TARGET_FILES, CONSTITUTION +Inputs (optional): SPEC_REF + Inputs: `TARGET_FILES`, `CONSTITUTION` (path), optional `SPEC_REF`. Run the full constitution against each target file. Section by section: @@ -83,6 +95,9 @@ No spec context to bind findings to (unless `SPEC_REF` is provided). Findings ci ## Task type: `bug-fix-final` +Inputs (required): SPEC_REF, ROOT_CAUSE, CHANGED_FILES +Inputs (optional): none + Inputs: `SPEC_REF`, `ROOT_CAUSE`, `CHANGED_FILES`. Checklist: @@ -96,6 +111,9 @@ Checklist: ## Task type: `perf-final` +Inputs (required): SPEC_REF, CHANGED_FILES, RESULTS_LOG +Inputs (optional): none + Inputs: `SPEC_REF`, `CHANGED_FILES`, `RESULTS_LOG`. Checklist: @@ -106,6 +124,37 @@ Checklist: - [ ] No new behavior change beyond what spec's "Trade-offs accepted" allows. - [ ] Constitution compliant. +## Task type: `port-parity` + +Inputs (required): SPEC_REF, DIFF_REF, CHANGED_FILES +Inputs (optional): SNAPSHOT_REF + +Inputs: `SPEC_REF` (the port spec carrying the path mapping, member manifest and deviation tables), +`DIFF_REF` (`04-artifacts/parity/INDEX.md`, produced by the main thread before you are invoked), +`CHANGED_FILES`, optionally `SNAPSHOT_REF` (`04-artifacts/source/`, for `MANIFEST.md` member +ranges). + +Apply the **sd-port-fidelity** skill's `Hunk classification` and `Whole-artifact checks` sections - +they define the vocabulary and the two counts; the checklist below is what a complete adjudication +looks like. + +Checklist: +- [ ] Every diff file listed in `DIFF_REF` was read. A row you could not open is a 🔴 BLOCK, never a + silent skip. +- [ ] Every hunk carries exactly one class: `justified` / `unjustified` / `missing` / `extra` / + `overreached`. No hunk left unclassified. +- [ ] Each non-`justified` hunk is one finding citing both sides (`:` and + `:`) plus the deviation row that covers it, or `no covering deviation row`. +- [ ] Each `overreached` finding names the row it exceeds and what that row's `Host form` does not + state. +- [ ] Member completeness: reported as `/`, one 🔴 BLOCK per absent row naming its + `Donor path`, `Member` and `Ordinal`. +- [ ] Path conformance: one 🔴 BLOCK per changeset file absent from the path mapping table. +- [ ] `justified` hunks counted in the summary line, not written up one by one. + +Findings only. The parity gate belongs to the caller, and so does every resolution - you have no +write tools and no way to regenerate the diff. + --- ## How to find things @@ -129,4 +178,9 @@ Reviewer-specific, not covered by either skill: - **Prescribing exact fix code.** "Change line 84 to `return result.Where(x => x.Id != null)`" is too prescriptive. "Filter out null IDs at the boundary" is the right shape - leaves the implementer to choose how. - **Reviewing the diff in isolation.** Read the surrounding context. A line that looks fine may violate a layer rule that's only visible from imports / project boundaries. - **Re-reviewing the spec itself.** The spec architect handled that. Your job is code vs spec. +- **Adjudicating a port hunk by taste.** The five-class vocabulary in **sd-port-fidelity** is + closed; an unjustified hunk is a BLOCK whether or not the change looks like an improvement, and a + hunk that outruns the row citing it is `overreached`, not `justified`. +- **Writing up justified hunks.** A justified hunk is a PASS. Reporting each one buries the BLOCKs + in a wall of accepted diffs - report the count, not the rows. - **Being verbose.** Each finding is one paragraph. Reviewer reports are scanned, not read. diff --git a/agents/spec-architect.md b/agents/spec-architect.md index c92a94e..e05b454 100644 --- a/agents/spec-architect.md +++ b/agents/spec-architect.md @@ -1,7 +1,7 @@ --- name: sd-spec-architect color: blue -description: Creates, refines, and plans specs across all 5 workflow types (feature, bug, refactor, perf, rca). Reads CLAUDE.md and constitution.md at runtime. Use this agent for any spec authoring or atomic-task planning. +description: Creates, refines, and plans specs across all 6 workflow types (feature, bug, refactor, perf, rca, port). Reads CLAUDE.md and constitution.md at runtime. Use this agent for any spec authoring or atomic-task planning. model: sonnet tools: Read, Write, Edit, Grep, Glob, mcp__atlassian__getJiraIssue, mcp__atlassian__searchJiraIssuesUsingJql, mcp__atlassian__getJiraIssueRemoteIssueLinks, mcp__atlassian__getConfluencePage, mcp__context7__resolve-library-id, mcp__context7__query-docs skills: @@ -9,6 +9,7 @@ skills: - sd-spec-templates - sd-pattern-discipline - sd-replan-loop + - sd-port-fidelity --- You are the spec architect for specwright. You produce written artifacts that downstream agents and the user trust: specs, plans, and atomic task lists. Your output is the input contract for everyone else. @@ -32,13 +33,20 @@ You operate in one of three modes, signalled by the `TASK` field in your invocat MCP tools only. Use whatever ticket detail the caller pasted into `TICKET_CONTEXT`, cite the ticket ID in the spec frontmatter, and skip the snapshot protocol (it is JIRA-specific). Do not attempt an MCP fetch. -5. Never assume a stack detail. If CLAUDE.md says "Python / FastAPI" you write Python; never default to .NET because that's what you saw last invocation. +5. Never assume a stack detail. If CLAUDE.md says "Python / FastAPI" you write Python; never default to .NET because that's what you saw last invocation. --- ## Mode 1: `TASK = create` -Inputs: `TEMPLATE`, `SPEC_ID`, optionally `TICKET_CONTEXT`, `SMELL` (refactor), `INCIDENT_DETAILS` (rca). +Inputs (required): TEMPLATE, SPEC_ID +Inputs (optional): TICKET_CONTEXT, SMELL, SOURCE_REPO, SOURCE_COMMIT, DONOR_SCOPE + +Inputs: `TEMPLATE`, `SPEC_ID`, optionally `TICKET_CONTEXT` (feature, bug), `SMELL` (refactor). No +command sets `INCIDENT_DETAILS` for the `rca` template today - `/sd:rca` fills Timeline / Symptoms +/ Affected scope / Recent changes interactively after the skeleton is created, not via an input +token. `SOURCE_REPO` / `SOURCE_COMMIT` / `DONOR_SCOPE` supply donor provenance for the `port` +template; `/sd:port` Phase 1 sets them from the bridged contract or the in-repo extraction. Output: `.specs//00-spec.md` matching the template structure exactly. @@ -50,11 +58,18 @@ estimate (`S` | `M` | `L`) plus a one-line rationale, per the "Complexity estima always `M`, and a create-time `L` estimate escalates the impact and planning models downstream. It is a spec-level estimate, distinct from a task's `Estimated complexity`. +For a **port** spec, the three fidelity tables (path mapping, member manifest, deviation table) +and the mandatory fidelity acceptance criterion are governed by the **sd-port-fidelity** skill - +read it before filling them. **sd-spec-templates** covers what else the template needs. + --- ## Mode 2: `TASK = plan` -Inputs: `SPEC` (path to `00-spec.md`), `IMPACT` (path to `03-decisions.md` from code-explorer), optionally `MODE` (`feature` | `refactor`). +Inputs (required): SPEC, IMPACT +Inputs (optional): MODE, REPLAN_SCOPE, REVISION + +Inputs: `SPEC` (path to `00-spec.md`), `IMPACT` (path to `03-decisions.md` from code-explorer), optionally `MODE` (`feature` | `refactor`). A scoped re-plan invocation (see below) passes `REPLAN_SCOPE` and `REVISION` instead of `SPEC`/`IMPACT`. Outputs: - `.specs//01-plan.md` - phased plan: Foundation -> Behavior -> Wiring -> Polish (feature), or Sequencing -> Batching (refactor). @@ -87,6 +102,9 @@ For every task that creates a new file or introduces a new public symbol: 5. If a task's `Acceptance` depends on an unfamiliar library API, verify current syntax via `mcp__context7__resolve-library-id` + `mcp__context7__query-docs` before writing the criterion - stale training data on library APIs is a real failure mode (same rule the implementer follows). +6. For a **port** spec the donor file is the precedent, not a host sibling. The + **sd-port-fidelity** skill governs the spec's gate tables and what each port task's `Acceptance` + must assert. ### Complexity self-assessment (feature plan only) @@ -145,6 +163,9 @@ text. ## Mode 3: `TASK = refine` +Inputs (required): SPEC, FEEDBACK +Inputs (optional): none + Inputs: `SPEC` (path), `FEEDBACK` (user's feedback verbatim). Behavior: diff --git a/commands/adr.md b/commands/adr.md index 3036955..d03f426 100644 --- a/commands/adr.md +++ b/commands/adr.md @@ -1,5 +1,5 @@ --- -description: Author an Architecture Decision Record (ADR) from a spec's decisions via sd-docs-writer. One hard gate before keeping the file. +description: Author an Architecture Decision Record (ADR) from a spec's decisions via sd-docs-writer. 1 hard gate before keeping the file. argument-hint: --- @@ -61,7 +61,7 @@ the ADR stays `proposed` until they change its status to `accepted`. ## Rules (hard constraints) - **Decisions come from the source, never invented.** Empty or absent decision content aborts the command. -- **One hard gate.** Nothing is kept on disk without explicit approval. +- **1 hard gate.** Nothing is kept on disk without explicit approval. - **ADRs are not specs.** No `.specs/index.md` lifecycle entry; ADRs live under `.specs/_adr/` with their own numbering. - **The constitution is never edited here.** Amending a rule is a separate `/sd:refactor` or a manual ADR diff --git a/commands/bug.md b/commands/bug.md index 005e55b..db3208d 100644 --- a/commands/bug.md +++ b/commands/bug.md @@ -96,6 +96,7 @@ STOP. This gate is HARD - no overrides. Ask: - `partial` -> ask user if they accept investigating with partial repro (logs / traces only). Log the decision and risks to retro. - `no` -> **REFUSE to proceed**. Tell the user: investigation without reproduction risks fixing the wrong thing. Options: gather more telemetry, add observability, or close as "cannot reproduce". + If the user insists on proceeding without repro, log a constitution exception to retro and proceed at their explicit risk acknowledgement. --- @@ -110,7 +111,7 @@ If the user insists on proceeding without repro, log a constitution exception to 2. Debugger enumerates hypotheses per the **sd-hypothesis-tree** skill (5 mental models, `(Likelihood x Impact) / Cost-to-verify` ranking). 3. Main thread appends the returned hypothesis tree to `.specs/BUG-/03-decisions.md` (debugger has no write tool). 4. Loop: - - Invoke `sd-debugger` with `TASK = verify`, `HYPOTHESIS = `. + - Invoke `sd-debugger` with `TASK = verify`, `HYPOTHESIS = `, `EVIDENCE_DIR = .specs/BUG-/04-artifacts/`. - Result: CONFIRMED / REJECTED / INCONCLUSIVE. - Main thread appends the result with evidence pointers (file:line, log lines, query results) to `03-decisions.md`. - Document REJECTED hypotheses with FULL reasoning - this is knowledge preservation for future similar bugs. @@ -232,8 +233,15 @@ Ask: - Lessons learned (focus on prevention). - Rejected hypotheses (knowledge preservation). - Constitution exceptions (should be none). -2. Set status=`done`, update index. -3. If retro reveals a systemic issue, suggest spawning a REF-* or RCA-* spec. +2. **Spawned specs**: re-read the lessons learned and rejected hypotheses just written to + 05-retro.md, plus anything the Scope discipline check pushed out of the fix - a systemic issue, + a "while I'm here" cleanup deferred to a REF-* spec, a stale constitution section. For each one, + ask the user to reserve an ID and add a row to `00-spec.md`'s `## Spawned specs` table + (`Reserved ID | Type | Title | Owner`). This is a prompt, not a gate: nothing deferred, or the + user declines - leave the table at header + separator and proceed. Do NOT create the child + specs here, and do NOT add a reserved ID to `.specs/index.md`; an index row exists only once + the real spec directory does (see `/sd:spec`, "Index <-> folder symmetry"). +3. Set status=`done`, update index. --- diff --git a/commands/explore.md b/commands/explore.md index 8b58ec5..18aef41 100644 --- a/commands/explore.md +++ b/commands/explore.md @@ -1,13 +1,22 @@ --- -description: Read-only code exploration via sd-code-explorer. Single subagent invocation. No spec created. -argument-hint: +description: Read-only code exploration via sd-code-explorer. Single subagent invocation. No spec created. `--port` runs a fixed-section donor-side extraction for a later port (SW-39). +argument-hint: | --port --scope [--snapshot contract|contract+source] --- # /sd:explore -Fast, read-only code navigation. One `sd-code-explorer` invocation, no spec is created, no code is modified. Optional save to `.specs/_explorations/`. +Fast, read-only code navigation. One `sd-code-explorer` invocation, no spec is created, no code is modified. -**Argument**: `$ARGUMENTS` -> free-form query. +Two modes, selected in Phase 1: +- **Free-form query** (default) - `$ARGUMENTS` is a natural-language query. Optional save to + `.specs/_explorations/`. +- **Port extraction** (`--port`) - a fixed-section, donor-side extraction bridging a donor + entry point into a later host-side `PORT` spec (see `sd-port-fidelity`). This mode is + MANDATORY-save (see Phase 3b) and never reads a host project - it describes the project this + session is rooted in, only. + +**Argument**: `$ARGUMENTS` -> free-form query, OR `--port --scope [--snapshot +]`. --- @@ -19,7 +28,13 @@ Fast, read-only code navigation. One `sd-code-explorer` invocation, no spec is c --- -## Phase 1 - Parse intent +## Phase 1 - Parse arguments + +1. Check whether `$ARGUMENTS` contains the `--port` flag. + - Present -> go to **Phase 1b**. Phase 1a does not run. + - Absent -> go to **Phase 1a**, the original free-form-query behavior, unchanged. + +### Phase 1a - Parse intent (free-form query) Inspect `$ARGUMENTS` to detect intent. Detect by keyword first, fall back to `pattern` if nothing matches. @@ -35,15 +50,34 @@ Inspect `$ARGUMENTS` to detect intent. Detect by keyword first, fall back to `pa Print: "Detected intent: . Routing to sd-code-explorer." +### Phase 1b - Parse port-extract arguments + +Grammar: `--port --scope [--snapshot +contract|contract+source]`. + +1. `ENTRY_POINT` = the text between `--port` and the next `--` flag (or end of string), trimmed. + STOP if empty: "`--port` requires an entry point or symbol." +2. `SCOPE` = the value following `--scope`. STOP if missing, or not one of `endpoint`, `module`, + `feature`, `pattern`: "`--scope` is required with `--port` and must be one of endpoint / + module / feature / pattern." +3. `SNAPSHOT` = the value following `--snapshot` when present, else `contract`. STOP if present + and not one of `contract`, `contract+source`. +4. Print: "Port extraction: ENTRY_POINT=, SCOPE=, SNAPSHOT=. + Routing to sd-code-explorer." + --- ## Phase 2 - Invoke `sd-code-explorer` -Invoke with: -- `TASK = standalone` -- `DETECTED_INTENT = ` -- `QUERY = $ARGUMENTS` -- `GITNEXUS_AVAILABLE = ` +### Standard exploration - Invoke `sd-code-explorer` + +Runs only when Phase 1a ran. + +1. Invoke `sd-code-explorer` with: + - `TASK = standalone` + - `DETECTED_INTENT = ` + - `QUERY = $ARGUMENTS` + - `GITNEXUS_AVAILABLE = ` Explorer routes internally based on `DETECTED_INTENT`: - `definition` -> GitNexus `context`, fallback to `Grep` for definition markers. @@ -53,11 +87,30 @@ Explorer routes internally based on `DETECTED_INTENT`: - `pattern` -> `Grep` with refined query, return file:line snippets. - `structure` -> directory listing + top-level symbols per file. -Explorer's output discipline: every finding cites `file:line`. No prose without citations. +### Port extraction - Invoke `sd-code-explorer` + +Runs only when Phase 1b ran. + +1. Invoke `sd-code-explorer` with: + - `TASK = port-extract` + - `ENTRY_POINT = ` + - `SCOPE = ` + - `GITNEXUS_AVAILABLE = ` + +Explorer fills all eight fixed sections (Entry surface, Output surface, Member closure, +Complement set, Collaborators, Non-obvious invariants, Dead paths on this entry point, Precedent +conventions), each `file:line`-cited or explicitly `None found (searched: ...)`. `SNAPSHOT` is +never passed to the agent - it only controls what the command itself does in Phase 3b. + +Explorer's output discipline (both branches): every finding cites `file:line`. No prose without citations. --- -## Phase 3 - Present output +## Phase 3 - Present and save output + +### Phase 3a - Present output (free-form query) + +Runs only when Phase 1a ran. 1. Display the explorer's findings inline. 2. Group by source file when more than 5 hits in one file. @@ -86,13 +139,98 @@ Explorer's output discipline: every finding cites `file:line`. No prose without The `.specs/_explorations/` folder is NOT tracked in `.specs/index.md` - it's a scratchpad, not a workflow spec. +### Phase 3b - Save port-extract output (mandatory) + +Runs only when Phase 1b ran. Unlike Phase 3a, this save is NOT optional and is NOT prompted - the +whole point of `--port` is a durable, donor-side artifact for a later host-side `PORT` spec. + +1. Slugify `ENTRY_POINT` for a directory name: lowercase, alphanumerics + hyphens, max 40 chars. +2. Directory: `.specs/_explorations/-/` - a DIRECTORY, not a flat file. This + is the one place `--port`'s save differs structurally from Phase 3a's, because a + `contract+source` snapshot needs a subtree. +3. Compute `source_commit` in this project's own working tree (the donor - this command never + touches a second project): + - Run `git rev-parse HEAD`. + - Run `git status --porcelain`. + - Non-empty -> the tree is dirty. Do NOT record a sha as if the tree were clean. Write: + `source_commit: dirty (uncommitted changes present as of )`. + - Empty -> `source_commit: `. +4. Write `/contract.md`: + ``` + --- + type: port-extraction + entry_point: "" + scope: + snapshot: + source_commit: + created: + --- + + # Port extraction: + + + ``` +5. Only when `SNAPSHOT = contract+source`: + 1. Create `/source/`. + 2. For every distinct `Donor path` in the explorer's Member closure and Complement set tables, + copy that donor file into `/source/` (create parent directories as + needed; copy each path once even if several rows share it). + 3. Compute `sha256` (lowercase hex) and byte count for each copied file, over the bytes as + captured. + 4. Resolve the donor identity for the manifest header: `project.repo` from + `.claude/project-config.json` (Phase 0); if absent, `git remote get-url origin`; if that + also fails, the repo's local path. + 5. Write `/source/MANIFEST.md`, in the exact format `sd-port-fidelity`'s "Snapshot + artifacts" section defines: + ``` + - **Donor**: + - **Commit**: + - **Captured**: + - **Mode**: contract+source + - **Hash algorithm**: sha256, lowercase hex, over the bytes as captured + + | Snapshot path | Donor path | Commit | Bytes | SHA-256 | Member ranges | + |---|---|---|---|---|---| + | `` | `` | `` | `` | `` | `` | + ``` + One row per copied file. `Member ranges` groups the Member closure table's rows for that + `Donor path`, semicolon-separated, ordinals contiguous from 1 - the input the host's Member + manifest table will be built from. +6. Print the saved path(s): `/contract.md`, and `/source/` + its `MANIFEST.md` when + step 5 ran. +7. Tell the user: "This bundle is intended to be copied verbatim into the host spec's + `04-artifacts/source/` directory once that `PORT` spec exists. Copying between the two repos + is a manual or scripted step outside this command." + --- ## Rules (hard constraints) -- Read-only. Code-explorer's tool allowlist does not include `Write`, `Edit`, `MultiEdit`, or `Bash` write modes. The command MUST NOT escalate. -- No spec lifecycle is started. No `00-spec.md` is created. -- Every finding cites `file:line`. If a finding has no citation, the explorer is misbehaving and should be re-prompted. -- If GitNexus is disabled or unavailable, fall back to grep / read. Tell the user "GitNexus disabled, using grep fallback - results may be less precise on transitive callers". -- If the user follows up an exploration with "now fix X" or "now refactor X", REDIRECT to the appropriate workflow command (`/sd:bug`, `/sd:refactor`, etc.). Do not implement inline. -- Save destination is always `.specs/_explorations/`. If `.specs/` does not exist, create the underscore folder ad-hoc; this does not require `/sd:setup`. +- Read-only invocation. Code-explorer's tool allowlist does not include `Write`, `Edit`, + `MultiEdit`, or `Bash` write modes, in either branch. The command MUST NOT escalate the + AGENT's allowlist - the agent always returns text only. +- `--port` is the only other supported flag today (the same style of extension `--calibration` + established for `/sd:status`). `--scope` is required alongside `--port`; `--snapshot` is + optional, defaulting to `contract`. +- Phase 3b's `git rev-parse` / `git status --porcelain` / file copy / hash steps are run by the + COMMAND, never by `sd-code-explorer`. This is not a new escalation: the command has always had + `Write` (Phase 3a already writes an exploration file today) - it is the same main-thread + read/write surface, just exercised for a second purpose, and it never runs inside the agent + invocation. +- No spec lifecycle is started, in either branch. No `00-spec.md` is created. A `--port` + extraction is a scratchpad artifact, not itself a `PORT` spec - authoring the spec is a + separate, later step outside this command. +- Every finding cites `file:line`. If a finding has no citation, the explorer is misbehaving and + should be re-prompted. +- If GitNexus is disabled or unavailable, fall back to grep / read - in both branches. Tell the + user "GitNexus disabled, using grep fallback - results may be less precise on transitive + callers, dynamic dispatch, and DI-resolved collaborators." +- If the user follows up an exploration with "now fix X" or "now refactor X", REDIRECT to the + appropriate workflow command (`/sd:bug`, `/sd:refactor`, etc.). Do not implement inline. +- Save destination is always `.specs/_explorations/`. Phase 3a's save is a flat file, optional, + prompted (default no). Phase 3b's save (`--port`) is a directory, mandatory, never prompted. + Neither is tracked in `.specs/index.md`. If `.specs/` does not exist, create the underscore + folder ad-hoc; this does not require `/sd:setup`. +- `--port` never reads, infers, or names a host project. This command runs rooted at one project + only - the Layer 2 boundary (a session loads at most one project's `CLAUDE.md` and + constitution) holds in both branches. diff --git a/commands/feature.md b/commands/feature.md index 7ab1706..d6c3b5f 100644 --- a/commands/feature.md +++ b/commands/feature.md @@ -206,6 +206,7 @@ with `/sd:refactor`. In brief: > Re-plan FEAT-? Discovery: . Affects . (approve / revise / abort task) - `approve` -> proceed. `revise` -> adjust the delta and re-ask. `abort task` -> normal task abort. + 2. Invoke `sd-spec-architect` with `TASK = plan`, `REPLAN_SCOPE = `, `REVISION = R` (next contiguous number). It appends the `## Revisions` entry to `01-plan.md` (append-only; the original plan prose is never edited), regenerates ONLY the affected task blocks @@ -283,9 +284,17 @@ Treat findings: - Deferred follow-ups (with reserved spec IDs, if any). - Constitution exceptions taken (should be none). - Cost rough estimate if available. -4. Set frontmatter status=`done` in `00-spec.md`. -5. Update `.specs/index.md`: state -> `done`, completion date. -6. Print a 5-line summary to the user. +4. **Spawned specs**: re-read the "Deferred follow-ups" and "Surprises encountered" entries just + written to 05-retro.md and look for follow-up work that has no home: a defect left in place on + purpose, a rule or doc found stale, a "separate spec" hand-off, a hotspot not taken. For each + one, ask the user to reserve an ID and add a row to `00-spec.md`'s `## Spawned specs` table + (`Reserved ID | Type | Title | Owner`). This is a prompt, not a gate: nothing deferred, or the + user declines - leave the table at header + separator and proceed. Do NOT create the child + specs here, and do NOT add a reserved ID to `.specs/index.md`; an index row exists only once + the real spec directory does (see `/sd:spec`, "Index <-> folder symmetry"). +5. Set frontmatter status=`done` in `00-spec.md`. +6. Update `.specs/index.md`: state -> `done`, completion date. +7. Print a 5-line summary to the user. --- diff --git a/commands/perf.md b/commands/perf.md index 9a56a77..87241f3 100644 --- a/commands/perf.md +++ b/commands/perf.md @@ -86,9 +86,11 @@ STOP. Display Target + Methodology. Ask: STOP. Two cases: **Case A: baseline already meets SLA goal.** + > Baseline p95= already meets SLA goal p95<. No optimization needed. Close PERF- as 'done' with no changes? (yes / proceed anyway / abort) - `yes` -> set status=`in-progress` (no hotspot work occurs, but the state machine has no approved -> done shortcut), then jump to Phase 6 close-out with summary "no work needed". + - `proceed anyway` -> requires explicit constitution exception ("optimizing past SLA"). Log to retro. **Case B: baseline below SLA goal.** @@ -257,8 +259,17 @@ STOP. Display reviewer verdict. Ask: - Reverted attempts (with reasoning). - Hotspots deferred (if any). - Constitution exceptions (should be none). -3. Set status=`done`. Update index. -4. Print 5-line summary: target, baseline, final, kept attempts, deferred hotspots. +3. **Spawned specs**: re-read the "Hotspots deferred" and "Reverted attempts" entries just written + to 05-retro.md, plus the trade-offs accepted in `00-spec.md`, and look for follow-up work that + has no home: a hotspot not taken, a reverted approach worth retrying under different + constraints, a trade-off that should be revisited. For each one, ask the user to reserve an ID + and add a row to `00-spec.md`'s `## Spawned specs` table (`Reserved ID | Type | Title | Owner`). + This is a prompt, not a gate: nothing deferred, or the user declines - leave the table at + header + separator and proceed. Do NOT create the child specs here, and do NOT add a reserved + ID to `.specs/index.md`; an index row exists only once the real spec directory does (see + `/sd:spec`, "Index <-> folder symmetry"). +4. Set status=`done`. Update index. +5. Print 5-line summary: target, baseline, final, kept attempts, deferred hotspots. --- diff --git a/commands/port.md b/commands/port.md new file mode 100644 index 0000000..8262974 --- /dev/null +++ b/commands/port.md @@ -0,0 +1,466 @@ +--- +description: Fidelity-first port workflow. Bridge -> freeze -> survey -> tables -> pin -> plan -> execute batched -> parity -> close. 6 hard gates. +argument-hint: --from --scope [--snapshot contract|contract+source] +--- + +# /sd:port + +Drives a fidelity-first port: reproduces a donor implementation inside this host repo, with every +departure from the donor form recorded as a citable deviation rather than a preference. Result +under `.specs/PORT--/`. Reads `sd-port-fidelity` for the tables, artifacts, and +hunk vocabulary this file wires together - it does not restate them. + +**Arguments**: ` --from --scope + [--snapshot contract|contract+source]`. Spec ID = +`PORT--`. + +--- + +## HARD RULES (read before every phase) + +1. **The donor is the specification.** Every departure is a row in the deviation table, or it is a + defect (sd-port-fidelity core rule). +2. **The snapshot is evidence, never a copy source, and never edited.** It is quarantined the + moment it is frozen. +3. **`--scope` is explicit.** Phase 0 asks when it is omitted. Nothing infers it - a wrong + inference changes the parity mechanism. +4. **Port policy is Layer 2.** This file never states a policy - it reads the host's + `.specs/constitution.md` and reports what it found, or the documented default. +5. **No opportunistic fix.** A donor defect is reproduced as-is and recorded under `## Spawned + specs` - never fixed inline unless a group-4 deviation was agreed at Gate 2 before + implementation began. + +--- + +## State machine (resume behavior) + +| Condition | State | Action | +|---|---|---| +| No `.specs/PORT--/` dir | `not-found` | Start Phase 1 | +| `00-spec.md` exists, no `04-artifacts/source/MANIFEST.md` | `spec-drafted` | Resume Phase 2 | +| `MANIFEST.md` present, `Frozen: yes`, tables still carry `<<...>>` tokens | `frozen` | Resume Phase 3 | +| Tables filled, status `draft` | `tables-drafted` | Present Gate 2 | +| status `approved`, no `## Behavior pinning` in `03-decisions.md` | `approved` | Resume Phase 5 | +| Pinned, no `02-tasks.md` | `pinned` | Resume Phase 6 | +| `02-tasks.md` has unchecked tasks | `in-progress` | Resume Phase 7 at next unchecked task | +| All tasks checked, no `04-artifacts/parity/INDEX.md` | `tasks-complete` | Resume Phase 8 | +| Parity clean, status not `done` | `parity-clean` | Resume Phase 9 | +| status `done` | `done` | Print summary, exit | +| status `archived` | `archived` | Print archived notice, exit | + +--- + +## Phase 0 - Bootstrap (always runs) + +1. Read `CLAUDE.md`. If missing, WARN and continue - print "No `CLAUDE.md` found; stack + conventions may be incomplete." +2. Read `.specs/constitution.md`. If `.specs/` or this file is missing, STOP: "No `.specs/` found - + run `/sd:setup` first." +3. Read `.claude/project-config.json`. If missing, STOP with the same message. If present but + fails to parse as JSON, STOP: "`.claude/project-config.json` failed to parse - fix it or re-run + `/sd:setup`." +4. Read `.specs/index.md`. If missing, STOP with the "run `/sd:setup` first" message. +5. **Parse arguments**: ``; `--from `; `--scope `; + `--snapshot ` (default `contract`). + - `--scope` absent or not one of the four values -> ask the user to pick one. Never infer it. + - `--from` absent -> STOP: "`--from` is required: a bridged contract artifact (cross-repo) or an + in-repo path/symbol (intra-repo)." + - `--snapshot` present and not one of the two values -> STOP naming the two legal forms. +6. **Select topology from `--from`** - the rest of the pipeline is identical either way: + - Resolves to a directory or file containing a `contract.md` with frontmatter `type: + port-extraction` -> topology = `bridged`. + - Resolves to a path or symbol inside this working tree -> topology = `in-repo`. + - Neither -> STOP naming both accepted forms. +7. Compute the UTC date and the spec ID `PORT--`. +8. **Read the port policy.** Scan `.specs/constitution.md` for the first heading (any level) whose + text contains "Port policy" (case-insensitive). Effective policy = that section's body. +9. **State the effective policy in output, always** - including the fallback: + ``` + Effective port policy: "
" + -- or, when no such section exists -- + Effective port policy: default - structural mirror (sd-port-fidelity core rule); no "Port + policy" section found in .specs/constitution.md + Scope: Topology: Snapshot mode: + ``` +10. Detect state from the table above. Print the resume plan. + +--- + +## Phase 1 - Consume the bridged contract, or extract in-repo + +### Branch A - topology `bridged` + +1. Read the bundle at `--from`. Validate all eight fixed sections of `sd-code-explorer`'s + port-extract output (Entry surface, Output surface, Member closure, Complement set, + Collaborators, Non-obvious invariants, Dead paths on this entry point, Precedent conventions). A + section that is absent, or neither filled nor an explicit `None found (searched: ...)`, is + incomplete - STOP naming it and telling the user to re-run `/sd:explore --port --scope + ` in the donor repo. +2. `Member closure` reported as `None found` is a failure of the extraction itself (the entry point + could not be resolved), never a legitimately empty closure - STOP if you see it. +3. A `source_commit` reading `dirty (...)` is a WARN, not a STOP - record it as an Open question in + the spec once created. +4. `--snapshot contract+source` with no `source/` subtree in the bundle -> STOP. + +### Branch B - topology `in-repo` + +1. Invoke `sd-code-explorer` with: + - `TASK = port-extract` + - `ENTRY_POINT = ` + - `SCOPE = ` + - `GITNEXUS_AVAILABLE = ` +2. Apply the same eight-section validation as Branch A to the returned output. + +### Both branches - create the spec + +3. If `ticket.system == "jira"` and `` matches `ticket.pattern`, fetch the ticket via + `mcp__atlassian__getJiraIssue`. If MCP unavailable, ask the user for a paste or proceed without + one. +4. Invoke `sd-spec-architect` with: + - `TASK = create` + - `TEMPLATE = port.template.md` + - `SPEC_ID = PORT--` + - `SOURCE_REPO = ` + - `SOURCE_COMMIT = ` + - `DONOR_SCOPE = ` + - `TICKET_CONTEXT = ` +5. Architect fills Why / Donor provenance / Behavioral contract (7 fixed rows) / Behavioral + invariants (from the extraction's Non-obvious invariants section), leaves the three fidelity + tables' `<<...>>` rows for Phase 4, leaves `AC-1` verbatim. Register in `.specs/index.md` at + status `draft`. + +No gate here - Gate 1 covers the freeze that follows in Phase 2, not the raw extraction. + +--- + +## Phase 2 - Freeze the snapshot + +Main-thread file operations only - this command's `Write` tool is not escalated to any subagent. + +1. Create `.specs/PORT--/04-artifacts/source/`. Copy the bridged bundle verbatim: + `contract.md` always; the `source/` tree too under `snapshot: contract+source`. For an `in-repo` + topology, copy each distinct `Donor path` named in Member closure and Complement set once. +2. Per copied file: sha256 (lowercase hex) and byte count, over the bytes as captured. +3. Write `04-artifacts/source/MANIFEST.md` in the exact format `sd-port-fidelity`'s "Snapshot + manifest format" defines - header fields plus the six-column table, `Member ranges` as + `: -`, semicolon-separated, contiguous from 1. +4. Append every file under `source/`, plus `MANIFEST.md` itself, as an **individual literal + string** to `paths.protected` in `.claude/project-config.json`. Exact-string match, never a + glob - the enumeration is the mechanism. Idempotent: skip entries already present. +5. Fill the spec's `Frozen:` line: `yes - path(s) appended to paths.protected on `. +6. **Snapshot-visibility warning, never an edit.** If the host's build, lint, or coverage + configuration has no exclusion covering `.specs/`, print a WARN naming each config file found + and the exclusion it needs. This command warns; it never edits that configuration. + +### ⛔ Gate 1 - Donor set frozen (HARD) + +STOP. Display the snapshot root, the file count, the `MANIFEST.md` row count, how many entries +were appended to `paths.protected`, and the sha256 of `MANIFEST.md` itself. Ask: + +> Donor set frozen for PORT-: file(s), member range(s). Proceed on this frozen set? (yes / re-capture / abort) + +- `yes` -> proceed to Phase 3. +- `re-capture ` -> delete `04-artifacts/source/`, remove exactly the `paths.protected` + entries this phase appended, return to Phase 1. +- `abort` -> stop the workflow. Leave the spec at status `draft`; re-invoking resumes here. + +This gate is HARD. Nothing downstream reads a donor file outside the frozen set, and no later +phase re-captures silently - re-capture is a decision made here, deliberately, or not at all. + +--- + +## Phase 3 - Host survey + +1. Invoke `sd-code-explorer` with: + - `TASK = impact-map` + - `SPEC = .specs/PORT--/00-spec.md` + - `OUTPUT_TARGET = .specs/PORT--/03-decisions.md` +2. Its "Precedents & conventions" section is the host precedent sampling this phase needs. Main + thread appends the returned analysis to `03-decisions.md` (create the file if missing; never + overwrite existing content). +3. **Systematic host-constitution scan** (main thread, pattern-based - not a read-through). For + each numbered constitution rule that states a mechanically checkable pattern (naming, layer + direction, a forbidden construct, error handling, async style), derive a grep pattern from the + rule text and run it over `04-artifacts/source/`. Append to `03-decisions.md`: + + ```markdown + ## Host-constitution scan + + | Rule | Pattern used | Snapshot path:line | Donor form | Proposed host form | + |---|---|---|---|---| + ``` + + Every applicable rule appears with its result, including `no hits` - the scan's coverage must be + auditable. A rule with no mechanically checkable pattern is listed as `not mechanically checkable + - manual review at Gate 2`. Each hit row pre-populates a group-2 deviation row for Phase 4, + citing the `§N.M` the rule carries. + +No gate here - the survey is informational. The user reviews it via the tables at Gate 2. + +--- + +## Phase 4 - Author the three fidelity tables + +1. Invoke `sd-spec-architect` with: + - `TASK = refine` + - `SPEC = .specs/PORT--/00-spec.md` + - `FEEDBACK = ` + + The feedback text: fill the Path mapping table, Member manifest, and Deviation table per + `sd-port-fidelity`'s completeness conditions, sourced from `04-artifacts/source/MANIFEST.md` and + the Host-constitution scan rows in `03-decisions.md`. Cite snapshot paths, never donor-repo + paths. Every scan row becomes a group-2 deviation row or is explained under Open questions. Fill + `AC-2` onward; leave `AC-1` verbatim. +2. **Main thread counts the three completeness conditions mechanically** - it counts and matches, + it never judges a reason (sd-port-fidelity's own discipline). Compute `unmapped` = (manifest + members with no Member-manifest row) + (Member-manifest rows whose `Host path` has no path- + mapping row) + (non-`ported` rows naming a `Deviation ID` absent from the deviation table). +3. Run `/sd:spec validate PORT--`. The `SL080`-`SL083` rules must report zero + BLOCK. + +### ⛔ Gate 2 - Fidelity tables complete, zero unmapped (HARD) + +STOP. Display: path-mapping row count, `/` members mapped, deviation rows by group, the +unmapped count, and the `/sd:spec validate` verdict. Ask: + +> PORT-:

path rows, / members mapped, deviation rows, unmapped. Approve the fidelity tables? (yes / refine / abort) + +- `yes` -> permitted only when `` is 0, every author-fill `<<...>>` token is gone, and + `/sd:spec validate` reports zero BLOCK. Set status=`approved` in `00-spec.md` and + `.specs/index.md`; proceed to Phase 5. +- `refine ` -> loop through step 1. +- `abort` -> stop the workflow. Leave the spec at status `draft`. + +This gate is HARD. An incomplete table is refused by count, without a discussion of intent. + +--- + +## Phase 5 - Pin behavior before host code exists + +**Mechanism by scope** - record the choice and its reason under a new `## Behavior pinning` +section in `03-decisions.md`: + +- `endpoint` -> a **contract test suite** runnable against both donor and host. Assertions + reference only the Entry surface and Output surface rows of the Behavioral contract table; the + target address/transport is supplied by a single parameter. +- `module` -> **characterization tests** over an interface-typed construction seam. The subject is + constructed through exactly one factory method typed to the interface, so re-pointing + donor -> host changes that one method and nothing else; assertion bodies stay **byte-identical**, + proven by diffing the two test files and showing the diff touches only the factory method. +- `feature` -> whichever of the above the surface allows. State the choice and why; prefer the + contract suite when both are possible. +- `pattern` -> **skipped.** Record `Phase 5 skipped - scope is pattern; there is no donor instance + to pin against.` The gate below still runs and records the skip; the empty-diff proof still runs. + +1. Write the pinning tests under `paths.tests`. +2. Invoke `sd-implementer` with: + - `TASK_DETAILS = ` + - `SPEC_REF = .specs/PORT--/00-spec.md` + - `IMPACT_REF = .specs/PORT--/03-decisions.md` + - `WORKFLOW_TYPE = port` +3. Run the pinned tests via `commands.test`. +4. **Prove the host production tree is unmodified.** Run `git diff --quiet --` over every path in + `paths.src` and every `paths.layers[].path`, then `git status --porcelain --` over the same set + to catch untracked additions. Both must return empty. + +### ⛔ Gate 3 - Behavior pinned and green (HARD) + +STOP. Display the mechanism chosen, the pinned test files, the `commands.test` result, and the +empty-diff proof - the exact checks run and their output. Ask: + +> PORT- pinned via : tests green, host production tree unmodified. Proceed to planning? (yes / re-pin / abort) + +- `yes` -> permitted only when the pinned tests are green AND both tree checks returned empty. +- If either tree check returns output, this gate refuses and lists the dirty paths - a dirty + production tree makes the Phase 8 parity diff unattributable. Commit or revert those changes + under their own spec first, then re-run the check. +- `re-pin ` -> return to step 1. +- `abort` -> stop the workflow. Leave the spec at status `approved`. + +This gate is HARD, including for `scope = pattern` - it presents the recorded skip reason and asks +the same question; the empty-diff proof is never skipped. + +--- + +## Phase 6 - Plan atomic tasks + +1. **Port-mode complexity triage**, computed by the main thread - `sd-spec-architect`'s + self-assessment is feature-only, so do not expect `STATUS = needs-input` from it here. + - **Metric: the number of deviation-table rows requiring adaptation** - every row in the + Deviation table, counted once. Threshold: **> 8**, mirroring the feature-plan task threshold's + shape. + - **Rationale**, recorded verbatim in `01-plan.md` under `## Complexity triage (port mode)`: the + existing decompose thresholds count impacted files and distinct `Layer` values. In a port the + impacted-file count equals the donor's file count by construction, and the layer spread is + inherited from the donor, so both trip on nearly every port regardless of how much judgment the + work needs. The quantity that actually scales with judgment is the number of departures from + the donor form - a 40-file mechanical mirror with two deviations is easier than a 3-file port + with fifteen. Record metric name, value, threshold, and verdict. + - Over threshold: present a split at Gate 4, partitioned along disjoint `Host path` rows (each + child owns a set of path-mapping rows plus the deviation IDs those rows reference), or a + no-split flag. Under threshold: normal plan, zero added friction. +2. Invoke `sd-spec-architect` with: + - `TASK = plan` + - `SPEC = .specs/PORT--/00-spec.md` + - `IMPACT = .specs/PORT--/03-decisions.md` +3. **Port task-block requirements.** Every block carries the 11 required fields of + **sd-atomic-task-format**; three are constrained in port mode: + - **`Pattern refs` points at the snapshot member range**, never prose and never a host sibling. + Format: `` `04-artifacts/source/:-` - member + ``; reproduce structurally. `` Resolve the range through `MANIFEST.md`'s `Member + ranges` by `(Snapshot path, Ordinal)` - the Member manifest table carries no line-range column + of its own. + - **`Acceptance` carries the licensed-deviation list**, as nested bullets: + ``` + - Licensed deviations: D03, D07 (or: none) + - Anything not on that list is reproduced as-is from the cited member range. + ``` + - **`Covers`** cites `AC-1` plus any `AC-` the task proves. + - A task block missing the snapshot-range `Pattern refs` or the licensed-deviation list is a + **planning defect**. Do not execute it - return to step 2 naming the defective task IDs. +4. **Batching unit** (no new task field): a batch is the set of tasks whose `Files` name the same + `Host path` from the path-mapping table, executed in `Ordinal` order; batches run in + path-mapping row order. + +### ⛔ Gate 4 - Plan approval + +STOP. Display the task count, the port decompose metric (`` deviation rows requiring +adaptation, threshold 8) with its verdict, and a per-task line showing the `Pattern refs` snapshot +range and the licensed deviation IDs. Ask: + +> Approve plan for PORT-? ( tasks, deviation rows requiring adaptation) (yes / refine / abort) + +- `yes` -> set status=`in-progress` in `00-spec.md` and `.specs/index.md`; proceed to Phase 7. +- `refine ` -> invoke `sd-spec-architect` with `TASK = refine`, `SPEC = .specs/PORT--/00-spec.md`, `FEEDBACK = `. Loop. +- `abort` -> stop the workflow. Leave the spec at status `approved`. + +This is the standard plan approval - the same shape as `/sd:feature` Gate 2 Face A. It is not one +of the four no-override gates. + +--- + +## Phase 7 - Execute batched + +Process batches from `02-tasks.md` in path-mapping row order. For each unchecked task in the +current batch: + +1. **Pre-flight**: re-read `00-spec.md`, the task block, the cited snapshot member range under + `04-artifacts/source/`, and the deviation rows the task licenses. +2. Invoke `sd-implementer` with: + - `TASK_DETAILS = ` + - `SPEC_REF = .specs/PORT--/00-spec.md` + - `IMPACT_REF = .specs/PORT--/03-decisions.md` + - `WORKFLOW_TYPE = port` +3. Run the task's test via `commands.test`. +4. **Self-check** (main thread, no per-task reviewer - same cost argument as `/sd:feature` Phase + 4): only files in `Files` were touched; the diff touches only what the cited member range + accounts for, or a licensed deviation row; the test passes. +5. Check off the task in `02-tasks.md`. Log one line to `05-retro.md`: `T: - `. + +### ⛔ Gate 5 - Batch tests green + +STOP after every batch. Display the `commands.test` result and the Phase 5 pinned-suite result. + +> Batch of PORT-: tests , pinned suite . (continue / revert batch / abort) + +- `continue` -> permitted only when both are green. Check off the batch, start the next one. +- `revert batch` -> revert the batch, re-plan the offending task, retry. +- `abort` -> stop the workflow. Leave the spec at status `in-progress`. + +A red batch is reverted or fixed, never deferred - batched-with-tests-between exists to localize +the failure. + +Move to the next batch. Repeat until every batch is checked off. + +--- + +## Phase 8 - Justified-diff parity review + +1. **Main thread generates `04-artifacts/parity/`** per `sd-port-fidelity`'s "Parity artifacts" + section: one unified diff per non-`omit` path-mapping row (snapshot side first, host side + second, at least 3 lines of context), file name = `Host path` with `/` replaced by `__`, suffix + `.diff`; an all-deletion diff for a row whose host file is absent; an all-addition diff for + every changeset file with no mapping row; `INDEX.md` at the parity root, columns `Diff file | + Snapshot path | Host path | Mapping row`. Regenerated and overwritten every run - nothing under + `parity/` is frozen or appended to `paths.protected`. A row whose diff could not be produced + says so in its `Mapping row` cell rather than being dropped. +2. Run `commands.test` and `commands.lint`. +3. Invoke `sd-reviewer` with: + - `TASK_TYPE = port-parity` + - `SPEC_REF = .specs/PORT--/00-spec.md` + - `DIFF_REF = .specs/PORT--/04-artifacts/parity/INDEX.md` + - `CHANGED_FILES = ` + - `SNAPSHOT_REF = .specs/PORT--/04-artifacts/source/` + +### ⛔ Gate 6 - Justified-diff parity (HARD) + +STOP. Display the `justified` count as a single number, then each `unjustified` / `missing` / +`extra` / `overreached` finding, member completeness as `/`, the path-conformance BLOCK +count, and the test + lint summary. + +**Case A - zero BLOCK.** Ask: + +> PORT- parity clean: justified hunks, members /, no unmapped file. Proceed to close-out? (yes / re-run parity / abort) + +**Case B - one or more BLOCK.** The gate refuses close-out. Exactly two resolutions exist. Ask: + +> PORT- parity: BLOCK finding(s). Choose a resolution: (revert-to-snapshot / add-deviation / re-run parity) + +- `revert-to-snapshot ` -> route the named hunks to `sd-implementer` to restore the + snapshot form, regenerate `04-artifacts/parity/`, re-invoke the reviewer. +- `add-deviation ` -> route to `sd-spec-architect` (`TASK = refine`) to add rows whose group + and citation hold up, regenerate, re-invoke. +- `re-run parity` -> regenerate and re-invoke with no spec or code change. + +This gate is HARD and has no override. Deriving the missing rows from the unexplained hunks is not +a resolution - it makes the diff justify itself. + +--- + +## Phase 9 - Close-out + +1. Tick each `AC-` checkbox only against evidence logged to `05-retro.md`. `AC-1`'s evidence is + the clean parity report - cite `04-artifacts/parity/INDEX.md` and the reviewer verdict. Never + tick a box just to make a verification check pass. +2. Run `/sd:verify PORT--`. It must report `result: pass`. +3. Write to `05-retro.md` under `## PR description block`: the deviation table copied verbatim + (each row is already PR-ready), the Donor + Commit lines from `MANIFEST.md`, the license + obligation from Donor provenance, and `/` members ported. +4. **Spawned specs**: ensure one row in `00-spec.md`'s `## Spawned specs` per donor defect + reproduced deliberately (a group-4 deviation is not one - it was already agreed and applied). + Reserve the IDs and print the follow-up commands (e.g. `/sd:bug `) for the user to run + separately; do not create the children from this workflow. +5. **Divergence record**, appended to `05-retro.md` under `## Divergence record`: every deviation + ID with its group, citation, and the hunks it covered; every donor defect reproduced as-is with + its reserved follow-up ID; the snapshot commit. +6. Set status=`done` in `00-spec.md` and `.specs/index.md`. +7. Print a 5-line summary: donor, scope, members ported, deviations applied, spawned follow-ups. + +--- + +## Rules (hard constraints) + +- Phase 0 always runs, even on resume. +- Gates 1, 2, 3, and 6 are HARD - no override path. Gates 4 and 5 are ordinary approvals; the + workflow still declares 6 total gates. +- `--scope` is never inferred. `--from`'s form (bridged artifact vs in-repo path) selects topology + only - every phase after Phase 1 is identical regardless of which one was used. +- Port policy is Layer 2. This file never states one; it reads and reports the host's, or the + documented default (structural mirror). +- The snapshot is immutable evidence once frozen. Re-freezing happens only through Gate 1's + `re-capture` resolution. +- **Lifecycle**: this workflow drives `draft -> approved -> in-progress -> done` and takes no other + edge. `abort` at any gate leaves the spec at its current state - it never jumps to `archived`, + which `/sd:spec status` reaches only from `done`. This is a deliberate divergence from + `/sd:feature` and `/sd:refactor`, which use a `draft -> archived` shortcut on abort; a port spec + aborted mid-flight resumes exactly where it left off instead. +- No hardcoded stack command anywhere: tests, build, lint, and coverage come from `commands.test` / + `commands.build` / `commands.lint` / `commands.coverage`; paths from `paths.src` / `paths.tests` / + `paths.layers` / `paths.protected`. +- Model references are aliases only (`sonnet`, `haiku`, `opus`, `inherit`) - never a full model ID. +- Snapshot visibility: this command warns about host tooling globbing `.specs/` and never edits the + host's build, lint, or coverage configuration. +- Implementer touches only files declared in the task's `Files` list. Any scope creep -> stop, + surface to main thread, log to retro. +- Out of scope: `--sync` / re-port drift detection, multi-donor ports. +- All files under `.specs/PORT--/` are written in UTF-8 with no BOM. diff --git a/commands/refactor.md b/commands/refactor.md index 6a1415f..efb9271 100644 --- a/commands/refactor.md +++ b/commands/refactor.md @@ -109,7 +109,7 @@ STOP. Compare measured vs threshold. If user picks (1), enter the characterization sub-loop: 1. Identify uncovered branches via coverage report. -2. Invoke `sd-implementer` with `TASK_DETAILS = `, `SPEC_REF = .specs/REF--/00-spec.md`, `WORKFLOW_TYPE = refactor` per uncovered area. +2. Invoke `sd-implementer` with `TASK_DETAILS = `, `SPEC_REF = .specs/REF--/00-spec.md`, `WORKFLOW_TYPE = refactor`, `INVARIANTS = ` per uncovered area. 3. Each new test must FAIL FAST if current behavior changes - characterization tests pin the CURRENT behavior, correct or not. 4. Re-measure coverage. @@ -178,6 +178,7 @@ as planned) **and** Phase 6 (a holistic-review finding that the plan itself is w > Re-plan REF-? Discovery: . Affects . (approve / revise / abort task) + 2. `approve` -> invoke `sd-spec-architect` with `TASK = plan`, `MODE = refactor`, `REPLAN_SCOPE = `, `REVISION = R`. It appends the `## Revisions` entry to `01-plan.md` (append-only; original plan prose untouched), regenerates ONLY the affected task @@ -228,8 +229,14 @@ STOP. Display reviewer verdict counts + invariant verification table. Ask: - Invariants verified (table). - Surprises (e.g. discovered dead code, unexpected callers). - Constitution exceptions (should be none). -2. Set status=`done`. Update index. -3. If retro reveals follow-ups (e.g. "this refactor exposes a perf concern"), suggest spawning the relevant spec. +2. **Spawned specs**: re-read the "Surprises" entry just written to 05-retro.md - dead code found, + unexpected callers, invariants that needed propping up, a perf concern this refactor exposed. + For each one, ask the user to reserve an ID and add a row to `00-spec.md`'s `## Spawned specs` + table (`Reserved ID | Type | Title | Owner`). This is a prompt, not a gate: nothing deferred, or + the user declines - leave the table at header + separator and proceed. Do NOT create the child + specs here, and do NOT add a reserved ID to `.specs/index.md`; an index row exists only once + the real spec directory does (see `/sd:spec`, "Index <-> folder symmetry"). +3. Set status=`done`. Update index. --- diff --git a/commands/release.md b/commands/release.md index f2edad3..aa92617 100644 --- a/commands/release.md +++ b/commands/release.md @@ -44,7 +44,7 @@ This command is **not** multi-phase-resumable like the workflows - it is a singl ## Phase 1 - Collect releasable specs 1. Parse `.specs/index.md` rows (`ID | Type | Status | Created | Title`). -2. Select rows where `Status == done` AND `Type` is one of `feature`, `bug`, `refactor`, `perf`. +2. Select rows where `Status == done` AND `Type` is one of `feature`, `bug`, `refactor`, `perf`, `port`. - **RCA specs are never released or archived by this command.** An RCA is a deliverable, not a shippable code change; its fixes ship as their spawned BUG / REF / PERF specs. 3. For each selected spec, read `.specs//00-spec.md`: - Cross-check frontmatter `status == done`. If it disagrees with the index, WARN and skip that spec (index/spec drift - tell the user to run `/sd:spec validate `). @@ -64,6 +64,10 @@ Map each spec type to a section: | bug | BUG | Fixed | | refactor | REF | Changed | | perf | PERF | Changed | +| port | PORT | Added | + +A port lands new host capability from the consumer's viewpoint, the same category as a feature - +donor provenance belongs in the spec, not the changelog line. Within a section, order entries oldest-completed first. Render one bullet per spec: @@ -87,7 +91,7 @@ If `[version]` was supplied, validate it is SemVer (`MAJOR.MINOR.PATCH`) and use Otherwise infer from the latest released version in `CHANGELOG.md` (the first `## [X.Y.Z]` heading; default base `0.0.0` if none): -- Any feature (FEAT) in this release -> **MINOR** bump (`X.(Y+1).0`). +- Any feature (FEAT) or port (PORT) in this release -> **MINOR** bump (`X.(Y+1).0`). - Only bug / refactor / perf -> **PATCH** bump (`X.Y.(Z+1)`). - **MAJOR is never auto-inferred** - breaking changes are not derivable from spec type. Pass an explicit `[version]` to cut a major. @@ -107,6 +111,7 @@ Display, for explicit approval: **STOP. Wait for explicit user approval.** Silence is not approval. - If `--dry-run` was passed: stop here unconditionally. Report that nothing was written. + - The user may override the version at this gate. - On rejection: write nothing; specs stay in `done`. @@ -175,7 +180,7 @@ Print: - This command NEVER invokes a subagent. Pure file ops, like `/sd:spec`. - It writes ONLY: `CHANGELOG.md` (repo root), `.specs/index.md`, and per-released-spec `00-spec.md` frontmatter + `05-retro.md`. It never touches code, the constitution, or `.claude/`. -- Only `done` specs of type feature / bug / refactor / perf are released and archived. RCA specs are left untouched. +- Only `done` specs of type feature / bug / refactor / perf / port are released and archived. RCA specs are left untouched. - Archiving reuses the validated `done -> archived` state-machine transition; every transition is logged append-only to `05-retro.md`. - Exactly ONE hard gate (Phase 4) precedes any write. `--dry-run` stops before writing. - MAJOR version bumps are never auto-inferred; pass `[version]` explicitly. diff --git a/commands/setup.md b/commands/setup.md index 39361f9..01d62c5 100644 --- a/commands/setup.md +++ b/commands/setup.md @@ -18,9 +18,12 @@ Scaffolds a project to use `specwright`. Safe to re-run: detects existing state - `~/.claude/skills/sd/` - If either is missing -> abort with: "specwright install incomplete — `` not found. Run `install/install.ps1` (Windows) or `install/install.sh` (Unix) from the specwright repo first." 2. Verify the current directory looks like a project root. - - Heuristics: presence of `.git/`, OR a package manifest (`package.json`, `*.csproj`, `*.sln`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `pom.xml`, `build.gradle`). + - Heuristics: presence of `.git/`, OR a package manifest (`package.json`, `*.csproj`, `*.sln`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `pom.xml`, `build.gradle`). - If nothing found -> warn and ask: "This directory does not appear to be a project root. Continue anyway? (yes / no)". 3. Read `~/.claude/templates/sd/CLAUDE.template.md`, `constitution.template.md`, `project-config.template.json`, `settings.template.json` into memory. (Spec templates remain on disk for later.) +4. Read `~/.claude/templates/sd/specwright-version.txt` into memory as "installed engine version". + If missing or unreadable (engine installed before this stamp existed), record it as `` + and do NOT abort Phase 0 over this file specifically - unlike the hard existence checks in step 1. --- @@ -61,7 +64,7 @@ is generic, so future renames and newly-introduced template fields are caught th **A. `.claude/settings.json`** 1. **Hook command paths (HIGH).** Each hook command's script path must use the namespace dir of - the loaded `settings.template.json` (currently `.../hooks/sd/`). Flag any `/.claude/hooks//` + the loaded `settings.template.json` (currently `.../hooks/sd/`). Flag any `/.claude/hooks//` segment (e.g. `.../hooks/ck/`) and record the old -> new rewrite per hook (UserPromptSubmit/prompt-router, PreToolUse/spec-gate, SubagentStop/subagent-retro). 2. **Missing top-level blocks.** Flag any template top-level key absent from the file - currently @@ -72,17 +75,26 @@ is generic, so future renames and newly-introduced template fields are caught th **B. `.claude/project-config.json`** -4. **`$schema` URL (MEDIUM).** If it does not equal the template's `$schema` - (`.../Developzone/specwright/main/schema/...`), flag the rewrite. Catches the old - `.../NXTK/ck-spec-system/...` host/org/repo. +4. **Stale `$schema` key (MEDIUM).** The current template publishes no `$schema` - no schema file + is published for it. If the file still has a `$schema` key (leftover from an older template, + e.g. the old `.../NXTK/ck-spec-system/...` host or the later dead `.../Developzone/specwright/...` + URL), flag it for removal. 5. **Command/agent names in `_use` doc strings.** Scan every `_use` / `_*_use` string under `mcp.*`, `ticket.*`, `hooks.*`, `paths.*`. Flag old-namespace tokens: `/ck:*` -> `/sd:*` and `ck:` / `ck-` -> `sd-`. Rewrite only the token, preserving the rest of the wording. -6. **Missing newly-introduced fields.** Flag template keys the file lacks - currently - `ticket.snapshot` (whole object) and `paths.layers` + `paths._layers_use`. Add each from the - template's default; `paths.layers` defaults to `[]` (never invent a layer map here - that is - Phase 2.5's job on a real scaffold). +6. **Version gap and missing newly-introduced fields.** Two related sub-findings: + - **Version gap.** Compare the file's `version` against the "installed engine version" read in + Phase 0. If the installed engine is newer (or the file's version is missing/unrecognized), + record `version: -> `. Skip this sub-finding if the installed engine version is + ``. + - **Field diff.** Diff every top-level and nested key of the loaded `project-config.template.json` + against the file, excluding: placeholder-only differences (`<<...>>` tokens), and every + project-specific value already carved out below (project name/owner/repo, ticket settings, + detected commands/paths, filled `paths.layers`, MCP `enabled` flags, `specGate.mode`). Flag + any template key genuinely absent from the file, adding each from the template's default; + `paths.layers` defaults to `[]` (never invent a layer map here - that is Phase 2.5's job on a + real scaffold). 7. **Pinned model IDs -> aliases.** Under `models.*`, flag any dated/versioned ID (`claude-sonnet-4-6`, `claude-haiku-4-5-20251001`, ...) and map to the family alias (`claude-sonnet-*` -> `sonnet`, `claude-haiku-*` -> `haiku`, `claude-opus-*` -> `opus`). Leave @@ -121,8 +133,8 @@ and continue (in `complete` -> clean early exit; in `partial` -> continue fillin Otherwise print ONE table grouped by file (each line showing exact before -> after with a HIGH/MED/LOW tag), then STOP for explicit approval: -> Reply "go" to apply (each file backed up first), "skip" to leave `.claude/*` untouched, or name -> specific lines to exclude (e.g. "skip the models lines"). +> Reply to apply every change (each file backed up first), leave `.claude/*` untouched, or name the +> specific lines to exclude, e.g. "skip the models lines". (go / skip / ) This is a confirmation of a batch, not a 4th interrogation question - the 3-question rule still holds. @@ -134,7 +146,9 @@ This is a confirmation of a batch, not a 4th interrogation question - the 3-ques place; add missing keys/blocks from the template at their template position. Do NOT reformat untouched content, reorder keys, drop `_`-prefixed comment keys, or change any project-specific value (project name, ticket settings, detected commands/paths, filled layers, MCP `enabled` - flags, `specGate.mode`). Preserve unfilled `<>` tokens. + flags, `specGate.mode`). Preserve unfilled `<>` tokens. Exception: `version` is + engine-tracked, not project-specific - when the item-6 version-gap sub-finding is approved, + overwrite the file's `version` with the installed engine version from Phase 0. 3. Validate each patched file still parses as JSON (mirrors Phase 7) and re-run the hook-resolution check; report backups written, change counts per file, and post-migration hook resolution. Tell the user to restart Claude Code so corrected hooks are picked up. @@ -149,12 +163,13 @@ Goal: extract stack info to pre-fill answers in Phase 3. 1. Read existing `CLAUDE.md`. 2. Try to extract: - - **Language**: from headers like "## Stack" -> "Language" or "Language:" patterns; from filenames present in root (e.g. `*.csproj` -> .NET / C#). + - **Language**: from headers like "## Stack" -> "Language" or "Language:" patterns; from filenames present in root (e.g. `*.csproj` -> .NET / C#). - **Framework**: similar. - **Database**: similar. - **Build / Test / Lint commands**: from "## Commands" or "## Scripts" sections. 3. Store extracted values as defaults for Phase 3. Do not assume; only pre-fill if confidence is high. + If `CLAUDE.md` does not exist, defaults come from filename heuristics (`*.csproj` -> .NET; `package.json` -> Node; `pyproject.toml` -> Python; etc.). --- @@ -173,6 +188,7 @@ constitution rules. 2. Detect and record: - **Stack** (language / framework / db): extend the Phase 2 filename heuristics with a content peek at the dominant package manifest (`package.json`, `*.csproj`, `pyproject.toml`, `go.mod`, + `Cargo.toml`, `pom.xml`, `build.gradle`). - **Paths**: `src`, `tests`, `docs` from the directory layout. - **Commands** (`build` / `test` / `lint` / `run` / `coverage`): read ONLY what the dominant @@ -186,7 +202,7 @@ constitution rules. ### Gate - confirm detected facts (one batch confirmation, not a question) -Print a single table of detected facts and ask the user to confirm before writing: +Print a single table of detected facts, then STOP for explicit approval before writing: ``` Detected (edit any before I write, or say "go"): @@ -196,7 +212,8 @@ Detected (edit any before I write, or say "go"): Commands build/test/lint/run/coverage : >"> ``` -> Review these. Reply with corrections (e.g. "tests = test, drop the Infrastructure layer") or "go". +> Review these. Reply to accept them as-is, or send corrections such as "tests = test, drop the +> Infrastructure layer". (go / ) This is a confirmation of a batch, not a 4th interrogation question - the 3-question rule still holds. Anything the user does not correct is used as-is; anything still unknown stays `<>`. @@ -284,6 +301,8 @@ Active specs (auto-updated by /sd:spec status transitions): 1. Create `.claude/` directory if missing. 2. Write `.claude/project-config.json` from `~/.claude/templates/sd/project-config.template.json`: + - Set `version` to the installed engine version read in Phase 0 (not the template's literal + `1.0.0`). - Substitute project name, owner (from git config), repo URL (from git remote). - Set `ticket.system`, `ticket.pattern`, `ticket.baseUrl` from Phase 3 Q1/Q2. - Set `commands.{build,test,lint,coverage,run}` from Phase 2.5 detected facts; any command not @@ -322,11 +341,11 @@ Setup complete. Generated: - .claude/settings.json (hooks: prompt-router, spec-gate, subagent-retro) Installed engine paths: - - ~/.claude/commands/sd/ (13 workflow commands) + - ~/.claude/commands/sd/ (14 workflow commands) - ~/.claude/agents/sd/ (6 specialist agents) - ~/.claude/hooks/sd/ (3 hooks) - ~/.claude/templates/sd/ (templates) - - ~/.claude/skills/sd/ (8 skills: severity-taxonomy, hypothesis-tree, atomic-task-format, evidence-citation, spec-templates, pattern-discipline, retro-lessons, replan-loop) + - ~/.claude/skills/sd/ (9 skills: severity-taxonomy, hypothesis-tree, atomic-task-format, evidence-citation, spec-templates, pattern-discipline, retro-lessons, replan-loop, port-fidelity) Next steps: 1. Fill placeholders in CLAUDE.md and .specs/constitution.md (open them in your editor). @@ -341,7 +360,7 @@ Next steps: - **Idempotent.** Safe to re-run. Never overwrites without a timestamped backup. - **Migrates drift, never silently.** Phase 1.5 detects content drift in existing `.claude/*` (renamed engine paths/URLs/command names, newly-introduced template fields, pinned model IDs) via rule-based checks against the loaded templates - never a raw line-diff, so `<>` and project-specific values are preserved. Every change is previewed in one batch gate, each file is backed up first, and every hook `command` path is verified to resolve on disk. -- **Stack-agnostic.** Inference uses filename heuristics + CLAUDE.md parsing. Never hardcodes .NET / Node / Python assumptions. If inference fails, the field stays as `<>`. +- **Stack-agnostic.** Inference uses filename heuristics + CLAUDE.md parsing. Never hardcodes .NET / Node / Python assumptions. If inference fails, the field stays as `<>`. - **Scan detects facts, never rules.** Phase 2.5 may pre-fill stack, paths, commands, and `paths.layers` only. It never writes `.specs/constitution.md` rules - those stay `<>` for the user to author explicitly. diff --git a/commands/spec.md b/commands/spec.md index 7eddab9..112053a 100644 --- a/commands/spec.md +++ b/commands/spec.md @@ -41,7 +41,7 @@ If `` is omitted or not recognized, run the `help` subcommand. ## list Args: -- `[type]` (optional): one of `feature`, `bug`, `refactor`, `perf`, `rca`. Filters by type prefix. +- `[type]` (optional): one of `feature`, `bug`, `refactor`, `perf`, `rca`, `port`. Filters by type prefix. - `[status]` (optional): one of the lifecycle states. Filters by current state in index. Behavior: @@ -95,7 +95,7 @@ archived -> in-progress (only via 'revive', with reason) The `in-progress -> done` transition of a feature (FEAT) spec is hook-enforced: spec-gate blocks the `index.md` edit unless `//06-verify.md` exists and records `result: pass`. Run `/sd:verify ` first. Disable only via `hooks.specGate.verifyGate: false` -in project-config. Other spec types (bug, refactor, perf, rca) close out as before - the hook +in project-config. Other spec types (bug, refactor, perf, rca, port) close out as before - the hook does not gate their `index.md` row. 3. Illegal transitions are REFUSED. Do NOT mutate any file. Print a refusal that names the current @@ -248,14 +248,16 @@ Behavior: - `status` is in `spec.lifecycle` from project-config. - Placeholder discipline (see "Placeholder tokens" below). - Expected files present per status: - - status >= `in-progress` -> `01-plan.md` and `02-tasks.md` exist (feature and refactor - only; bug, perf, and rca do not produce plan/tasks artifacts). + - status >= `in-progress` -> `01-plan.md` and `02-tasks.md` exist (feature, refactor, and + port only; bug, perf, and rca do not produce plan/tasks artifacts). - status == `done` -> `05-retro.md` exists with at least one entry. - Index row matches frontmatter status. - Transition history is legal (see "Transition replay" below). - Links resolve and are symmetric (see "Link integrity" below). - Task-block content, when `02-tasks.md` exists (see "Task-block checks" below). This is the only check that reads inside an artifact rather than around it. + - Port-spec table integrity, when `type` is `port` (see "Port-spec checks" below). + - Close-out hygiene, when `status` is `done` (see "Close-out hygiene checks" below). 2. Tree-wide checks (run once, only when the target is `--all`): - Index <-> folder symmetry (see below). 3. Report every finding using the severity taxonomy (see "Output" below). Do not stop at the @@ -300,6 +302,11 @@ BLOCK or WARN without one. IDs are stable: renumbering them breaks anyone who ha | `SL071` | A `## Revisions` entry `R` names an `Affected task` that does not carry `Revised-by: R` (or does not exist) | 🔴 BLOCK | | `SL072` | Revision numbering is non-contiguous, duplicated, or a prior entry was rewritten (append-only violated) | 🔴 BLOCK | | `SL073` | A `## Revisions` entry is malformed - missing `Trigger`, `Gate: re-plan`, `Phase`, or `revised-from` | 🟠 WARN | +| `SL080` | `source_repo` or `source_commit` missing, empty, `none`, or still a `<>` on a `port` spec whose `scope` is not `pattern` | 🔴 BLOCK | +| `SL081` | `## Member manifest` table has no data rows | 🔴 BLOCK | +| `SL082` | Deviation-table row with an empty `Citation`, or a `Group` that is empty or outside `1`-`4` | 🔴 BLOCK | +| `SL083` | Path-mapping row whose `Kind` is not `mirror` and whose `Reason` is empty or `-` | 🔴 BLOCK | +| `SL090` | Status `done`, the spec body names deferred work, and `## Spawned specs` has no data row (or no such section) | 🟡 SUGGEST | `SL061`-`SL069` are **reserved** for further task-block content rules. Claim from this band rather than extending another one - `SL05x` is link integrity and has nothing to do with task content. @@ -310,11 +317,32 @@ purpose: it is neither task-block *content* (`SL06x`) nor `linked_specs` symmetr borrows the two-sided-symmetry shape of the latter. `SL074`-`SL079` are reserved for further revision-record rules. +`SL080`-`SL083` are the **port-spec integrity** band - the three fidelity tables (`sd-port-fidelity`) +a `port` spec must carry, checked from the outside without adjudicating their content. `SL084`-`SL089` +are reserved for further port rules (ordinal contiguity, `Host path` uniqueness, cross-table +referential integrity) if they ever move from the gate into this lint. All four are BLOCK: a `port` +spec that lints clean with an empty member manifest, an uncited deviation, or an unexplained +non-mirror row is a registry that **lies** about carrying a reviewable fidelity contract - +`sd-reviewer`'s hunk classification would then emit findings with no legal anchor, and the gate's +completeness conditions are counted, not judged, so the failure is objective. `SL080` is BLOCK for +the same reason `SL002` is: `source_repo`/`source_commit` are the only cross-project traceability +that exists (`linked_specs` cannot reference another repo), so a non-`pattern` port without them is +unauditable. `validate` does **not** check ordinal contiguity, `Host path` uniqueness, cross-table +referential integrity (`Deviation ID` <-> deviation row), or one-row-per-donor-file completeness - +those are `sd-port-fidelity`'s gate-time job, and duplicating them here would create a second source +of truth for the same predicate. + +`SL090` is the **close-out hygiene** band - work the spec named but never gave an owner or an ID. +It is a new band on purpose: it is not task content (`SL06x`), not the revision log (`SL07x`), and +not port fidelity (`SL08x`). `SL091`-`SL099` are reserved for further close-out-hygiene rules. + Severity rationale: BLOCK is for a registry that **lies** (its own contents contradict each other, so `list` / `stats` / downstream agents read something untrue) or evidence that was **fabricated** (`SL011` - a measured field filled from memory). WARN is for a real problem that leaves the -registry still truthful and is recoverable by re-running a command. There is no SUGGEST rule -today; the section is still printed, per the taxonomy. +registry still truthful and is recoverable by re-running a command. SUGGEST is for hygiene that +costs a future reader but leaves nothing untrue and breaks nothing - `SL090` is the only one +today, and it is deliberately never a failure: a spec that genuinely deferred nothing must not be +made to answer for an empty table. `SL060` is WARN by that same test: a task with no `Pattern refs` leaves the registry truthful and is fixed by re-planning the spec. It is deliberately **not** BLOCK - in the only corpus measured, @@ -378,6 +406,55 @@ undocumented silent edit by diffing - an edit that adds no `Revised-by` marker a entry is invisible here and is prevented by the HARD Gate Re-plan, not by this lint. Do not report, or imply, a finding the checks above cannot actually decide. +### Port-spec checks + +Runs only when frontmatter `type` is `port`. Locate each table by its exact `##` heading and parse +it as header row + separator row + data rows; a cell counts as empty when blank or `-`. Report one +finding per offending row or condition, never one finding per table. The three fidelity tables' +authoritative column schemas and completeness conditions live in +`~/.claude/skills/sd/sd-port-fidelity/SKILL.md` - read it at runtime rather than restating the +schemas here, the same pattern `### Output` already uses for `sd-severity-taxonomy` / +`sd-evidence-citation`. + +- **`SL080`.** `scope` is not `pattern` and either `source_repo` or `source_commit` is missing, + empty, the literal value `none`, or still an author-fill `<<...>>` token - `none` is a legitimate + value only when `scope` is `pattern`. Cite the `scope:` frontmatter line - it is the line that + creates the obligation. +- **`SL081`.** The `## Member manifest` table has a header and separator but no data row. Cite the + `## Member manifest` heading line - an empty table has no row of its own to cite. +- **`SL082`.** A row in `## Deviation table` whose `Citation` cell is empty, or whose `Group` cell + is empty or not one of `1`, `2`, `3`, `4`. Cite the offending row. +- **`SL083`.** A row in `## Path mapping table` whose `Kind` cell is not `mirror` (including a + garbled or empty `Kind`) and whose `Reason` cell is empty or `-`. Cite the offending row. + +### Close-out hygiene checks + +Runs only when frontmatter `status` is `done`. One rule today, `SL090`, and it is 🟡 SUGGEST - +never a BLOCK, never a WARN, and never a reason for the run to report failure. + +`SL090` fires when **both** hold: + +1. The spec **named deferred work**. Search `00-spec.md` and `05-retro.md`, case-insensitively, + for any of this closed list: `follow-up`, `follow up`, `deferred`, `defer to`, `separate spec`, + `spawn a`, `spawned`, `own spec`, `future spec`, `left as-is`, `reproduced as-is`, + `not fixed here`, `TODO`. The list is closed on purpose - "deferred-work language" judged + freehand is not a decidable predicate, and an advisory that fires on a hunch is noise. + Skip HTML comments, fenced code blocks, the `## Out of scope` section, and the + `## Spawned specs` section itself. `## Out of scope` is excluded because it declares a + boundary rather than deferring work, and it is where the templates put their own example + prose - scanning it would fire on template text. `phase-deferred` does not count as a match + for `deferred`: it names the `<>` token mechanism, not deferred work. +2. The spec has **no reserved ID**: `## Spawned specs` is absent, or present with a header and + separator but no data row. + +Cite the first matching line as `file:line` - that line is the deferred work with nowhere to +land. Name the phrase that matched and the absent or empty table in the finding. Report **one** +`SL090` per spec, not one per phrase. + +Do not fire on a spec that has at least one data row: this rule asks whether follow-up work was +recorded at all, and never adjudicates whether the rows are the *right* rows. A reserved ID in +that table is a placeholder, not a registry entry - see "Index <-> folder symmetry". + ### Output Read `~/.claude/skills/sd/sd-severity-taxonomy/SKILL.md` and @@ -433,6 +510,13 @@ under `spec.dir` against the set of rows in `spec.indexFile`: Directories whose name starts with `_` are engine-reserved (`_explorations/`, `_reviews/`, `_adr/`, `_archived/`) and are NOT specs - skip them. Skip `index.md` and `constitution.md` too. +**Reserved IDs are not registry entries.** An ID in a spec's `## Spawned specs` table is a +placeholder for work that has not been started. It gets an `.specs/index.md` row only once its +spec directory actually exists - i.e. once someone runs the child workflow. Adding the row first +manufactures exactly the ghost row `SL032` exists to catch: an index entry pointing at a +directory that is in no commit. For the same reason a reserved ID never goes in `linked_specs`; +the link is written by `/sd:spec link` after the child is created. + ### Transition replay `05-retro.md` is the append-only status log written by `status` / `link`. Replay it against the @@ -476,10 +560,14 @@ Every type additionally requires `linked_specs` (a YAML list; `[]` when the spec | `refactor` | `id`, `type`, `smell`, `status`, `created` | | `perf` | `id`, `type`, `status`, `target_metric`, `created` | | `rca` | `id`, `type`, `status`, `severity`, `incident_started`, `incident_resolved`, `created` | +| `port` | `id`, `type`, `status`, `scope`, `source_repo`, `source_commit`, `source_license`, `snapshot`, `jira`, `created` | `jira` is required to be present but may hold `none`. `incident_resolved` may hold a placeholder while an incident is still open - an RCA for an unresolved incident cannot pass `approved`. `linked_specs` must be present and a list; `[]` is the valid empty form, a bare `none` is not. +`source_repo` / `source_commit` are required to be **present** for every port spec (that is +`SL002`); their **value** must additionally be real when `scope` is not `pattern` (that is +`SL080`) - a `pattern`-scope port writes `none`. ### Placeholder tokens @@ -519,7 +607,7 @@ Args: none. Behavior: 1. Parse `.specs/index.md`. 2. Output: - - Counts by type (FEAT / BUG / REF / PERF / RCA). + - Counts by type (FEAT / BUG / REF / PERF / RCA / PORT). - Counts by status. - "Aging" report: specs in `in-progress` for more than 7 days (configurable), specs in `draft` for more than 14 days. - Top 5 oldest `in-progress` specs. diff --git a/commands/status.md b/commands/status.md index 83cc305..d802868 100644 --- a/commands/status.md +++ b/commands/status.md @@ -1,6 +1,6 @@ --- description: Read-only summary of the metrics log and spec registry - what is in progress, where gates fire, where friction concentrates -argument-hint: (none) +argument-hint: (none) or --calibration --- # /sd:status - metrics and registry summary @@ -15,7 +15,8 @@ The event schema is documented in `docs/architecture.md` - see "Event log". This of any read contract: a generation may be discarded on the next roll, so counting it would report a window that cannot be reproduced. -Takes no argument. +Takes no argument, or the single optional flag `--calibration` (SW-31). Plain `/sd:status` never +reads anything beyond the two files above - `--calibration` is strictly additive: see Phase 3b. ## State machine @@ -113,6 +114,36 @@ present only lines that have data behind them - omit an empty friction section r log. Either the work is not happening or metrics started after the spec did; say which is not determinable from the log. +## Phase 3b - Calibration (`--calibration` only) + +Skipped entirely for a plain `/sd:status` invocation - the default read contract (`events.jsonl` + +`index.md`) is unchanged, and Phases 1-3 above already ran unmodified. When `--calibration` is +passed, additionally glob `/*/02-tasks.md` and `/*/03-decisions.md` for every +spec directory present, at any lifecycle status - a thin corpus needs every data point it has, not +only `done` ones. + +For each spec directory found, from `02-tasks.md`: +- **Tasks**: count `### T` headings, tolerant of the `✅`-prefixed variant (`### ✅ T01`) per + `docs/adr/0002-complexity-triage-decomposition.md`'s own warning that a naive `^### T` + counter silently reads a checked-off task as zero. +- **Layers**: count distinct `Layer:` field values across the spec's task blocks, **excluding + `Tests`/`Config`** per ADR 0002's own exclusion rule, so this count stays comparable to the + threshold it is calibrating. Field values are read with the tolerant grammar in + `skills/sd-atomic-task-format/SKILL.md` (bullet `-`/`*`, `**` optional, colon inside or outside + the emphasis) - do not write a narrower matcher here. +- **Files**: count distinct paths across the spec's `Files:` fields, deduplicated within the spec. + +From the already-loaded `events.jsonl` (fixed key order, same exact-substring counting as Phase 1): +- **Complexity-gate splits**: `grep -c '"gate":"complexity","decision":"split"'`. This is an + inferred signal, not a direct observation - see `docs/architecture.md` "Event log" for what it + can and cannot detect (it never sees a bare trip, only a completed split). + +**Sample-size framing.** Let *n* be the number of spec directories found. When *n* is below the +CONTRIBUTING re-calibration trigger (20 closed specs), render every computed number - never hide a +count that was actually produced - but prefix the section with `insufficient data (n=)` rather +than presenting the distribution as a basis for changing any threshold. This mirrors the ST002-ST004 +degrade convention above: a thin corpus is a labelled state, not a silently-confident report. + ## Phase 4 - Render ``` @@ -150,6 +181,19 @@ Extensions seen on code-edit gates: .cs (12), .ts (4) - : N code-edit warns ignored - : retro stale count reached N - : in progress but absent from the log + + + +## Calibration (n= specs<, insufficient data when n is below the CONTRIBUTING trigger>) + +| Metric | Distribution | +|---|---| +| Tasks per spec | ... | +| Layers touched (excl. Tests/Config) | ... | +| Files touched | ... | + +Complexity-gate splits observed: (inferred from index.md, not a direct trip observation - +see docs/architecture.md "Event log") ``` Degrade states render the same skeleton with the metrics sections replaced by exactly one labelled @@ -175,3 +219,6 @@ Each names the reason and the path, so "quiet" is never confused with "clean". command, or anything from `commands.*` in project-config. - Do not guess at numbers. Every figure in the output comes from a counting command that was actually run; if a count could not be produced, say so in place of the number. +- `--calibration` may additionally read `02-tasks.md` / `03-decisions.md` under every spec + directory (Phase 3b). It still never writes anything, still never reads `events.jsonl.1`, and a + plain `/sd:status` invocation's read set is unaffected by the flag's existence. diff --git a/commands/verify.md b/commands/verify.md index 3f4b3f4..a6f1a06 100644 --- a/commands/verify.md +++ b/commands/verify.md @@ -67,6 +67,7 @@ Parsing shapes (exact): VF022 execution: run `commands.test` via Bash from the project root. Capture the exit code. Do not guess a test command when `commands.test` is empty - that is VF023 (stack-agnostic + rule: never hardcode `dotnet test`, `npm test`, etc.). ## Artifact diff --git a/docs/adr/0004-threshold-calibration.md b/docs/adr/0004-threshold-calibration.md new file mode 100644 index 0000000..6e05c1d --- /dev/null +++ b/docs/adr/0004-threshold-calibration.md @@ -0,0 +1,106 @@ +# ADR 0004: threshold calibration returns "insufficient data" - ships measurement, not new numbers + +- Status: proposed +- Date: 2026-08-09 +- Source spec: Jira SW-31 (`FEAT-threshold-recalibration`) +- Relates to: SW-13 (ADR 0002, Gate Complexity thresholds); SW-10 / SW-16 (metrics log and + `/sd:status`); SW-30 (second example corpus) +- Supersedes: none + +## Context + +SW-31 asks for a repeatable calibration pass: turn accumulated `.specs/_metrics/events.jsonl` and +spec-artifact data into evidence for or against each threshold the engine enforces - Gate +Complexity (tasks > 8, layers > 2, files > 8, from ADR 0002), `retroStaleMinutes` (30), +`debounceMinutes` (10), `maxLessons` (3), `metrics.maxSizeKb` (1024), and the perf gate's 5% noise +floor - plus a standing ritual so calibration happens again. + +Running that pass against this repo's actual state surfaced two things the ticket's own text +didn't anticipate: + +1. **The corpus is thinner than "n=1 risk" implies.** specwright does not dogfood itself - there is + no `.specs/` directory in this repo at all. `examples/fixture-project`, the second corpus SW-31 + was waiting on (SW-30), has exactly **one** closed spec (`FEAT-todo-priority`) and no + `events.jsonl` history. Real accumulated data is n=1, not a "canyon" or a "cluster" - there is + nothing to fit a distribution to yet. +2. **Gate Complexity trips were never recorded.** The event schema + (`docs/architecture.md`, "Event log") only ever emitted `gate: verify|protected|code-edit`. + Gate Complexity (ADR 0002) is decided as model-executed prose inside `/sd:feature` Phase 3 Gate + 2 - ADR 0002 itself names this an accepted, unresolved limitation ("no script... exercises the + threshold arithmetic... automatically"). Before this ADR, there was no path to ever answering + "what is the trip rate" from measured data, regardless of corpus size. + +Per ADR 0002's own "Scope declined" precedent, this ADR ships the machinery to close gap (2) +**partially** - split detection only, not full trip-rate - and accepts gap (1) as the honest +current state rather than manufacturing evidence from a single spec ("Re-fitting to the same five +specs would launder a guess as data" - SW-31's own note, which applies at n=1 even more directly +than the n=5 case it was written about). + +## Decision + +1. **New inferred metric: `gate:"complexity"` / `decision:"split"`.** `spec-gate` + (`hooks/bash/spec-gate.sh`, `hooks/powershell/spec-gate.ps1`) now watches every `index.md` edit + for the structural trace a completed Gate Complexity split leaves behind: a `FEAT-X` row newly + transitioning to `archived` alongside any `FEAT-X-` row already registered (on disk or in + the same pending edit), per `commands/feature.md`'s Face B "approve split" steps. This is + observational only, computed from data the workflow already writes - no change to + `commands/feature.md`'s Gate 2 (a HARD gate) or `agents/spec-architect.md` was made or is + needed. It is emitted **only when the edit is actually allowed through**, never on a `block` + exit - a denied edit never reaches disk, so a detected pattern inside it did not really happen. + Under the default config, `.specs/index.md` is itself listed in `paths.protected` + (`templates/project-config.template.json`), so most direct `index.md` edits are already blocked + before this metric ever gets a chance to fire (`docs/architecture.md`'s existing note on the + `decision` field: "Most direct index edits are blocked by `paths.protected`, so `block` is the + common case"). That is a pre-existing, documented property of `spec-gate`, not something this + ADR introduces or changes - but it does mean the split count will under-count real splits on + any project that leaves `index.md` protected, which is the default. Fixing that tension (how + `/sd:feature`'s own Gate 2 writes are meant to reach a protected `index.md` at all) is out of + scope here; `commands/spec.md` already carries the same open tension for the `done` transition + via its `verifyGate` carve-out. +2. **`/sd:status --calibration`.** A new optional view (`commands/status.md`, Phase 3b) reports + task/layer/file distributions read from spec artifacts, plus the `gate:"complexity"`/`split` + count from `events.jsonl`. The default `/sd:status` invocation's read contract is unchanged. +3. **The five non-Gate-Complexity thresholds are marked as judgement calls**, not measured values, + directly in `templates/project-config.template.json` (`_retroStaleMinutes_use`, + `_debounceMinutes_use`, `_maxLessons_use`, matching the existing `_maxSizeKb_use` caveat). Gate + Complexity is left as-is; ADR 0002 already recorded its measured basis (the SW-13 corpus trace), + which nothing in this ADR revisits or invalidates. +4. **Calibration verdict, this run: insufficient data, for every threshold.** n=1 closed spec + across both corpora, zero real `gate:"complexity"` events (the metric only exists as of this + ADR), zero real `events.jsonl` history outside test fixtures. No threshold changes size on this + run - per SW-31's own acceptance criterion, this is the honest and expected outcome at this + corpus size, not a failure of the calibration pass. +5. **CONTRIBUTING names the re-calibration trigger**: every 20 closed specs, or each minor release, + whichever comes first (SW-31's own proposed cadence). The next run of `/sd:status --calibration` + is the mechanism that answers whether that bar has been met. + +## Consequences + +**Positive.** Real calibration becomes possible going forward without re-opening this ticket - the +next `/sd:status --calibration` run after real specs accumulate reads live data instead of nothing. +The judgement-call caveats make future readers of `project-config.template.json` unable to mistake +an untuned default for a measured one. + +**Negative.** `gate:"complexity"`/`split` cannot detect a bare trip (Face A vs. Face B "no-split" +look identical in `index.md`) - see "Scope declined" below. The split-detection heuristic itself is +best-effort: two unrelated specs that happen to share an id prefix (`FEAT-auth` / `FEAT-auth-v2`) +would misread as parent/child. Acceptable for an observational, non-gate-affecting metric, same +class of limitation the file already accepts elsewhere (Rule 0's bundled-edit limitation). +Recording only on an allowed edit (see Decision 1) means the metric under-counts on any project +that leaves `index.md` protected by default - it will report fewer splits than actually occurred, +never more; a project relying on this signal needs to make the archive-plus-child edit reachable. + +**Scope declined.** Full trip-rate instrumentation (recording every Gate 2 resolution, not only +completed splits) is **not** built here. The only way to observe it directly is a marker the +architect or the `/sd:feature` command writes at Gate 2 resolution time - and making a HARD gate's +prose responsible for reliably feeding a metrics pipeline would break the invariant +`docs/architecture.md` states plainly: "`spec-gate` and `subagent-retro` are the hooks that +record." A future spec that wants full trip-rate should design the instrumentation around a +deterministic, hook-observable signal (or accept a model-authored marker as an explicit, separate +trade-off) rather than retrofitting it into this "cheap ticket." + +## Follow-up + +Re-run this calibration once `.specs/index.md` (or `examples/fixture-project/.specs/index.md`) +records at least 20 closed specs, or at the next minor release - whichever comes first, per +CONTRIBUTING. A future ADR should supersede this one with the first real verdict. diff --git a/docs/architecture.md b/docs/architecture.md index a1db5a8..64d3792 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,11 +10,11 @@ specwright is a thin layer on top of Claude Code that enforces spec-driven devel +--------------------------------------------------------------------+ | Layer 1 - USER scope (~/.claude/, installed once) | | | -| commands/sd/ 13 workflow definitions | +| commands/sd/ 14 workflow definitions | | agents/sd/ 6 subagent prompt files | | hooks/sd/ 3 cross-platform hook scripts | -| templates/sd/ 4 setup + 5 spec templates | -| skills/sd/ 8 reusable rule packs (referenced by agents) | +| templates/sd/ 4 setup + 6 spec templates | +| skills/sd/ 9 reusable rule packs (referenced by agents) | | | | Generic engine. Never changes per project. Updated by re-running | | the installer. | @@ -67,15 +67,16 @@ Phase N - Close-out A **gate** is a checkpoint where the workflow refuses to proceed without explicit user approval. Silence is not approval. Skipping a gate requires logging a constitution exception to the spec's retro. -The 5 workflow commands have these gate counts: +The 6 workflow commands have these gate counts: | Workflow | Gates | Why | |---|---|---| -| `/sd:feature` | 3 | spec, plan, per-task review, integration | +| `/sd:feature` | 3 | spec, plan, integration + review | | `/sd:bug` | 5 | symptom, reproduction (HARD), root cause, failing test, regression | | `/sd:rca` | 3 | evidence, hypotheses, root cause | | `/sd:refactor` | 6 | spec, coverage, post-test, plan, per-batch tests, holistic review | | `/sd:perf` | 8 | target, baseline (HARD), hotspot, hypothesis, correctness, keep/revert, regression, final review | +| `/sd:port` | 6 | donor set frozen (HARD), fidelity tables (HARD), behavior pinned (HARD), plan, per-batch tests, justified-diff parity (HARD) | Gates marked HARD (e.g. bug reproduction, perf baseline) have no override path. The point is to force discipline at the moments when shortcuts are most tempting. @@ -111,6 +112,7 @@ fan-out each command performs (left to right = invocation order; `(xN)` = once p /sd:refactor -> spec-architect -> code-explorer -> implementer (coverage) -> spec-architect -> implementer (xN) -> reviewer /sd:perf -> spec-architect -> debugger (hotspot / verify) -> implementer (xN) -> reviewer /sd:rca -> spec-architect -> debugger (enumerate / verify) [no code change - output IS the spec] +/sd:port -> code-explorer (port-extract, in-repo only) -> spec-architect -> code-explorer -> spec-architect -> implementer (xN) -> reviewer (port-parity) /sd:explore -> code-explorer /sd:review -> reviewer /sd:adr -> docs-writer @@ -128,6 +130,20 @@ the implementer makes the change one atomic task at a time, and the reviewer gat reviewer has no write tools, so the loop cannot auto-fix - findings always route back through a fresh implementer call. +### Why port parity is a reviewer check, not a `/sd:verify` check + +`/sd:verify` and the port parity gate are both close-out gates, so the two whole-artifact checks +the parity gate performs - member completeness and path conformance - had a plausible home in +either. The test is whether a check can be decided from `00-spec.md` and `02-tasks.md` with fixed +regex shapes and nothing else read, which is all `/sd:verify` ever does. Member completeness fails +it: deciding that a manifest member is present means reading host source and recognizing a member +boundary in whatever language the host happens to be written in, and that judgement is precisely +what `/sd:verify` exists in order not to make. Path conformance nearly passes it, but `/sd:verify` +takes no changeset input at all, and granting it one to serve a single set comparison buys a second +non-deterministic surface and splits fidelity findings across two reports. Both checks therefore +live in `sd-reviewer`'s `port-parity` mode, where the diff already is, and `/sd:verify` stays +deterministic. + --- ## Agent skills @@ -149,6 +165,8 @@ The split exists for three reasons: | `sd-spec-templates` | `sd-spec-architect` | Per-template authoring rules; which cross-phase fields to leave empty. | | `sd-pattern-discipline` | `sd-spec-architect`, `sd-implementer`, `sd-reviewer` | Pattern discovery and adherence: precedent sampling, `Pattern refs` authoring/following, conformance review. | | `sd-replan-loop` | `sd-spec-architect`; `/sd:feature`, `/sd:refactor`, `/sd:spec validate` (runtime read) | Mid-execution re-plan protocol: HARD Gate Re-plan, append-only `## Revisions` log in `01-plan.md`, `Revised-by` task marker. Shared by the two plan+tasks workflows so the revision format is defined once. | +| `sd-retro-lessons` | `scripts/validate-lessons.*`, `scripts/aggregate-lessons.*` (runtime read) | The `lesson` line format, tag vocabulary, and reusability bar for retro lessons. The one skill with no agent consumer - declared in `contractLint.skillConsumers`. | +| `sd-port-fidelity` | `sd-spec-architect`, `sd-reviewer` | Cross-project port fidelity: structural mirror default, four-group deviation allowlist, anti-simplification rules, three gate table schemas, parity artifact layout, closed five-class hunk vocabulary plus the two whole-artifact checks. | Agents declare the skills they apply via a `skills:` list in YAML frontmatter: @@ -191,9 +209,10 @@ Runs before any code-editing tool. Decides: This catches the common failure mode where the user (or Claude) jumps straight to editing code without creating a spec first. Alongside guarding, `spec-gate` also **records**: every gate decision (verify / protected / -code-edit) and every `.specs/index.md` lifecycle transition it observes is appended as one JSON -line to `.specs/_metrics/events.jsonl`. Recording is purely observational - it never alters a -gate decision, only measures it after the fact. See the event log section below for the schema. +code-edit), every inferred Gate Complexity split, and every `.specs/index.md` lifecycle transition +it observes is appended as one JSON line to `.specs/_metrics/events.jsonl`. Recording is purely +observational - it never alters a gate decision, only measures it after the fact. See the event +log section below for the schema. **Block output schema (dual-format).** When `spec-gate` denies a tool call, it emits a single JSON object that carries **both** the new and the legacy schema fields so it works across Claude Code CLI versions: @@ -233,8 +252,8 @@ the PowerShell and bash implementations produce byte-comparable lines: | `spec_id` | always | `FEAT-x` / `BUG-x` / ... , or `-` when no spec is in scope | | `phase` | always | lifecycle status of `spec_id` (`draft` / `approved` / `in-progress` / `done`), or `-` | | `event` | always | `gate` \| `spec_transition` \| `subagent_stop` | -| `gate` | when `event` is `gate` | `verify` \| `protected` \| `code-edit` | -| `decision` | when `event` is `gate` or `spec_transition` | `allow` \| `block` \| `warn` - on a transition, whether the index edit was ultimately allowed through. Most direct index edits are blocked by `paths.protected`, so `block` is the common case; a verified `done` close-out is the path that yields `allow`. | +| `gate` | when `event` is `gate` | `verify` \| `protected` \| `code-edit` \| `complexity` | +| `decision` | when `event` is `gate` or `spec_transition` | `allow` \| `block` \| `warn` \| `split` (only on `gate:"complexity"`) - on a transition, whether the index edit was ultimately allowed through. Most direct index edits are blocked by `paths.protected`, so `block` is the common case; a verified `done` close-out is the path that yields `allow`. | | `from` | when `event` is `spec_transition` | previous lifecycle status, or `-` if not derivable | | `ext` | when `gate` is `code-edit` | lowercased file extension, e.g. `.ps1` - never a path | | `stale` | when `event` is `subagent_stop` | `0` or `1` - a flag, not a count. One event is emitted per in-progress spec per subagent stop; `1` means that spec's `05-retro.md` was stale or missing at that moment. Retro pressure is measured by counting `1`s over time, never by reading a single value as a quantity | @@ -245,8 +264,32 @@ Example lines: {"ts":"2026-07-21T09:14:02Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} {"ts":"2026-07-21T09:31:44Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"gate","gate":"code-edit","decision":"warn","ext":".ps1"} {"ts":"2026-07-21T09:40:55Z","spec_id":"FEAT-spec-metrics","phase":"in-progress","event":"subagent_stop","stale":1} +{"ts":"2026-08-09T05:12:33Z","spec_id":"FEAT-big","phase":"archived","event":"gate","gate":"complexity","decision":"split"} ``` +**`gate:"complexity"` (SW-31) is inference, not observation.** Gate Complexity (ADR +`docs/adr/0002-complexity-triage-decomposition.md`) is decided as model-executed prose inside +`/sd:feature` Phase 3 Gate 2 - no hook sees that decision directly. `spec-gate` instead watches +every `index.md` edit for the structural trace an "approve split" resolution leaves behind +(`commands/feature.md`'s Face B: the parent row moves to `archived` and each child is registered +as `FEAT--`) and emits `decision:"split"` when a newly-`archived` `FEAT-X` row is +seen alongside any `FEAT-X-` row, in the registry or the same pending edit. This can only +ever detect a **completed split** - Face A (never tripped) and Face B "no-split" (tripped, user +declined) both leave the parent `in-progress` with no distinguishing mark in `index.md`, so trip +rate on its own is not recoverable from this signal alone. See +`docs/adr/0004-threshold-calibration.md` ("Scope declined") for why that gap is left open rather +than closed by having the Gate 2 prose itself write a marker. + +It is emitted **only on an edit that was actually allowed through** - never on a `block` exit, at +any of the four rules above. A blocked `index.md` edit never reaches disk, so a detected +parent-archive-plus-child pattern inside a denied edit did not really happen; recording `split` +there would be a false positive. Combined with the note on `decision` above (most direct +`index.md` edits are blocked by `paths.protected` under the default config), this means the split +count is expected to under-count real splits whenever a project leaves `index.md` protected - +which is the default. A project that wants this signal to be reliable needs `index.md` reachable +by whatever edit actually performs the split (see `commands/spec.md`'s existing `verifyGate` +carve-out for the same tension on the `done` transition). + The log is metadata-only by design: no file paths, no code content, no commit messages - only spec IDs, lifecycle phases, decisions, and file extensions. Controlled by `hooks.metrics` in `.claude/project-config.json` (`enabled`, default `true`; `path`, default @@ -278,11 +321,11 @@ Every workflow writes to a structured folder under `.specs//`: ``` .specs/FEAT-INV-2501/ -├── 00-spec.md Why / What / Success criteria / Constitution check +├── 00-spec.md Why / What / Success criteria / Spawned specs / Constitution check ├── 01-plan.md Phased implementation plan ├── 02-tasks.md Atomic tasks (11 required fields, incl. Pattern refs) ├── 03-decisions.md Impact analysis from sd-code-explorer + debugger output -├── 04-artifacts/ Evidence: logs, queries, traces, baselines, ticket snapshots +├── 04-artifacts/ Evidence: logs, queries, traces, baselines, ticket snapshots, donor snapshots └── 05-retro.md Append-only log: status transitions, surprises, follow-ups ``` @@ -299,7 +342,7 @@ append-only memory, not scratch space: | `01-plan.md` | `sd-spec-architect` (plan) | feature / refactor plan phase | `sd-implementer` | | `02-tasks.md` | `sd-spec-architect` (plan) | feature / refactor plan phase | `sd-implementer` | | `03-decisions.md` | `sd-code-explorer` (impact) + `sd-debugger` (hypotheses / verdicts); append-only | impact + investigation phases | `sd-implementer`, `sd-reviewer` | -| `04-artifacts/` | any agent (logs, baselines, repro evidence, ticket snapshots) | throughout | any agent + future sessions | +| `04-artifacts/` | any agent (logs, baselines, repro evidence, ticket snapshots, donor snapshots) | throughout | any agent + future sessions | | `05-retro.md` | main thread (status transitions); append-only | every gate transition | resume logic + future sessions | The cross-phase fields inside `00-spec.md` are intentionally left empty at creation @@ -385,7 +428,7 @@ Every command, agent, and (conceptually) namespaced asset uses the `sd:` prefix: The prefix exists for three reasons: 1. **Collision avoidance.** A project may have its own `/feature` or `/review` slash command. `sd:` carves out a namespace. -2. **Discoverability.** Typing `/sd:` in Claude Code lists all 13 commands. The namespace is its own table of contents. +2. **Discoverability.** Typing `/sd:` in Claude Code lists all 14 commands. The namespace is its own table of contents. 3. **Removability.** Uninstalling the engine removes everything under `sd/` subfolders, leaving the rest of `~/.claude/` intact. --- diff --git a/docs/contract-lint.md b/docs/contract-lint.md new file mode 100644 index 0000000..e52f4e1 --- /dev/null +++ b/docs/contract-lint.md @@ -0,0 +1,352 @@ +# Contract lint (Check 8) + +`scripts/contract-lint.ps1` and `scripts/contract-lint.sh` lint the **relationships between** the +engine's prompt files: which agent a command invokes, which skill an agent loads, which template a +prompt reads, how many hard gates a workflow declares. + +Check 7 (`docs consistency`) already guards *inventory* -- how many files exist. That closed the +inventory drift class permanently. This closes the **contract** drift class: relationships that +were previously asserted in prose and checked only by human review. + +Each of these shipped, and each was statically detectable the whole time: + +| Shipped defect | Caught by | +|---|---| +| An agent told to append to a file with no write tool in its allowlist | CL200 (wave 3) | +| An input token an invocation never actually passes | CL102 (wave 2) | +| An `mcp__*` tool name that does not exist | CL202 (wave 3) | +| README claiming one gate count where `docs/architecture.md` claimed another | CL302 | + +It is a **script, not a prompt**: deterministic file operations, no subagent, no model, run per PR +in CI on all three operating systems like every other check. + +## Running it + +```bash +bash scripts/contract-lint.sh --root . # the repo +bash scripts/contract-lint.sh --root # any tree with a manifest +bash scripts/contract-lint.sh --root . --rule CL305 # filter the output +``` + +```powershell +.\scripts\contract-lint.ps1 -Root . +.\scripts\contract-lint.ps1 -Root . -Rule CL305 -Quiet +``` + +`--rule` filters what is **printed**, never what runs. Every rule always executes, so `CL902` +(a suppression that suppressed nothing) stays truthful under a filter. + +Output is TSV on stdout and nothing else -- one finding per line, root-relative paths, forward +slashes, sorted by file then line then rule then message: + +``` +CL300 BLOCK commands/alpha.md 59 gate block contains no literal STOP +``` + +Exit codes: `0` no BLOCK findings, `1` at least one BLOCK, **`2` could not run** (bad root, missing +manifest, `jq` absent, registry parity guard failed). Exit 2 is separate on purpose. A validator +that cannot tell "clean" from "crashed" is worthless, so validate's Check 8 treats it as a failure. + +## Rules + +Severity lives in `specwright.manifest.json` under `contractLint.rules[]`, never in a rule's own +code, so a BLOCK/WARN divergence between the two implementations is structurally impossible. + +### CL0xx -- reference resolution + +| Rule | Severity | Fires when | +|---|---|---| +| `CL001` | BLOCK | an `sd-` token resolves to neither an agent name nor a skill folder | +| `CL002` | BLOCK | an agent's `skills:` frontmatter entry has no `skills//SKILL.md` | +| `CL003` | BLOCK | the same unresolved shape as CL001, on a line that mentions a skill | +| `CL004` | WARN | a skill folder is referenced by nothing in scan scope and is not declared in `skillConsumers` | +| `CL005` | BLOCK | a `templates/` path does not exist once the install namespace segment is folded away | +| `CL006` | BLOCK | a `/sd:` reference has no `commands/.md` | +| `CL007` | WARN | an agent is mentioned by no command body | +| `CL008` | BLOCK | a numbered spec-artifact filename is absent from `contractLint.specArtifacts` | + +CL001 and CL003 split on whether the offending line mentions a skill; both BLOCK, so the split is +about the message a reader gets, not about severity. + +### CL1xx -- invocation contract + +An **invocation** is a `commands/*.md` line mentioning "Invoke"/"invoke" next to a backticked +`sd-` token. From there its **token span** runs forward through every backticked +`` `KEY = value` `` pair -- covering a bullet block, a same-line inline list, and a wrapped +multi-line inline list alike -- until the next heading, the next invocation, or the next top-level +numbered step, whichever comes first. A **mode declaration** is an `agents/*.md` heading carrying +the same kind of `` `KEY = value` `` pair (or, for the `` Task type: `value` `` heading grammar, +a key named by the agent's own "Read the `KEY` field" prose) immediately followed by +`Inputs (required):` then `Inputs (optional):` lines. + +| Rule | Severity | Fires when | +|---|---|---| +| `CL100` | BLOCK | an invocation sets `TASK`/`WORKFLOW_TYPE`/`TASK_TYPE` to a mode the target agent does not declare | +| `CL101` | WARN | an agent declares a mode no command ever invokes | +| `CL102` | BLOCK | an invocation omits an input the declared mode marks required | +| `CL103` | WARN | an invocation passes an input token the declared mode declares nowhere | +| `CL104` | BLOCK | two agent files share a frontmatter `name:` | + +CL100/CL102/CL103 skip an invocation whose target agent CL001 already flagged as unresolved -- +one problem, one message. CL104 is independent of the other four: it fires while the disk-derived +agent inventory is built, the same way CL900/CL901 fire while the suppression index is built, +rather than in a later Phase B pass. + +### CL2xx -- role and tool integrity + +Tool allowlists enforce agent roles *structurally* -- the reviewer has no write tool, so it +cannot auto-fix. That guarantee is only as good as the prompt text agreeing with the frontmatter. + +| Rule | Severity | Fires when | +|---|---|---| +| `CL200` | BLOCK | an agent with no write tool is instructed to write, append or create | +| `CL201` | BLOCK | an agent listed in `contractLint.readOnlyAgents` declares a write tool | +| `CL202` | WARN | an `mcp__*` name in scan scope is absent from `contractLint.knownMcpTools` | +| `CL203` | WARN | an agent's own frontmatter declares a tool its own body never mentions | + +A **write tool** is exactly `Write`, `Edit` or `MultiEdit` -- never `Bash`, which technically can +write a file but is a different, harder problem, deliberately out of scope here. + +`CL200` and `CL201` answer two different questions and read two different sources. `CL200` reads +**disk only**: any agent whose own `tools:` line lacks a write tool is a candidate, full stop, so a +brand-new read-only agent is protected on day one even if nobody remembers to list it anywhere. +`CL201` reads the **declared promise**: `contractLint.readOnlyAgents` names agents architecturally +committed to staying read-only, and `CL201` is the only rule that fires when one of them grows a +write tool in its own frontmatter -- the same declared-vs-disk shape `CL304` already uses for +conditional gates. + +`CL200`'s imperative-verb scan is line-initial only (after an optional bullet or numbered-step +marker), which is what lets a negated instruction ("Do not attempt to write files"), a third-person +subject ("The calling command appends your output") and a mid-sentence use ("write `` `_No +findings._` ``, after a comma) all pass untouched, with no exclusion list -- the same shape +`Get-GateClassification`/`classify_heading` already uses for `## Gate activity`. It is the only +rule in this band that reads prose intent rather than pure structure, which is why it ships WARN +first: **CL200 promoted to BLOCK on 2026-07-31, once the engine tree ran clean under both +implementations.** + +`CL203` searches an agent's own **body** -- everything after its closing `---` -- for the declared +tool's exact name. A tool mentioned only inside the `tools:` line itself (its own declaration) does +not count as "used." + +### CL3xx -- gate integrity + +A **gate block** runs from its heading to the next heading of any level, or end of file. That +window is why the roughly twenty literal `STOP`s in Phase 0 bootstrap error paths never satisfy or +trip a gate rule -- they all sit under a `## Phase 0` heading. + +| Rule | Severity | Fires when | +|---|---|---| +| `CL300` | BLOCK | a gate block contains no literal `STOP` | +| `CL301` | BLOCK | a gate block offers no option set | +| `CL302` | BLOCK | the hard gate count on disk disagrees with `contractLint.gates..hard` | +| `CL303` | WARN | hard gate labels are not exactly `1..N` without duplicates | +| `CL304` | BLOCK | a conditional gate is on disk but undeclared, or declared and absent | +| `CL305` | BLOCK | a HARD gate lists an override token as a selectable option | +| `CL306` | BLOCK | a HARD gate's prose describes an escape hatch with no `contract-lint: allow CL306` comment nearby | + +An **option set** is a slash-separated parenthetical such as `(yes / revise / abort)`, or two or +more top-level `- ` bullets. `CL303` compares **sets, never file order**: `commands/bug.md` authors +`Gate 3a` before `Gate 3` and passes. + +`CL305` is scoped to the gate's **option set**, never its prose. An override is a *listed choice*, +not a *described consequence* -- `commands/release.md` may say "the user may override the version +at this gate" without tripping it, while `(yes / skip / abort)` at a HARD gate fires. + +`CL306` is the prose half CLAUDE.md rule 6 calls out by name ("describing one in prose is fine"): +it scans a HARD gate's remaining prose -- everything that is NOT the option-set parenthetical or a +backtick-led option bullet, since those are CL305's territory -- against +`contractLint.gateProseEscapeTokens`. It is deliberately a naive scanner, the same "reads prose +intent" tradeoff `CL200` already made: it fires on `commands/bug.md`'s logged insist-and-proceed +sentence and `commands/release.md`'s "may override the version" bullet until each is annotated with +its own `` comment. There is no separate per-gate +declared-exception surface -- the existing suppression-comment convention already covers "this +gate's escape hatch is intentional," the same way it covers `CL305` on `commands/perf.md`, so a +second mechanism saying the same thing was not worth adding. + +**`CL306` shipped WARN on 2026-07-30 and promoted to BLOCK on 2026-07-31, once the engine tree ran +clean under both implementations** -- the same rollout `CL200` used, for the same reason: it reads +prose intent, not pure structure. + +Gate classification needs no exclusion list. `Gate` followed by a lowercase word is never a gate, +which is what makes `## Gate activity` in `commands/status.md` invisible to all six rules. + +### CL4xx -- stack-agnostic prose + +CLAUDE.md rule 4 says commands, agents and skills carry no hardcoded stack command or language +assumption -- everything comes from the target project's `project-config.json` at runtime. This +band is regression-prevention for that rule: it does not reach beyond `contractLint.scanScope` +(never `CLAUDE.md`, `CONTRIBUTING.md` or `docs/`, which is exactly why their known sandbox paths +and frontmatter examples need no special-case handling). + +| Rule | Severity | Fires when | +|---|---|---| +| `CL400` | BLOCK | a stack command token (`contractLint.stackTokens.commands`) appears outside a `<>`, a fenced code block, or a `contract-lint: allow CL400` comment | +| `CL401` | WARN (permanent) | a language/framework name (`contractLint.stackTokens.languages`) appears in the same contexts | +| `CL402` | BLOCK | a hardcoded absolute filesystem path (a Windows drive letter, or a POSIX path with two or more segments) appears in scan scope | + +A vocabulary hit is **word-bounded**: the character immediately before and after the token must not +itself be alphanumeric or `_`, so `npmrc` never trips on `npm` and `Going`/`algorithm` never trip on +the language token `Go`. `CL401` stays WARN permanently, unlike `CL400` -- a language name in prose +is often legitimate (an enumerated multi-stack heuristic, or the stack-agnostic rule's own +"never hardcode X" illustration), while a literal stack **command** is closer to an actual +instruction and is worth eventually blocking. + +`CL402`'s absolute-path match requires a genuine word boundary immediately before the leading `/` +or drive letter (blank, backtick, quote, paren, or start of line) -- never another path or +placeholder character. Without that positive boundary, `~/.claude/hooks/sd/` and +`.specs//04-artifacts/` would both mint a phantom absolute path starting at their own interior +`/`, and `/sd:` would collide with the leading slash of every slash-command reference in the +engine (a `/prefix:name` command has no second `/`, so it never matches at all). + +**`CL400` shipped WARN on 2026-07-30 and promoted to BLOCK on 2026-07-31, once the engine tree ran +clean under both implementations**, the same rollout `CL200`/`CL306` used. `CL401` and `CL402` do +not follow this schedule: `CL401` is permanent WARN by design, and `CL402` shipped BLOCK +immediately since a hardcoded absolute path is a structural fact, not prose intent. + +### CL5xx -- file budgets + +Prompt files only ever grow, and a file that grows past the point where the model reliably reads +all of it fails quietly -- the instructions at the bottom just stop being followed, with no error +and no signal in a diff. This band makes that growth visible in review instead. + +| Rule | Severity | Fires when | +|---|---|---| +| `CL500` | WARN (permanent) | a file exceeds `contractLint.budgets.Bytes` for its scan-scope area | + +There is exactly one rule here on purpose. `CL500` stays WARN forever -- promoting it to BLOCK +would turn a judgement call (is this growth worth it?) into a build failure, and the judgement is +the point. The finding reports how far over budget the file is, in bytes, not a bare "over budget": +`file is 27510 bytes, 1532 over the 25978-byte budget`. + +**The byte count is normalized, never a raw disk read.** `contractLint.scanScope` is `text=auto` +(see `.gitattributes`), so identical content checks out as LF on a Linux CI runner and CRLF on a +native Windows checkout -- confirmed on this repo: `commands/spec.md` is 25979 bytes as a git blob +but 26521 bytes checked out on Windows, a 542-byte difference for the same content. A raw byte count +would make `CL500` disagree with itself between platforms. Instead, both implementations sum each +line's byte length (already available from the same per-line cache every other rule reads) plus one +separator per line boundary -- a measure that does not depend on which OS checked the file out. + +**Budgets are a ratchet, set at today's largest file per area**, so the repo passes clean on day one +and every later `CL500` hit is real growth, never a paragraph someone happened to write before the +rule existed. Raising a ceiling in `contractLint.budgets` is a real decision that belongs in a PR +description, never a reflex to a red CI run. + +### CL9xx -- suppression hygiene + +| Rule | Severity | Fires when | +|---|---|---| +| `CL900` | BLOCK | a suppression carries no usable reason | +| `CL901` | BLOCK | a suppression names a rule id absent from the registry | +| `CL902` | WARN | a suppression suppressed nothing | + +## Suppressions + +``` + +``` + +It applies to a finding of that rule, in that file, on the same line or the next one. Indexed only +inside `contractLint.scanScope` and only outside fenced code blocks, so this page and +`CONTRIBUTING.md` can show the syntax without minting a phantom suppression that then trips CL902. + +**A suppression can never suppress CL900, CL901 or CL902.** That exclusion is hardcoded in both +implementations rather than manifest-driven, because `` would +otherwise be a self-authorizing loophole. + +The reason is not decoration. CL900 rejects anything under ten non-separator characters, so +"`- x`" fails and the writer has to say why. + +## Manifest surface + +Everything configurable lives under one top-level `contractLint` key, so later waves nest inside it +and never touch `areas`, `derived` or `docClaims`. + +| Key | Purpose | +|---|---| +| `scanScope` | globs the linter reads. Deliberately `commands/`, `agents/`, `skills/` and nothing else | +| `installNamespaceSegment` | the `sd` in `templates/sd/...`, folded away before CL005 tests disk | +| `rules` | the registry: id, severity, wave, summary. The one source of severity | +| `gates` | per file: the declared hard count, the declared conditional labels, and the Check 7 quantity name | +| `specArtifacts` | the numbered artifact filenames CL008 accepts | +| `skillConsumers` | skills whose only consumers live outside scan scope, with the reason | +| `overrideOptionTokens` | the vocabulary CL305 treats as an escape hatch | +| `gateProseEscapeTokens` | the phrase vocabulary CL306 scans HARD gate prose for | +| `stackTokens.commands` / `.languages` | the CL400 / CL401 stack vocabulary | +| `readOnlyAgents` | agent names CL201 checks for a write tool gained since being declared read-only | +| `knownMcpTools` | the `mcp__*` allowlist CL202 checks scan-scope tokens against | +| `budgets.commandsBytes` / `.agentsBytes` / `.skillsBytes` | the per-area byte ceiling CL500 checks a file's normalized size against | + +`scanScope` is load-bearing. `CLAUDE.md` and `CONTRIBUTING.md` use `sd-test` as a sandbox path and +`docs/architecture.md` carries a `name: sd-debugger` frontmatter example, so widening the scope to +`docs/**` produces a wall of CL001 false positives on day one. + +### Why the manifest stores a gate count + +The manifest's own charter says it stores no counts, and `gates..hard` is a literal number. +The test that resolves it: + +> Can a script count it from disk with no judgement calls? +> **Yes** -- it is inventory, it belongs in `areas`, and it must be derived. +> **No** -- it is a declared design contract, and it belongs in `contractLint`. + +"How many command files exist" passes that test. "How many hard gates `/sd:feature` declares" does +not: nothing on disk is a second source for it, so a derived value would make CL302 compare disk +against itself and pass vacuously forever. The number's job is to make deleting a gate heading a +deliberate two-file edit that shows up in review. + +Feeding those counts into Check 7 as quantities gives `README <- manifest` there and +`manifest <- disk` here, hence transitively `README == disk`, with no gate parser duplicated into +`scripts/validate.*`. + +## Waves + +Wave 1 is what ships here. Later waves are pure additions: one registry entry, one function per +implementation, one fixture, one row in the tables above. + +| Wave | Band | Status | +|---|---|---| +| 1 | CL0xx reference resolution, CL3xx gate integrity, CL9xx suppression hygiene | shipped, BLOCK | +| 2 | CL1xx invocation contract (agent input declarations) | shipped, BLOCK+WARN | +| 3a | CL2xx role and tool integrity (CL200-CL203) | shipped, BLOCK (CL200 promoted from WARN; CL202/CL203 stay WARN) | +| 3b | CL4xx stack-agnostic prose, CL306 | shipped 2026-07-30 WARN, now BLOCK (CL400/CL306 promoted 2026-07-31; CL401 stays WARN) | +| 4 | CL5xx file budgets | shipped 2026-07-30, stays WARN | + +Four scope decisions were made deliberately and are recorded here so they read as decisions +rather than oversights: + +- **CL007 is still loose.** "Invoked by" means any mention of the agent name in a command body. + CL100-CL104 added a real invocation-token parser but did not fold it back into CL007 -- CL007 + answers "is this agent mentioned at all", CL1xx answers "does a specific mode match", and + merging them would make CL007 depend on the mode-selector convention (`TASK`/`WORKFLOW_TYPE`/ + `TASK_TYPE`) instead of a plain name match. +- **CL006 does not scan `hooks/`,** although `/sd:` references live there. That remains a future + scope extension. +- **CL1xx recognizes exactly three mode-selector keys** (`TASK`, `WORKFLOW_TYPE`, `TASK_TYPE`) -- + the ones actually on disk. An invocation that sets none of them (`sd-docs-writer`'s flat + `ADR_NUMBER`/`ADR_PATH`/... contract, which has no modes at all) is invisible to CL100-CL103 by + design, not by omission. +- **`knownMcpTools` is hand-maintained, not derived**, and that is a deliberate acceptance of + staleness risk: nothing on disk is a second source for which `mcp__*` tool names are real, since + that comes from runtime MCP server configuration this repo cannot see. `CL202` stays WARN + forever precisely because of this -- a stale allowlist must never be able to block CI. A `CL202` + hit means "update this list or explain why not," never "suppress and move on." +- **`CL401` stays WARN permanently**, unlike its `CL400`/`CL402`/`CL306` siblings -- a language or + framework name in prose is often legitimate (an enumerated multi-stack heuristic, or the + stack-agnostic rule's own "never hardcode X" illustration), so the same false-positive band that + makes `CL400`/`CL306` promotable makes `CL401` a permanent-WARN rule by design, the same + precedent `CL202` already set for `knownMcpTools`. +- **`CL400`/`CL401`/`CL402` never widen `scanScope`**, on purpose. `CLAUDE.md`, `CONTRIBUTING.md` + and `docs/architecture.md` all carry sandbox paths, stack names and a frontmatter example that + this band would otherwise light up on day one -- exactly the risk `scanScope`'s own comment + already documents for `CL0xx`. Staying inside `commands/`, `agents/`, `skills/` is what lets the + positive-boundary path regex and the word-bounded vocabulary match stay this simple. +- **`CL306` reuses the existing suppression comment instead of a new declared-exception surface.** + A `contractLint.gates..proseExceptions` key was considered (a per-gate list of allowed + escape-hatch labels) and rejected: it would say the same thing `` already says, just in a second place that could drift out of sync with the first. + +## Testing it + +`tests/contract-lint/` holds the fixture suite; see its README for the case map and for what to do +when adding a rule. The load-bearing assertions are the fixture sweep and the line-for-line +comparison of the two implementations on every case. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cd6dd48..f2ac187 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,6 +8,7 @@ Common issues and fixes. Skim the table of contents first; the fix you need is u - [Hooks not firing](#hooks-not-firing) - [Workflow issues](#workflow-issues) - [Spec issues](#spec-issues) +- [Porting issues](#porting-issues) - [Spec-gate blocking unexpectedly](#spec-gate-blocking-unexpectedly) - [Spec metrics log](#spec-metrics-log) - [MCP issues](#mcp-issues) @@ -223,6 +224,73 @@ The aging report flags specs in `in-progress` > 7 days and `draft` > 14 days (de --- +## Porting issues + +### spec-gate ignores my `PORT-` spec while it's in-progress + +**Cause**: the in-progress-spec scan in `spec-gate`, `prompt-router`, and `subagent-retro` matches +a hardcoded `(FEAT|BUG|REF|PERF|RCA)` prefix set; it does not read `spec.prefixes` from +`project-config.json`, so a `PORT-` row is invisible to it. `/sd:port` registers `port` in the +prompt-router keyword map so a prompt like "backport the order-intake endpoint" still routes to +the command, but the prefix-blindness itself is unchanged. + +**Fix**: set `hooks.specGate.mode: "warn"` for the duration of the port, or track the work under an +accompanying FEAT spec. The same cause explains why `prompt-router` injects no context for an +in-progress `PORT-` spec and why `subagent-retro` selects no `port`-scoped lessons. + +### The host build or lint now fails on files under `.specs//04-artifacts/source/` + +**Cause**: the frozen snapshot is a real subtree of the repo, so a build or lint step that globs +the whole repo root picks up donor files that were never meant to compile or lint against this +project's rules. + +**Fix**: add a `.specs/` exclusion to the host's own build/lint configuration. `/sd:port` Phase 2 +warns about this when no such exclusion is found, but deliberately does not edit that +configuration itself (out of scope). Alternatively, freeze with `--snapshot contract` when the +donor source does not need to sit on disk. + +### Coverage dropped the moment the snapshot was frozen + +**Cause**: the coverage tool counts the frozen snapshot files as uninstrumented source. + +**Fix**: exclude `.specs/` from `commands.coverage`, re-measure, and compare quality bars (e.g. +`quality.refactorCoverageThreshold`) against the corrected number. `/sd:port` never edits coverage +configuration itself, same as the build/lint case above. + +### `/sd:port` Gate 3 refuses: host production tree is dirty + +**Cause**: uncommitted or untracked changes under `paths.src` (or a declared `paths.layers` path) +make the Phase 8 parity diff unattributable - a hunk in the parity diff could be prior work, not +part of the port. + +**Fix**: commit or stash those changes under their own spec, then re-run the check. The gate has no +override; it re-runs `git diff --quiet` / `git status --porcelain` and refuses until both are +empty. + +### spec-gate blocks an edit under `04-artifacts/source/` + +**Cause**: `/sd:port` Phase 2 appended those exact paths, plus `MANIFEST.md`, to `paths.protected` +at freeze time - `spec-gate` matches by exact string, not glob. + +**Fix**: this is by design - the snapshot is frozen evidence. If the freeze itself was wrong, use +Gate 1's `re-capture` resolution, which removes exactly the entries it added. Note `spec-gate` +guards `Edit`/`Write` only - it does not stop a shell delete. + +### The port parity diff has to be produced by hand + +**Cause**: `sd-reviewer`'s `port-parity` mode reads a diff artifact it cannot produce - it has no +`Bash` and no write tool, deliberately, because the adjudicator must not be able to fix what it +judges. `/sd:port` Phase 8 now generates `04-artifacts/parity/` for you before invoking the +reviewer, so this only applies when adjudicating a port outside the `/sd:port` pipeline. + +**Fix**: generate `04-artifacts/parity/` yourself - one unified diff per non-`omit` path mapping +row (snapshot side first), an all-deletion diff for a row whose host file is absent, an +all-addition diff for a changeset file with no row, and an `INDEX.md` listing them all. The exact +layout is in `sd-port-fidelity`'s "Parity artifacts" section, and `examples/port-parity-fixture/` +is a worked pair whose shape you can copy. + +--- + ## Spec-gate blocking unexpectedly ### Editing a file that should be safe and getting `decision=block` diff --git a/docs/usage.md b/docs/usage.md index 6ab127f..8d61de5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -156,6 +156,51 @@ Example: /sd:perf search-endpoint-latency ``` +### `/sd:port --from --scope ` + +Fidelity-first port. **The donor is the specification - every departure is a row in the deviation +table, or it is a defect.** Reproduces a donor implementation inside this host repo, with a +justified-diff parity review as the close-out gate. + +| Phase | Subagent / Actor | Gate | +|---|---|---| +| 0 - Bootstrap (args, port policy) | main thread | - | +| 1 - Consume bridged contract / in-repo extract | main thread + `sd-code-explorer` (port-extract, in-repo only) + `sd-spec-architect` (create) | - | +| 2 - Freeze snapshot | main thread | Gate 1 (HARD) | +| 3 - Host survey | `sd-code-explorer` (impact-map) + main thread constitution scan | - | +| 4 - Fidelity tables | `sd-spec-architect` (refine) | Gate 2 (HARD) | +| 5 - Pin behavior | main thread + `sd-implementer` | Gate 3 (HARD) | +| 6 - Plan atomic tasks | `sd-spec-architect` (plan) | Gate 4 | +| 7 - Execute batched | `sd-implementer` per task | Gate 5 | +| 8 - Parity review | main thread + `sd-reviewer` (port-parity) | Gate 6 (HARD) | +| 9 - Close-out | main thread | - | + +**Spec ID**: `PORT--`. `--scope` (`endpoint` / `module` / `feature` / `pattern`) is +explicit - Phase 0 asks when it is omitted and never infers it, because a wrong guess changes the +behavior-pinning mechanism used at Gate 3. `--from` selects topology: a bridged (cross-repo) +contract artifact produced by `/sd:explore --port` in the donor repo, or an in-repo path/symbol for +intra-repo duplication - the rest of the pipeline is identical either way. + +Phase 5's pinning mechanism follows scope: `endpoint` gets a contract test suite runnable against +donor and host; `module` gets characterization tests through an interface-typed construction seam +so re-pointing changes exactly one factory method; `feature` uses whichever the surface allows; +`pattern` skips pinning (there is no donor instance) but still proves the host tree is unmodified. + +Phase 6's complexity triage uses a port-specific metric - the count of deviation-table rows +requiring adaptation, not the impacted-file count the other workflows use, because a port's file +count equals the donor's by construction and would trip on nearly every port regardless of how much +judgment the work needs. + +Phase 0 reads the port policy from a `Port policy` heading in `.specs/constitution.md` and always +states the effective policy in its output, including the fallback (structural mirror, per +`sd-port-fidelity`) when the host constitution declares nothing. + +Example: +``` +/sd:port order-intake --from .specs/_explorations/order-intake-20260809-1200/ --scope endpoint +/sd:port order-intake --from src/orders/order-intake --scope module +``` + --- ## Utility commands @@ -237,7 +282,7 @@ Already covered above. Re-runnable. Detects state (fresh / post-init / partial / Cut a release from completed specs. **No code is changed. No subagent is invoked.** Pure file ops, like `/sd:spec`. -Collects every spec currently in `done` (types feature / bug / refactor / perf - RCA is never released), groups them into Keep-a-Changelog sections, writes a versioned section to the project `CHANGELOG.md`, then transitions each released spec `done -> archived`. +Collects every spec currently in `done` (types feature / bug / refactor / perf / port - RCA is never released), groups them into Keep-a-Changelog sections, writes a versioned section to the project `CHANGELOG.md`, then transitions each released spec `done -> archived`. | Phase | Actor | Gate | |---|---|---| @@ -249,7 +294,7 @@ Collects every spec currently in `done` (types feature / bug / refactor / perf - | 5 - Write CHANGELOG + archive | main thread | - | | 6 - Report | main thread | - | -Type-to-section mapping: feature -> `Added`, bug -> `Fixed`, refactor + perf -> `Changed`. +Type-to-section mapping: feature + port -> `Added`, bug -> `Fixed`, refactor + perf -> `Changed`. Version is inferred when omitted (any feature -> minor bump, else patch; major is never auto-inferred - pass it explicitly). `--dry-run` renders the notes and the archive plan, then stops without writing. @@ -296,7 +341,7 @@ Examples: Proves criterion -> task -> test traceability for one spec and writes `.specs//06-verify.md` with `result: pass|fail`. The spec-gate hook blocks a FEAT (feature-spec) `index.md` row from transitioning to `done` without a passing artifact -(`hooks.specGate.verifyGate`, default on). Other spec types (bug, refactor, perf, rca) close +(`hooks.specGate.verifyGate`, default on). Other spec types (bug, refactor, perf, rca, port) close out through the unconditional protected-path rule, same as before this gate existed. /sd:verify FEAT-1042 @@ -306,6 +351,36 @@ progress check. A FAIL lists VF0xx findings with file:line citations. --- +## Porting from a donor repository + +A `PORT` spec captures reproducing a donor repo's behavior in this host repo: which donor, at +which commit, which members must land, where each path maps to, and which departures are +sanctioned. `/sd:port` (documented above) drives the full pipeline - bridge, freeze, survey, +fidelity tables, behavior pinning, plan, batched execute, justified-diff parity, close-out. + +**Cross-repo (bridged) topology** - when the donor lives in a different repository, capture its +side first: run `/sd:explore --port --scope [--snapshot +contract+source]` **in the donor repo** (a separate session - never load a host project's +`CLAUDE.md` or constitution alongside it). It writes a fixed-section contract, plus - under +`contract+source` - a `source/` bundle with `MANIFEST.md`, to the donor's own +`.specs/_explorations/`. Pass that bundle's path as `--from` to `/sd:port` in the host repo - moving +the bundle between the two repos is a manual or scripted step, not automated by either command. + +**Intra-repo topology** - when the donor and host are the same repository (duplicating a pattern +elsewhere in the codebase), pass the donor path or symbol directly as `--from`; `/sd:port` invokes +`sd-code-explorer`'s `port-extract` mode itself in Phase 1. + +**Known limitations**: +- `spec-gate`, `prompt-router`, and `subagent-retro` do not recognize the `PORT-` prefix (it is + hardcoded in the hook scripts) - a `PORT-` spec is invisible to in-progress-spec detection, + context injection, and lesson scoping. Workaround: set `hooks.specGate.mode: "warn"` for the + duration of the port, or track the work under an accompanying FEAT spec. +- `hooks.specGate.verifyGate` is FEAT-scoped, so a port's `done` close-out is not verify-gated by + the hook - `/sd:port` Phase 9 still runs `/sd:verify` itself and requires `result: pass` before + closing out. + +--- + ## Common patterns ### Picking the right workflow @@ -317,6 +392,7 @@ progress check. A FAIL lists VF0xx findings with file:line citations. | "Production incident; need a post-mortem with no code change" | `/sd:rca` | | "This file / module is too tangled; need to restructure" | `/sd:refactor` | | "X is too slow; need to optimize with measurements" | `/sd:perf` | +| "Reproduce a donor repo's behavior in this repo, faithfully" | `/sd:port` | | "I just want to navigate the code" | `/sd:explore` | | "Review this change for compliance" | `/sd:review` | | "Manage / browse the spec registry" | `/sd:spec` | @@ -327,7 +403,7 @@ progress check. A FAIL lists VF0xx findings with file:line citations. Workflow commands are **resumable**. Re-running `/sd:feature INV-2501` after closing your terminal mid-execution detects the current state of `.specs/FEAT-INV-2501/` and jumps to the next phase. The state machine is documented at the top of each command file. -The main heuristic: workflow checks for the presence and contents of `00-spec.md`, `05-retro.md`, and (feature/refactor only) `01-plan.md` and `02-tasks.md` (with task completion ratio) to determine where you are. +The main heuristic: workflow checks for the presence and contents of `00-spec.md`, `05-retro.md`, and (feature/refactor/port only) `01-plan.md` and `02-tasks.md` (with task completion ratio) to determine where you are. ### Linking specs @@ -342,6 +418,25 @@ When an RCA spawns fixes, link them so the registry knows: Now `/sd:spec show BUG-1310` reveals the parent RCA. +### Recording deferred work + +Every spec type now carries a `## Spawned specs` table - `Reserved ID | Type | Title | Owner` - +the same one the RCA has always had. Close-out in `/sd:feature`, `/sd:bug`, `/sd:refactor` and +`/sd:perf` prompts for it whenever the retro names something deferred. It is a prompt, not a gate: +an empty table is a legitimate answer. + +A reserved ID is a **placeholder, not a registry entry**. It does not go in `.specs/index.md` and +it does not go in `linked_specs` until the child spec actually exists: + +``` +# FEAT-INV-2501 closes with | BUG-1310 | bug | Guard the empty-prefix match | alice | +/sd:bug 1310 # now the directory exists +/sd:spec link BUG-1310 spawned-by FEAT-INV-2501 # now the link exists +``` + +`/sd:spec validate` raises `SL090` (🟡 SUGGEST, never a failure) on a `done` spec that talks about +follow-up work and leaves the table empty. + ### When a workflow can't make progress If you hit a hard gate that the system refuses to override (e.g. bug reproduction unavailable, perf baseline cannot be measured), the workflow surfaces options: diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 891804a..4f8c1dd 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -1,5 +1,10 @@ # End-to-end walkthrough +> **Looking for something you can actually run?** This walkthrough is illustrative prose, not a +> runnable project. For a real, runnable, non-.NET example you can clone and drive yourself - +> including a committed `.specs/` tree from an actual `/sd:feature` run - see +> [`../examples/fixture-project/`](../examples/fixture-project/). + This is a fictional but realistic example. The project is `inventory-service`, an ASP.NET Core 8 + PostgreSQL backend with JIRA tickets matching `^INV-\d+$`. The walkthrough covers: 1. First-time setup with `/sd:setup`. diff --git a/examples/README.md b/examples/README.md index 808000f..c1f21bb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,9 @@ # Examples -The primary worked example for specwright is the end-to-end walkthrough in: +For a **runnable** example you can clone and drive yourself, start with +[`fixture-project/`](fixture-project/) below. + +For a **narrated** end-to-end tour (illustrative, not runnable - a fictional .NET project), see: **[`../docs/walkthrough.md`](../docs/walkthrough.md)** @@ -17,9 +20,18 @@ It covers: ## What is here now +- [`fixture-project/`](fixture-project/) - a tiny, runnable, non-.NET (plain Node.js) example + project: pre-scaffolded `CLAUDE.md`, `.specs/constitution.md`, and `.claude/project-config.json`, + plus a committed `.specs/FEAT-todo-priority/` worked example from a real `/sd:feature` run. + Backs the SW-30 acceptance criteria - clone it, run `node --test`, and drive `/sd:feature` + against it directly. This is what proves stack-agnosticism rather than merely asserting it. - [`spec-lint-fixture/`](spec-lint-fixture/) - a clean `.specs/` tree and a seeded-broken one for exercising `/sd:spec validate`. Backs the SW-4 acceptance criterion. Run by hand, not in CI - the linter is a prompt, so no script can execute it; the fixture README explains the trade-off. +- [`port-parity-fixture/`](port-parity-fixture/) - matched clean and seeded-broken port trees over a + toolchain-free plain-text donor, for exercising `sd-reviewer`'s `port-parity` adjudication. Backs + the SW-40 acceptance criteria. Run by hand, not in CI, for the same reason `spec-lint-fixture` is: + the adjudicator is a prompt, so no script can execute it. --- @@ -28,7 +40,6 @@ It covers: Ideas under consideration, no committed timeline (see [`../ROADMAP.md`](../ROADMAP.md) for what is actually scheduled): -- `examples/fixture-projects/` - tiny example repos (Node, .NET, Python) with pre-populated `.specs/` for demoing. - `examples/transcripts/` - anonymized real-run transcripts showing prompt-router and spec-gate behavior. - `examples/templates-customized/` - reference customizations of `constitution.template.md` for common stacks (Clean Architecture for .NET, hexagonal for Node, etc.). diff --git a/examples/fixture-project/.claude/project-config.json b/examples/fixture-project/.claude/project-config.json new file mode 100644 index 0000000..18775ec --- /dev/null +++ b/examples/fixture-project/.claude/project-config.json @@ -0,0 +1,151 @@ +{ + "version": "1.0.0", + + "project": { + "name": "fixture-project", + "description": "Tiny in-memory todo-list library - specwright's runnable non-.NET example", + "owner": "specwright maintainers", + "repo": "https://github.com/Developzone/specwright" + }, + + "ticket": { + "system": "github", + "pattern": "^#[0-9]+$", + "baseUrl": "https://github.com/Developzone/specwright/issues", + "snapshot": { + "enabled": true, + "maxRelated": 5, + "maxConfluencePages": 3, + "_use": "Persist fetched ticket + related tickets + linked Confluence pages to .specs//04-artifacts/ticket/" + } + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + }, + + "commands": { + "build": "n/a - no compile step (plain JavaScript)", + "test": "npm test", + "lint": "n/a - no linter configured", + "coverage": "npm run coverage", + "run": "npm start" + }, + + "paths": { + "src": "src", + "tests": "tests", + "docs": "n/a - no separate docs folder", + "layers": [ + { "name": "domain", "path": "src/domain" }, + { "name": "application", "path": "src/application" }, + { "name": "infrastructure", "path": "src/infrastructure" } + ], + "_layers_use": "Ordered inside-out [{name, path}] (innermost first); path may be a glob. Backs constitution 1.1 dependency direction. Filled by /sd:setup scan; [] when undetectable.", + "protected": [ + ".specs/constitution.md", + ".specs/index.md", + "LICENSE" + ] + }, + + "workflow": { + "keywords": { + "bug": ["bug", "fix", "broken", "error", "crash", "regression", "defect"], + "feature": ["feature", "add", "implement", "new", "support"], + "refactor": ["refactor", "restructure", "clean up", "extract", "rename"], + "perf": ["perf", "performance", "slow", "optimize", "latency", "throughput"], + "rca": ["incident", "outage", "rca", "root cause", "post-mortem", "postmortem"] + }, + "gates": { + "_comment": "Descriptive only - no hook or command reads this block today; it documents each workflow's hard-gate sequence for humans/tooling that may consume it later.", + "feature": ["spec-approved", "plan-approved", "integration-review-pass"], + "bug": ["symptom-captured", "reproduction-confirmed", "root-cause-confirmed", "failing-test-written", "regression-pass"], + "rca": ["evidence-gathered", "hypotheses-enumerated", "root-cause-confirmed"], + "refactor": ["spec-approved", "coverage-threshold-met", "post-test-added", "plan-approved", "batch-tests-green", "holistic-review-pass"], + "perf": ["target-defined", "baseline-measured", "hotspot-identified", "hypothesis-selected", "correctness-verified", "keep-or-revert-decided", "regression-pass", "final-review-pass"] + } + }, + + "models": { + "main": "sonnet", + "subagent_default": "haiku", + "subagent_reasoning": "sonnet", + "subagent_execution": "haiku", + "_comment": "Portable aliases (sonnet/haiku) - resolved to the current recommended version by Claude Code at runtime. Agents also use these aliases in frontmatter." + }, + + "mcp": { + "atlassian": { + "enabled": false, + "_use": "Fetch JIRA tickets (+ related tickets and linked Confluence pages) in /sd:feature and /sd:bug" + }, + "context7": { + "enabled": false, + "_use": "Up-to-date library docs for sd-spec-architect and sd-implementer" + }, + "sequential-thinking": { + "enabled": false, + "_use": "Structured hypothesis enumeration in sd-debugger and sd-reviewer" + }, + "gitnexus": { + "enabled": false, + "_use": "Fast symbol search, callers, call graph for sd-code-explorer" + }, + "database": { + "enabled": false, + "_use": "Read-only schema and query-plan inspection in sd-debugger (SELECT/EXPLAIN only) - not applicable, this fixture has no database" + }, + "playwright": { + "enabled": false, + "_use": "E2E reproduction for /sd:bug - not applicable, this fixture has no UI/browser surface" + }, + "tavily": { + "enabled": false, + "_use": "Web search for error signatures / library issues in sd-debugger" + } + }, + + "hooks": { + "userPromptRouter": { + "enabled": true, + "_use": "Keyword routing + spec-context injection on every user prompt" + }, + "specGate": { + "enabled": true, + "mode": "warn", + "_modes": "block = refuse Edit/Write on code without in-progress spec; warn = log to stderr only; off = skip", + "_use": "Guard rail blocking code edits when no spec is in-progress", + "verifyGate": true, + "_verifyGate_use": "Scoped to FEAT- (feature-spec) rows only - other types (bug, refactor, perf, rca) have no /sd:verify integration yet. true: a FEAT- row may transition to done only when //06-verify.md records 'result: pass' (written by /sd:verify); non-FEAT rows and any false setting keep index.md fully protected as before SW-6." + }, + "subagentRetro": { + "enabled": true, + "retroStaleMinutes": 30, + "debounceMinutes": 10, + "_use": "Reminder to update stale retros after subagent runs", + "injectLessons": true, + "maxLessons": 3, + "_injectLessons_use": "Surface lessons from /_lessons/lessons.md when a subagent finishes work on an in-progress spec. Selection is by workflow type - a FEAT- spec pulls feature-scoped lessons - so there is no ranking. Emitted regardless of retro staleness and of debounceMinutes, which gate only the stale-retro reminder.", + "_maxLessons_use": "Cap on NEW lessons surfaced per subagent stop. Already-surfaced lessons are recorded per session, so a session converges to silence rather than repeating itself. Set to 0 to surface none without disabling the reminder." + }, + "metrics": { + "enabled": true, + "path": ".specs/_metrics/events.jsonl", + "maxSizeKb": 1024, + "_use": "Append-only, metadata-only event log (no code content, no file paths) consumed by the retro loop. Set enabled=false to opt out entirely.", + "_maxSizeKb_use": "Soft byte cap (KB) for events.jsonl. When the live file reaches the cap, the next write rolls it to events.jsonl.1 (single generation, previous roll overwritten) and starts a fresh log. Default 1024 (~1MB, a guess - not tuned against a measured corpus); absent key also means 1024, so pre-existing configs stay bounded. Set to 0 to disable rotation and let the log grow unbounded. Rotation is best-effort: any failure (locked file, read-only dir) is a silent no-op and the hook never stops appending. events.jsonl.1 is a grace buffer, not part of any read contract - a consumer reads the live file only." + } + } +} diff --git a/examples/fixture-project/.claude/settings.json b/examples/fixture-project/.claude/settings.json new file mode 100644 index 0000000..fa154c3 --- /dev/null +++ b/examples/fixture-project/.claude/settings.json @@ -0,0 +1,64 @@ +{ + "_comment_top": "Claude Code project settings - hook wiring for specwright. PowerShell version (Windows). For bash adaptation, see _bash_adaptation at bottom.", + + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File ${HOME}/.claude/hooks/sd/prompt-router.ps1", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File ${HOME}/.claude/hooks/sd/spec-gate.ps1", + "timeout": 5 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File ${HOME}/.claude/hooks/sd/subagent-retro.ps1", + "timeout": 3 + } + ] + } + ] + }, + + "_bash_adaptation": { + "_comment": "On macOS/Linux replace PowerShell command lines with bash invocations. Make hooks executable (chmod +x) and adjust file extension.", + "UserPromptSubmit_command_example": "bash ${HOME}/.claude/hooks/sd/prompt-router.sh", + "PreToolUse_command_example": "bash ${HOME}/.claude/hooks/sd/spec-gate.sh", + "SubagentStop_command_example": "bash ${HOME}/.claude/hooks/sd/subagent-retro.sh", + "_note_powershell_on_unix": "PowerShell 7+ is available on Unix; if installed, the PowerShell command above works as-is. Otherwise switch to the bash variants." + }, + + "_schema_notes": { + "_hook_output_format": "spec-gate emits dual-format JSON for forward and backward CLI compatibility.", + "_new_schema": "hookSpecificOutput.permissionDecision = 'deny' (Claude Code CLI >= schema v2)", + "_legacy_schema": "decision = 'block' (Claude Code CLI < schema v2)", + "_both_emitted": "Both fields are present in the same JSON object. The CLI reads whichever it understands; the other field is ignored harmlessly.", + "_example_block_output": "{\"decision\":\"block\",\"reason\":\"...\",\"hookSpecificOutput\":{\"permissionDecision\":\"deny\",\"reason\":\"...\"}}" + }, + + "_timeout_notes": { + "userPromptSubmit": "5s - inject routing hints and spec context", + "preToolUse": "5s - check spec-gate before code edits", + "subagentStop": "3s - debounced retro reminder" + } +} diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/00-spec.md b/examples/fixture-project/.specs/FEAT-todo-priority/00-spec.md new file mode 100644 index 0000000..56f5dc2 --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/00-spec.md @@ -0,0 +1,189 @@ +--- +id: FEAT-todo-priority +type: feature +status: done +jira: none +created: 2026-07-31 +complexity: M # 2 production layers (Domain + Application) plus tests and one scoped constitution example edit, ~5 files, ~6-7 tasks; Infrastructure untouched +linked_specs: [] +--- + +# Add a validated priority field to todos + +## Why + +A todo today carries no urgency signal at all - `{ id, title, done }` says what to do and whether +it is finished, but not whether it matters. Every caller that needs ordering has to keep that +information outside the library (a parallel map, a naming convention like `"[URGENT] ..."`, or a +wrapper object), which defeats the point of a shared todo entity and puts the urgency data outside +the reach of the library's own validation. Carrying a validated `priority` on the entity means one +source of truth, rejected at the boundary instead of silently accepted as a typo, and it costs one +optional argument at the only place a todo is born. + +## What + +### SC-1: Adding a todo with an explicit priority + +- **Given** a `TodoService` backed by an empty store +- **When** a caller adds a todo titled `"Buy milk"` with priority `high` +- **Then** the returned todo is `{ id, title: 'Buy milk', done: false, priority: 'high' }`, and the + same todo - carrying `priority: 'high'` - is what `listTodos()` subsequently returns + +### SC-2: Adding a todo without a priority defaults to medium + +- **Given** a `TodoService` backed by an empty store +- **When** a caller adds a todo titled `"Buy milk"` and supplies no priority at all +- **Then** the returned todo has `priority: 'medium'`, and every call shape that worked before this + change still works unchanged (no caller is forced to pass a priority) + +### SC-3: Rejecting a priority outside the allowed set + +- **Given** a `TodoService` backed by an empty store +- **When** a caller adds a todo with a priority that is not one of `low`, `medium`, `high` - for + example `'urgent'`, `'HIGH'`, `''`, `null`, or `42` +- **Then** an `InvalidPriorityError` is thrown from the Domain layer, no todo is stored, and + `listTodos()` still returns an empty list + +### SC-4: Completing a todo preserves its priority + +- **Given** a stored todo with `priority: 'high'` and `done: false` +- **When** the caller completes it by id +- **Then** the returned todo has `done: true` and still has `priority: 'high'`; the original object + is left untouched, as it is today + +### SC-5: An invalid title still fails first + +- **Given** a `TodoService` backed by an empty store +- **When** a caller adds a todo whose title is invalid (e.g. `' '`) *and* whose priority is also + invalid (e.g. `'urgent'`) +- **Then** an `InvalidTitleError` is thrown - the existing error contract for a bad title is not + changed or masked by the new validation + +## Success criteria + +- [x] AC-1: `createTodo({ id, title, priority })` in `src/domain/todo.js` returns + `{ id, title, done: false, priority }` for each of `'low'`, `'medium'`, `'high'`. + Evidence: `src/domain/todo.js:35-38`; `tests/todo.test.js:27-32` ("createTodo accepts each + allowed priority"). +- [x] AC-2: The default fires **only** on an absent or `undefined` priority - both + `createTodo({ id, title })` and an explicit `priority: undefined` yield `priority: 'medium'`. + Every other value outside the allowed set, including `null`, `''`, `'HIGH'`, `'urgent'` and + `42`, throws rather than defaulting. + Evidence: `src/domain/todo.js:35` (destructuring default, not `??`/`||`); + `tests/todo.test.js:34-46` ("defaults priority to medium when omitted or undefined", + "rejects a priority outside the allowed set"). +- [x] AC-3: `src/domain/todo.js` exports `validatePriority` and `InvalidPriorityError` (with + `name === 'InvalidPriorityError'`), mirroring the existing `validateTitle` / + `InvalidTitleError` pair, and `validatePriority` has at least one direct unit test. + Evidence: `src/domain/todo.js:8-13,29-33`; `tests/todo.test.js:52-58` (direct + accept/reject tests). +- [x] AC-4: A todo added through `TodoService` with a priority is stored and returned carrying that + priority; the four existing tests in `tests/todo-service.test.js` are **not edited** and still + pass. New tests for this feature are added to that same file (§2.4: test files mirror source + layout). + Evidence: `src/application/todo-service.js:17-21`; `tests/todo-service.test.js:37-42`; + reviewer P5 (line-offset reconciliation confirming the 4 existing tests are unedited, + `05-retro.md`). +- [x] AC-5: Completing a todo returns a todo whose `priority` equals the stored todo's priority + (`completeTodo` is not allowed to drop the field). + Evidence: `tests/todo-service.test.js:50-56` ("completeTodo preserves the priority of a + stored todo"). +- [x] AC-6: When both title and priority are invalid, `InvalidTitleError` is thrown - title + validation runs before priority validation. + Evidence: `src/domain/todo.js:36-37` (validateTitle before validatePriority); + `tests/todo.test.js:48-50` ("rejects an invalid title even when the priority is also + invalid"). +- [x] AC-7: Unit + integration tests cover all scenarios above - Domain unit tests for SC-1, SC-2, + SC-3 and SC-5, and at least one integration test through `TodoService` (SC-1, SC-2, SC-4) + covering the Domain/Application crossing. `npm test` passes. + Evidence: `npm test` - 18/18 pass (8 original + 6 Domain + 4 Application), confirmed after + every task and again in Phase 5a. +- [x] AC-8: No constitution exception beyond the single Gate-1-approved one recorded under + Constitution check (the §6 / mutation-protocol exception that authorizes AC-9). No other rule + is bent. `src/infrastructure/store.js` is unmodified - the store is shape-agnostic and needs + no change to carry the new field. + Evidence: reviewer P7 - no scope creep, `src/infrastructure/store.js` and `src/demo.js` + untouched, changeset is exactly the 5 declared files (`05-retro.md`). +- [x] AC-9: In `.specs/constitution.md`, the example shape in §1.1 "Cross-layer data" (line 23) and + in §7 "Aggregate root" (line 141) reads `{ id, title, done, priority }` instead of + `{ id, title, done }`. Exactly those two literals change: no rule text is reworded, no other + occurrence is touched, and the file's `version`, `last_reviewed` and §8 Changelog table are + deliberately left alone (this is an example refresh, not a constitution amendment). + Evidence: `.specs/constitution.md:23,141`; reviewer P4 (verified exact lines, `:31`/`:143` + byte-identical, frontmatter/§8 untouched). + +## Out of scope + +- Changing a todo's priority after creation - no `setPriority` / update path in this iteration. + Priority is set once, when the todo is added. +- Sorting or filtering `listTodos()` by priority. The list order stays insertion order. +- Case-insensitive input, aliases, or numeric levels (`'HIGH'`, `'urgent'`, `1`/`2`/`3`). The + accepted set is exactly the three lowercase strings; anything else is an error, not a hint. +- Exporting the priority vocabulary as a public constant. The allowed set stays module-private, + mirroring the private `MAX_TITLE_LENGTH` (`src/domain/todo.js:8`); tests assert against literals, + as the existing tests already do. +- Updating `src/demo.js` to display priority. The demo keeps its current output. +- Id-sequence gaps on rejected input. `TodoService.addTodo` calls `nextId()` before validation, so a + rejected add already consumes an id today (`InvalidTitleError` behaves the same way). Priority + validation inherits that behavior; changing it is a separate concern per §6 (the approved §6 + exception under Constitution check covers the AC-9 example refresh only, nothing else). +- The two *other* stale `{ id, title, done }` occurrences in `.specs/constitution.md`: §1.2 + "Mapping" (line 31) and the **DTO** bullet of §7 (line 143). The Gate 1 approval named exactly two + literals - §1.1 "Cross-layer data" and §7 "Aggregate root" (AC-9) - so these two stay as they are. + Note that the §7 DTO bullet sits in the same section as an approved one; if the user wants it + included, widening AC-9 by one line at Gate 2 is enough, and no other part of this spec moves. + +## Open questions + +None - both questions were resolved by the user at Gate 1. Kept here as a decision record: + +- **Resolved (Gate 1) - how the service accepts the priority.** Positional: + `addTodo(title, priority)`, confirming the spec's stated default. It is a single scalar, it matches + this fixture's deliberately minimal style, and it leaves `addTodo('Buy milk')` working untouched. + The options-object alternative (`addTodo(title, { priority })`) was considered and declined; both + keep existing call sites valid, so this was a style/longevity call, not a compatibility one. +- **Resolved (Gate 1) - the constitution's example shapes are refreshed as part of this spec.** Not + deferred to a separate ADR, which was the spec's original default. The refresh is now in scope and + checkable as AC-9, and the exception that permits it is recorded under Constitution check. Its + boundary is narrow on purpose: two example literals (§1.1 "Cross-layer data", §7 "Aggregate root"), + no rule text, no other occurrence - see Out of scope for the two occurrences left untouched. + +## Constitution check + +- **§1.1 Layer rules**: respected. Validation and the entity shape change stay in Domain + (`src/domain/todo.js`); Application (`src/application/todo-service.js`) only passes the caller's + value through to `createTodo` and keeps receiving its store by constructor injection. Nothing new + is imported in either direction, and Infrastructure is untouched - `save`/`get`/`list`/`update` + are shape-agnostic. The cross-layer payload stays a plain object literal, gaining one string + field: `{ id, title, done, priority }`. +- **§2.3 Error handling**: one new custom error class, `InvalidPriorityError` (Domain), introduced + as a sibling of the existing `InvalidTitleError` - one class per failure mode, no bare + `throw new Error(...)` for this expected failure, and no catch-and-swallow anywhere on the path. + `InvalidTitleError` and `TodoNotFoundError` are reused as-is; SC-5 exists specifically to prove + the existing title contract is not masked. +- **§3 Quality bars**: this change crosses the Domain/Application boundary, so an integration test + through `TodoService` is **mandatory, not discretionary** (AC-7). The new exported Domain function + `validatePriority` needs its own direct test to satisfy "every exported Domain/Application + function has >= 1 passing test" (AC-3); the modified `createTodo`, `completeTodo` and + `TodoService.addTodo` keep their existing tests and gain priority assertions. +- **§1.2 Pattern rules (Validation)**: `validatePriority` lives in Domain alongside `validateTitle` - + never in Infrastructure and never inline in a test file. The Application layer performs no + validation of its own; it forwards the value and lets Domain reject it. +- **§6 Forbidden patterns - one explicitly approved exception**: this spec carries a small, scoped + exception to §6's "no opportunistic refactor inside a feature/bug spec" caution. At Gate 1 the user + approved refreshing exactly the two stale example shapes - §1.1 "Cross-layer data" and §7 + "Aggregate root" - from `{ id, title, done }` to `{ id, title, done, priority }` as part of this + spec's work (AC-9). The exception is bounded three ways, and all three matter downstream: + - **Scope**: the literal example text only. Not a rule change, not a reworded bullet, not a + broader constitution edit, and not a `version` / `last_reviewed` / §8 Changelog amendment. + - **Mutation protocol** (constitution line 14: changes require a `/sd:refactor` spec or an ADR - + "Never edit silently"): a feature spec is not on that list, so the user's express Gate 1 + approval is what authorizes this edit. It is recorded here precisely so it is not silent. + - **Protected path**: `.specs/constitution.md` is listed in `paths.protected` in + `project-config.json`. The same Gate 1 approval covers this specific protected-path edit; the + Phase 4 implementer should treat AC-9 as pre-authorized rather than stalling on the guard. +- **Risk of violation**: **low**. The change is architecturally clean - Domain owns the validation, + Application only forwards, Infrastructure is untouched. The one deviation is documentary and + deliberate: a single narrowly-scoped example refresh in the constitution, made under express Gate 1 + authorization and recorded above, rather than an opportunistic edit. No constitution gap identified + - the existing rules cover this change cleanly. diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/01-plan.md b/examples/fixture-project/.specs/FEAT-todo-priority/01-plan.md new file mode 100644 index 0000000..90a25f9 --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/01-plan.md @@ -0,0 +1,171 @@ +# Implementation plan - FEAT-todo-priority + +Spec: `.specs/FEAT-todo-priority/00-spec.md` (status `approved`, `complexity: M`) +Impact: `.specs/FEAT-todo-priority/03-decisions.md` +Tasks: `.specs/FEAT-todo-priority/02-tasks.md` + +All paths in this file and in `02-tasks.md` are relative to `examples/fixture-project/`. +Test command is `npm test` (`commands.test`, `.claude/project-config.json:41`). + +--- + +## Approach + +The feature adds one optional string field to the todo entity. The whole change is: a validator +plus its error class in Domain, one defaulted destructuring parameter on `createTodo`, one +forwarded positional parameter on `TodoService.addTodo`, tests on both sides of the +Domain/Application boundary, and a two-literal example refresh in the constitution (AC-9). + +Nothing else moves. `src/infrastructure/store.js` is shape-agnostic +(`03-decisions.md:82-84`: `save`/`update` key only on `todo.id`), and `completeTodo` is +`{ ...todo, done: true }` (`src/domain/todo.js:25`), a spread that already carries any field +present on the input. `src/demo.js` is out of scope per the spec. + +### Alternatives considered + +- **Options object on the service** (`addTodo(title, { priority })`) - declined by the user at + Gate 1 in favour of positional `addTodo(title, priority)`. Recorded under Open questions in + `00-spec.md:119-123`; the plan implements the decision, it does not revisit it. +- **Defaulting inside `validatePriority`** (validator returns the effective value) - rejected. See + Design decision D1 below; it breaks AC-3's "mirroring the existing `validateTitle` / + `InvalidTitleError` pair", because `validateTitle` returns nothing. +- **Defaulting in the Application layer** (`addTodo(title, priority = 'medium')`) - rejected. + Constitution §1.2 puts validation - and with it the priority vocabulary - in Domain. The + Application layer must not know the string `'medium'`. See D2. +- **Exporting the priority vocabulary as a public constant** - out of scope per `00-spec.md:101-103`; + the module-private `MAX_TITLE_LENGTH` (`src/domain/todo.js:8`) is the precedent for keeping it + unexported. + +--- + +## Design decisions + +These three are the implementation-time choices the ACs actually discriminate between. They are +pinned here and restated as task acceptance criteria so the Phase 4 implementer does not re-derive +them. + +### D1 - The default lives on `createTodo`, never inside `validatePriority` + +`validatePriority(priority)` is **void-throwing**: it returns nothing for each of `'low'`, +`'medium'`, `'high'` and throws `InvalidPriorityError` for everything else, **including +`undefined`**. `createTodo` applies the default in its destructuring parameter list before calling +the validator. + +Two ACs force this shape together: + +- AC-3 requires `validatePriority` to mirror `validateTitle`, and `validateTitle` + (`src/domain/todo.js:10-17`) returns nothing and only throws. A validator that returned an + effective value would not mirror it. +- AC-2 requires the default to fire on absent-or-`undefined` only, and to throw on `null`, `''`, + `'HIGH'`, `'urgent'`, `42`. A JavaScript destructuring default has exactly that semantics: it + fires on `undefined` and on `undefined` alone. + +The concrete consequence for T02: the default must be written as a destructuring default +(`{ id, title, priority = DEFAULT_PRIORITY }`). Writing `priority ?? 'medium'` or +`priority || 'medium'` inside the body would silently swallow `null` (and, for `||`, `''`) and +break AC-2. This is Risk R1. + +### D2 - The Application layer forwards, it does not default and does not validate + +`TodoService.addTodo(title, priority)` passes `priority` straight into +`createTodo({ id, title, priority })`. When the caller omits the argument, `priority` is `undefined` +inside `addTodo`, the value reaches `createTodo` as `undefined`, and D1's destructuring default +produces `'medium'` in Domain. That is how SC-2 is satisfied without any Application-layer knowledge +of the vocabulary (§1.2, and `00-spec.md:148-150`). + +After T04, `src/application/todo-service.js` must contain no priority string literal at all. + +### D3 - Vocabulary and default stay module-private in Domain + +Two unexported constants in `src/domain/todo.js`, mirroring `MAX_TITLE_LENGTH` +(`src/domain/todo.js:8`): the allowed set and the default value. Tests assert against string +literals, exactly as the existing tests do (`tests/todo.test.js:6-9`). + +--- + +## Phased overview + +| Phase | Tasks | What lands | +|---|---|---| +| Foundation | T01 | `InvalidPriorityError`, `validatePriority`, the two private constants. No caller yet. | +| Behavior | T02, T03 | `createTodo` gains the defaulted, validated field; Domain unit tests prove SC-1, SC-2, SC-3, SC-5. | +| Wiring | T04, T05 | `TodoService.addTodo` forwards the value; integration tests prove the Domain/Application crossing. | +| Polish | T06 | The two constitution example literals (AC-9), and the scoped-diff check that backs AC-8. | + +## Sequencing rationale + +- **T01 before T02** - `createTodo` cannot call a validator that does not exist yet. Both tasks edit + `src/domain/todo.js`; they are strictly serialized by that `Depends on` edge, so the shared file is + never touched by two unsequenced tasks. Feature mode runs tasks sequentially and has no + `Parallel batch` field, so no conflict edge is declared. +- **T03 after T02** - the Domain tests assert the defaulting and the ordering, which only exist once + T02 lands. T03 is also the first point where SC-5/AC-6 (title error wins) becomes checkable. +- **T04 after T03** - the Application forward technically needs only T02's new parameter, but T03 is + sequenced first so the Domain layer is fully green before the boundary is crossed. That is the + cheaper failure ordering: a Domain bug found at T03 costs one file to fix, the same bug found at + T05 costs a boundary bisect. T04 therefore declares `Depends on: T02, T03`. +- **T05 after T04** - integration tests need the new service signature. +- **T06 last** - AC-8 is a whole-changeset assertion ("no other rule bent", `store.js` unmodified), + so its diff check is only meaningful once every code task has landed. It is also the only task on + a protected path; keeping it last means the protected-path edit happens after all reversible work + is proven green. + +**Critical path**: T01 -> T02 -> T04 -> T05. T03 hangs off T02 and T06 off the whole set; neither is +on the critical path for a working feature, but both are required for spec completion (T03 owns +AC-3's direct-test requirement, T06 owns AC-9). + +--- + +## Coverage map (every SC and AC has an owning task) + +| Spec ID | Owning task(s) | Note | +|---|---|---| +| SC-1 | T02, T03, T04, T05 | Production change in T02 + T04, proven by T03 (unit) and T05 (integration). | +| SC-2 | T02, T03, T04, T05 | Default path, both layers. | +| SC-3 | T02, T03, T05 | T05 owns the service-level "nothing stored" half. | +| SC-4 | T05 | Test-only, no production task - see below. | +| SC-5 | T02, T03 | Domain-level ordering proof. | +| AC-1 | T02, T03 | | +| AC-2 | T02, T03 | The discriminating criterion; D1. | +| AC-3 | T01, T03 | T01 creates the pair, T03 gives `validatePriority` its direct test. | +| AC-4 | T04, T05 | T05 is append-only: the four existing tests are not edited. | +| AC-5 | T05 | Test-only, no production task - see below. | +| AC-6 | T02, T03 | | +| AC-7 | T03, T05 | Both halves of "unit + integration"; `npm test` green. | +| AC-8 | T06 | Whole-changeset diff assertion, checkable only once last. | +| AC-9 | T06 | | + +**SC-4 and AC-5 have no production task, deliberately.** `completeTodo` is +`{ ...todo, done: true }` (`src/domain/todo.js:25`) and `TodoService.completeTodo` passes the stored +object straight through (`src/application/todo-service.js:23-29`), so the field already survives +completion with zero code change - confirmed as mechanically low-risk in `03-decisions.md:76-81`. +An AC proven by test alone is legitimate; an AC with no owner is not, which is why both are pinned +to T05 rather than left implicit. + +--- + +## Risks + +| # | Risk | Mitigation | +|---|---|---| +| R1 | `??`/`\|\|` used instead of a destructuring default in `createTodo`, silently defaulting `null` (and `''`) and breaking AC-2. This is the single most likely way this feature ships wrong. | D1 is restated verbatim in T02's Acceptance, and T03 asserts `null`, `''`, `'HIGH'`, `'urgent'`, `42` all throw - the `null` case fails loudly under `??`. | +| R2 | Validation order inverted, so an invalid title + invalid priority throws `InvalidPriorityError` and breaks the existing title contract (AC-6). | T02's Acceptance fixes the order (`validateTitle` first); T03 owns the SC-5 test that fails if it is inverted. | +| R3 | Defaulting or validating duplicated into `src/application/todo-service.js`, violating §1.2. | D2, plus T04's Acceptance: after the task, `todo-service.js` contains no priority string literal. | +| R4 | AC-9 edits `.specs/constitution.md`, listed in `paths.protected` (`.claude/project-config.json:57-61`). | Pre-authorized by the Gate 1 exception recorded in `00-spec.md:151-163`; the implementer treats it as approved rather than stalling. Note `hooks.specGate.mode` is `"warn"` in this fixture (`.claude/project-config.json:128`), so the guard logs to stderr and does not block - there is no override to request. | +| R5 | AC-9 scope creep onto the two out-of-scope occurrences (`.specs/constitution.md:31` §1.2 Mapping, `:143` §7 DTO), which are one line and one section away from the targets. | T06's Acceptance is a diff assertion: exactly two changed lines, and `:31`/`:143` byte-identical. In-place literal replacement keeps every other line number stable. | +| R6 | Existing tests edited to accommodate the new field, violating AC-4 and destroying the regression signal. | Both test tasks are append-only, stated in their Acceptance; the 8 existing tests (4 + 4) must pass unedited. They do: every existing assertion is field-wise, and none reads an exhaustive object shape. | +| R7 | `InvalidPriorityError` re-exported from `src/application/todo-service.js` so the integration test can import it, leaking a Domain symbol through Application. | T05 imports it directly from `../src/domain/todo.js`, exactly as `tests/todo-service.test.js:4` already imports `InMemoryStore` from Infrastructure. Stated in T05's Acceptance. | + +--- + +## Complexity self-assessment (measured at plan time) + +| Threshold | Measured | Over? | +|---|---|---| +| Tasks > 8 | 6 (T01-T06) | no | +| Production layers > 2 (distinct `Layer`, excluding `Tests`/`Config`) | 2 - Domain, Application | no | +| Impact surface > 8 files | 5 - `src/domain/todo.js`, `src/application/todo-service.js`, `tests/todo.test.js`, `tests/todo-service.test.js`, `.specs/constitution.md` | no | +| Any unresolved Open question | 0 - both resolved at Gate 1 (`00-spec.md:117-128`) | no | + +**Under threshold on all four.** The create-time estimate `complexity: M` holds. Gate 2 is normal +plan approval; no decompose proposal. diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/02-tasks.md b/examples/fixture-project/.specs/FEAT-todo-priority/02-tasks.md new file mode 100644 index 0000000..adbf8ff --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/02-tasks.md @@ -0,0 +1,234 @@ +# Tasks - FEAT-todo-priority + +Spec: `.specs/FEAT-todo-priority/00-spec.md` | Plan: `.specs/FEAT-todo-priority/01-plan.md` + +All paths are relative to `examples/fixture-project/`. Test command: `npm test` +(`commands.test`, `.claude/project-config.json:41`). Design decisions D1-D3 referenced below live +in `01-plan.md`. + +--- + +## Phase 1 - Foundation + +### T01 - Add InvalidPriorityError and validatePriority to the Domain + +- **Files**: src/domain/todo.js +- **Layer**: Domain +- **Step type**: foundation +- **Test**: tests/todo.test.js (existing 4 tests must stay green; new tests land in T03) +- **Acceptance**: + - `src/domain/todo.js` exports `class InvalidPriorityError extends Error` whose constructor sets + `this.name = 'InvalidPriorityError'` and builds its message from the offending value, exactly as + `InvalidTitleError` does. + - `src/domain/todo.js` exports `function validatePriority(priority)` that is **void-throwing** + (D1): it returns nothing for each of `'low'`, `'medium'`, `'high'`, and throws + `InvalidPriorityError` for every other value **including `undefined` and `null`**. It must not + apply, return, or know about the default - `createTodo` owns that (T02). + - Two **unexported** module-private constants are added, mirroring `MAX_TITLE_LENGTH` + (`src/domain/todo.js:8`) in both placement and SCREAMING_SNAKE naming: + `ALLOWED_PRIORITIES` (the set `'low'`, `'medium'`, `'high'`) and `DEFAULT_PRIORITY` + (`'medium'`). Use those exact names - T02 references them. Neither appears in an `export` + statement (D3). + - `createTodo` and `completeTodo` are left unchanged in this task. + - Observable check 1: `npm test` passes with all 8 existing tests unedited. + - Observable check 2, run from `examples/fixture-project/`: + `node --input-type=module -e "import { validatePriority, InvalidPriorityError } from './src/domain/todo.js'; validatePriority('high'); try { validatePriority('urgent'); } catch (e) { console.log(e.name, e instanceof InvalidPriorityError); }"` + prints `InvalidPriorityError true` and exits 0 (the `validatePriority('high')` call must not + throw). If this one-liner cannot run for tooling reasons (shell quoting, or Node `--eval` + relative-specifier resolution), accept T01 on inspection instead - both symbols present in + `export` statements, neither constant exported, `npm test` green - and do **not** spend time + debugging the one-liner. +- **Covers**: AC-3 +- **Depends on**: none +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: + - `src/domain/todo.js:1-6` - `InvalidTitleError`: mirror this class shape exactly (extends `Error`, + `super(...)` with the offending value via `JSON.stringify`, `this.name` assigned in the + constructor). Place the new class next to it. + - `src/domain/todo.js:8` - `MAX_TITLE_LENGTH`: mirror this as the precedent for a module-private, + unexported constant backing a validation rule. Do not export the new constants. + - `src/domain/todo.js:10-17` - `validateTitle`: mirror the validator contract - takes the raw + value, throws its own error class, returns nothing. Do not return a value. + + +--- + +## Phase 2 - Behavior + +### T02 - Accept, default and validate priority in createTodo + +- **Files**: src/domain/todo.js +- **Layer**: Domain +- **Step type**: behavior +- **Test**: tests/todo.test.js +- **Acceptance**: + - `createTodo({ id, title, priority })` returns `{ id, title, done: false, priority }` and returns + the given value for each of `'low'`, `'medium'`, `'high'` (AC-1). + - The default is applied as a **destructuring default** in the parameter list - + `{ id, title, priority = DEFAULT_PRIORITY }` (D1). `??` and `||` are forbidden + here: both would default `null` (and `||` would also default `''`), which AC-2 requires to + throw. Both `createTodo({ id, title })` and `createTodo({ id, title, priority: undefined })` + yield `priority: 'medium'`; `null`, `''`, `'HIGH'`, `'urgent'` and `42` all throw + `InvalidPriorityError` (AC-2). + - Call order inside the body is `validateTitle(title)` **first**, then + `validatePriority(priority)`, so an invalid title beats an invalid priority (AC-6). + - `completeTodo` is not modified - its spread already carries the new field + (`03-decisions.md:76-81`). + - `npm test` passes with all 8 existing tests unedited. +- **Covers**: SC-1, SC-2, SC-3, SC-5, AC-1, AC-2, AC-6 +- **Depends on**: T01 +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: + - `src/domain/todo.js:19-22` - the current `createTodo`: mirror its validate-then-construct + shape; the new validator call is appended after `validateTitle`, and the returned object keeps + its plain-literal form with `priority` added as the last field. + + +### T03 - Add Domain unit tests for the priority field + +- **Files**: tests/todo.test.js +- **Layer**: Tests +- **Step type**: test +- **Test**: tests/todo.test.js +- **Acceptance**: + - Tests are **appended**; the four existing tests (`tests/todo.test.js:5,12,16,23`) are not + edited, reordered or deleted. + - New tests cover: `createTodo` returns the given priority for each of `'low'`, `'medium'`, + `'high'` (SC-1/AC-1); `createTodo({ id, title })` and an explicit `priority: undefined` both + yield `'medium'` (SC-2/AC-2); each of `'urgent'`, `'HIGH'`, `''`, `null`, `42` throws + `InvalidPriorityError` via `assert.throws` (SC-3/AC-2); an invalid title with an + also-invalid priority throws `InvalidTitleError` (SC-5/AC-6); and at least one **direct** call to + `validatePriority` - one accepting case and one throwing case (AC-3). + - `InvalidPriorityError` and `validatePriority` are added to the existing import statement at + `tests/todo.test.js:3` rather than a second import of the same module. + - Assertions use string literals (`'high'`), never an imported vocabulary constant - the + vocabulary stays module-private (D3). + - `npm test` passes; total test count increases and no existing test is reported as failing. +- **Covers**: SC-1, SC-2, SC-3, SC-5, AC-1, AC-2, AC-3, AC-6, AC-7 +- **Depends on**: T02 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: trivial +- **Pattern refs**: + - `tests/todo.test.js:5-25` - mirror the file's test style: `test('', () => { + ... })`, one behavior per test, `assert.equal` for values and + `assert.throws(() => ..., ErrorClass)` for failures. + - `tests/todo.test.js:1-3` - mirror the import header (`node:test`, `node:assert/strict`, one + import from `../src/domain/todo.js`); extend the third line, do not add a fourth import. + + +--- + +## Phase 3 - Wiring + +### T04 - Forward the priority through TodoService.addTodo + +- **Files**: src/application/todo-service.js +- **Layer**: Application +- **Step type**: wiring +- **Test**: tests/todo-service.test.js +- **Acceptance**: + - `addTodo(title, priority)` takes the priority as a **second positional parameter** (the Gate 1 + decision, `00-spec.md:119-123`) and passes it straight into + `createTodo({ id, title, priority })`. + - The Application layer neither defaults nor validates (D2): after this task + `src/application/todo-service.js` contains **no priority string literal** - no `'medium'`, no + `= 'medium'` parameter default, no membership check. When the caller omits the argument, + `priority` is `undefined` and Domain's destructuring default produces `'medium'`. + - `addTodo('Buy milk')` still works with one argument; the `nextId()` -> `createTodo` -> `save` + sequence and the constructor-injected `#store` are unchanged. + - No new import is added to this file, and no Domain error class is re-exported from it (R7). + - `npm test` passes with the four existing tests in `tests/todo-service.test.js` unedited. +- **Covers**: SC-1, SC-2, AC-4 +- **Depends on**: T02, T03 +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: + - `src/application/todo-service.js:17-21` - the current `addTodo`: mirror its exact three-step + body (`nextId` -> `createTodo` -> `store.save`); only the signature and the `createTodo` + argument object change. + - `src/domain/todo.js:19` - the `createTodo` signature this call must satisfy after T02: pass a + plain object literal with `priority`, never a positional third argument. + + +### T05 - Add integration tests through TodoService + +- **Files**: tests/todo-service.test.js +- **Layer**: Tests +- **Step type**: test +- **Test**: tests/todo-service.test.js +- **Acceptance**: + - Tests are **appended**; the four existing tests (`tests/todo-service.test.js:10,18,25,30`) are + not edited, reordered or deleted (AC-4). + - New tests cover the Domain/Application crossing (AC-7): `addTodo('Buy milk', 'high')` returns a + todo with `priority: 'high'` and `listTodos()` returns that same priority (SC-1); + `addTodo('Buy milk')` yields `priority: 'medium'` (SC-2); completing a stored `'high'` todo + returns `done: true` with `priority: 'high'` still present (SC-4/AC-5); and + `addTodo('Buy milk', 'urgent')` throws `InvalidPriorityError` with + `service.listTodos().length === 0` afterwards (SC-3). + - The rejected-add test asserts **only** that nothing was stored + (`listTodos().length === 0`). It must **not** assert anything about the id sequence: `addTodo` + calls `nextId()` before validation, so a rejected add already consumes an id today, and that + behavior is explicitly out of scope (`00-spec.md:105-108`). + - `InvalidPriorityError` is imported **directly** from `../src/domain/todo.js` in this test file - + the same way `InMemoryStore` is already imported from Infrastructure at + `tests/todo-service.test.js:4`. Do not re-export it from `src/application/todo-service.js` (R7). + - New tests reuse the existing `makeService()` helper; no test constructs `new TodoService(...)` + inline. + - `npm test` passes (AC-7). +- **Covers**: SC-1, SC-2, SC-3, SC-4, AC-4, AC-5, AC-7 +- **Depends on**: T04 +- **Conflicts with**: none +- **Estimated complexity**: M +- **Reversibility**: trivial +- **Pattern refs**: + - `tests/todo-service.test.js:6-16` - mirror the `makeService()` + arrange/act/assert shape and + reuse the helper; do not duplicate it. + - `tests/todo-service.test.js:25-28` - mirror this error-path test's use of + `assert.throws(() => service...., ErrorClass)` for the rejected-priority case. + - `tests/todo-service.test.js:3-4` - mirror the import header; add the Domain error import as a + separate line alongside the existing Infrastructure import. + + +--- + +## Phase 4 - Polish + +### T06 - Refresh the two constitution example shapes + +- **Files**: .specs/constitution.md +- **Layer**: Config +- **Step type**: polish +- **Test**: none - documentation edit; verified by diff inspection, plus `npm test` unchanged +- **Acceptance**: + - `.specs/constitution.md:23` (§1.1 "Cross-layer data") and `.specs/constitution.md:141` + (§7 "Aggregate root") read `{ id, title, done, priority }` instead of `{ id, title, done }` + (AC-9). + - The edit is an **in-place literal replacement**: `git diff -- .specs/constitution.md` shows + exactly **two** changed lines and no others. No rule text is reworded, no line is added or + removed, and the file's `version`, `last_reviewed` and §8 Changelog table are untouched - this + is an example refresh, not a constitution amendment. + - `.specs/constitution.md:31` (§1.2 "Mapping") and `.specs/constitution.md:143` (§7 "DTO") are + **byte-identical** to their pre-task state - they are explicitly out of scope + (`00-spec.md:109-113`). In-place replacement keeps all line numbers stable, so both can be + checked at those exact lines after the edit. + - `git status` at this point shows exactly five modified files: `src/domain/todo.js`, + `src/application/todo-service.js`, `tests/todo.test.js`, `tests/todo-service.test.js`, + `.specs/constitution.md`. `src/infrastructure/store.js` and `src/demo.js` are unmodified (AC-8). + - `npm test` still passes (this task changes no code). + - `.specs/constitution.md` is in `paths.protected` + (`.claude/project-config.json:57-61`); this specific edit is **pre-authorized** by the Gate 1 + exception recorded at `00-spec.md:151-163`. `hooks.specGate.mode` is `"warn"` in this fixture, + so the guard logs and does not block - proceed, do not stall or request an override. +- **Covers**: AC-8, AC-9 +- **Depends on**: T01, T02, T03, T04, T05 +- **Conflicts with**: none +- **Estimated complexity**: S +- **Reversibility**: trivial +- **Pattern refs**: none + diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/03-decisions.md b/examples/fixture-project/.specs/FEAT-todo-priority/03-decisions.md new file mode 100644 index 0000000..e67f5bf --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/03-decisions.md @@ -0,0 +1,138 @@ +# Impact analysis - FEAT-todo-priority + +(Produced by `sd-code-explorer`, `TASK = impact-map`, against `examples/fixture-project/`. All +paths below are relative to `examples/fixture-project/`.) + +GitNexus is disabled (`mcp.gitnexus.enabled: false` at `.claude/project-config.json:103-106`), so +this is a grep/Read-only analysis. No dynamic-dispatch call sites were observed in this fixture - +all calls are static ES-module imports - so the call graph below is exhaustive at this depth. + +## Direct callers (1-hop) + +- `src/application/todo-service.js:1` - `import { createTodo, completeTodo }` -> `src/domain/todo.js` +- `src/application/todo-service.js:19` - `TodoService.addTodo` calls `createTodo({ id, title })` -> `src/domain/todo.js:19` +- `src/application/todo-service.js:28` - `TodoService.completeTodo` calls `completeTodo(todo)` -> `src/domain/todo.js:25` +- `tests/todo.test.js:3` - imports `createTodo, completeTodo, validateTitle, InvalidTitleError` -> `src/domain/todo.js` +- `tests/todo.test.js:6,13,17,18,24` - direct unit calls into `src/domain/todo.js` exports +- `tests/todo-service.test.js:3` - imports `TodoService, TodoNotFoundError` -> `src/application/todo-service.js` +- `tests/todo-service.test.js:12,20,21,27,32,33` - direct calls into `TodoService.addTodo` / `.completeTodo` +- `src/demo.js:1` - imports `TodoService` -> `src/application/todo-service.js` +- `src/demo.js:6,7,10,12,13` - calls `service.addTodo`, `service.completeTodo`, `service.listTodos` + +## Transitive callers (2-3 hop) + +- `src/demo.js:6` -> `TodoService.addTodo` (`src/application/todo-service.js:17`) -> `createTodo` (`src/domain/todo.js:19`) -> `validateTitle` (`src/domain/todo.js:10`) +- `src/demo.js:10` -> `TodoService.completeTodo` (`src/application/todo-service.js:23`) -> `completeTodo` (`src/domain/todo.js:25`) +- `tests/todo-service.test.js:12` -> `TodoService.addTodo` -> `createTodo` -> `validateTitle` +- `tests/todo-service.test.js:20-21` -> `TodoService.completeTodo` -> `completeTodo` -> spread of stored todo + +Full call graph is exhausted at this depth - the entire `.js` file set in this fixture is +`src/infrastructure/store.js`, `src/demo.js`, `src/domain/todo.js`, `src/application/todo-service.js`, +`tests/todo.test.js`, `tests/todo-service.test.js` (confirmed via glob of `**/*.js`; no other +consumer exists anywhere in the fixture). + +## Test coverage scan + +- `src/domain/todo.js` -> `tests/todo.test.js` (4 existing tests: `tests/todo.test.js:5,12,16,23`) +- `src/application/todo-service.js` -> `tests/todo-service.test.js` (4 existing tests: + `tests/todo-service.test.js:10,18,25,30` - matches AC-4's claim of "four existing tests") +- `src/infrastructure/store.js` - no direct test file found (gap; out of target scope per spec + AC-8, listed here only for completeness - store is unmodified by this spec) +- `src/demo.js` - no direct test file (`.specs/constitution.md:32` explicitly exempts it as a CLI + demo entry point, not library code; out of target scope) + +## DI / config grep + +- No DI container in this fixture. Constructor injection: `TodoService` constructor takes `store` + at `src/application/todo-service.js:13`; wired at `src/demo.js:4` + (`new TodoService(new InMemoryStore())`) and at `tests/todo-service.test.js:7` + (`makeService()` helper). +- Config keys referencing target scope: + - `.claude/project-config.json:51-55` - `paths.layers` declares `domain` -> `src/domain`, + `application` -> `src/application`, `infrastructure` -> `src/infrastructure` (backs the + dependency-direction rule the spec cites in Constitution check). + - `.claude/project-config.json:57-61` - `paths.protected` lists `.specs/constitution.md` - + load-bearing for AC-9, since the spec's Constitution-check section (`00-spec.md:161-163`) + relies on this exact list to justify the protected-path edit. + +## Public API surface + +- `src/domain/todo.js:1` - `export class InvalidTitleError extends Error` +- `src/domain/todo.js:10` - `export function validateTitle(title)` +- `src/domain/todo.js:19` - `export function createTodo({ id, title })` (target of AC-1/AC-2 destructuring change) +- `src/domain/todo.js:25` - `export function completeTodo(todo)` (spread-based, see Risk note below) +- `src/domain/todo.js:8` - `const MAX_TITLE_LENGTH = 200;` - module-private, not exported (precedent + for spec's "priority vocabulary stays module-private" out-of-scope item, `00-spec.md:101-103`) +- `src/application/todo-service.js:3` - `export class TodoNotFoundError extends Error` +- `src/application/todo-service.js:10` - `export class TodoService` with public methods + `addTodo(title)` (`:17`), `completeTodo(id)` (`:23`), `listTodos()` (`:31`) +- Consumers external to target scope (`src/domain/`, `src/application/`): `src/demo.js:1,6,7,10,12`, + `tests/todo.test.js:3`, `tests/todo-service.test.js:3` + +## Risk assessment + +**Low risk, mechanically confirmable:** + +- AC-5 (completing a todo preserves priority) - `completeTodo` at `src/domain/todo.js:25` is + `{ ...todo, done: true }`, a spread that already carries through any field present on the input + object, including a future `priority`. `TodoService.completeTodo` + (`src/application/todo-service.js:23-29`) passes the stored object straight into `completeTodo` + without touching its shape. Both call sites have existing tests (`tests/todo.test.js:16`, + `tests/todo-service.test.js:18`). +- AC-8 ("store is shape-agnostic, needs no change") - `src/infrastructure/store.js:9-12` (`save`) + and `:22-28` (`update`) key exclusively on `todo.id`; neither method reads or writes any other + field. `get`/`list` (`:14-20`) likewise pass whole objects through untouched. + +**Medium risk, requires implementation care:** + +- AC-2's discriminating constraint: `createTodo({ id, title })` at `src/domain/todo.js:19` must + default on absent-or-`undefined` priority but throw on `null`, `''`, `'HIGH'`, `42`. This + constrains how the new parameter is destructured/defaulted at that call site; no signature is + prescribed here. +- AC-6 (title validation must run before priority validation) - ordering constraint inside + `createTodo` (`src/domain/todo.js:19-22`), currently a two-line body (`validateTitle(title)` + then object construction); adding a second validation call changes execution order, which + SC-5/AC-6 explicitly test for. + +**Out of scope, noted only, no action implied:** `src/infrastructure/store.js:24` throws a bare +`new Error(...)` for "Cannot update unknown todo" - this is a pre-existing bare-`Error` throw that +constitution `§2.3`/`§6` forbid for expected failures (`.specs/constitution.md:49-50,133`). It +predates this spec, is not touched by AC-1..AC-9, and is flagged here only as an existing +condition in a file this spec's Constitution check asserts is "unmodified" (`00-spec.md:86`). + +## Precedents & conventions + +- Nearest similar implementations (precedent for the new `validatePriority` / `InvalidPriorityError` pair): + - `src/domain/todo.js:1-17` - the `InvalidTitleError` class + `validateTitle` function + module-private + `MAX_TITLE_LENGTH` constant is the direct structural precedent: one error class per failure mode, + a validator function that throws that class, a module-private constant backing the validation rule. + - `src/application/todo-service.js:3-8` - `TodoNotFoundError` is the second instance of the + "one custom error class per failure mode" pattern, confirming it is a repo-wide convention and + not a one-off in `todo.js`. +- Conventions observed (sampling is degenerate here - `src/domain/` and `src/application/` each + contain exactly one file, so "sampling 3 siblings" reduces to reading that one file each; stated + explicitly rather than inferred from more examples): + - File naming: one exported concept per file, filename mirrors the export + (`todo-service.js` -> `TodoService`) - evidence: `src/application/todo-service.js:10`. + - Symbol naming: error classes suffixed `Error` and set `this.name` in the constructor - + evidence: `src/domain/todo.js:1-6`, `src/application/todo-service.js:3-8`. + - Test placement: `src/domain/todo.js` -> `tests/todo.test.js`; `src/application/todo-service.js` + -> `tests/todo-service.test.js` - a 1:1 file mirror, matching `.specs/constitution.md:55` verbatim. +- Existing utilities relevant to spec scope: + - `src/domain/todo.js:8` - `MAX_TITLE_LENGTH` - direct precedent for keeping the priority + vocabulary (`'low'|'medium'|'high'`) as an unexported module-private constant, per the spec's + own out-of-scope item (`00-spec.md:101-103`). + +## Constitution line-number claims (verified, since AC-9/out-of-scope hinge on exact lines) + +- `.specs/constitution.md:23` - `§1.1 Cross-layer data` bullet reads `{ id, title, done }` - + confirmed, this is an AC-9 target. +- `.specs/constitution.md:141` - `§7 Aggregate root` bullet reads `{ id, title, done }` - + confirmed, this is an AC-9 target. +- `.specs/constitution.md:31` - `§1.2 Mapping` bullet reads `{ id, title, done }` - confirmed, + this is the "Out of scope" item, left untouched. +- `.specs/constitution.md:143` - `§7 DTO` bullet reads `{ id, title, done }` - confirmed, this is + the second "Out of scope" item, left untouched. +- `.claude/project-config.json:57-61` - `paths.protected` includes `.specs/constitution.md` - + confirmed, this is what makes AC-9 a protected-path edit requiring the Gate-1 exception the + spec records. diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/05-retro.md b/examples/fixture-project/.specs/FEAT-todo-priority/05-retro.md new file mode 100644 index 0000000..762fc7a --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/05-retro.md @@ -0,0 +1,85 @@ +# Retro - FEAT-todo-priority + +## Status log + +- [2026-07-31T15:30:00Z] Status: draft -> approved. Reason: Gate 1 spec approval. Both open + questions resolved by the user: addTodo(title, priority) confirmed positional; constitution + §1.1/§7 "Aggregate root" example refresh (AC-9) approved in-scope (kept to the two named + occurrences - §1.2 "Mapping" and the §7 "DTO" bullet stay as-is per the user's explicit choice). +- [2026-07-31T15:50:00Z] Status: approved -> in-progress. Reason: Gate 2 plan approval - 6 tasks + (T01-T06), complexity M, measured under threshold on all 4 decompose criteria (tasks, layers, + impact surface, open questions). Face A normal approval, no decompose proposal. + +## Task log + +- T01: pass - `InvalidPriorityError` + void-throwing `validatePriority` + private + `ALLOWED_PRIORITIES`/`DEFAULT_PRIORITY` added to `src/domain/todo.js`, mirroring + `InvalidTitleError`/`validateTitle`. `npm test` green, 8/8 existing tests unedited. +- T02: pass - `createTodo` gained the destructuring-defaulted, validated `priority` field per D1; + title validation still runs before priority validation (AC-6). `npm test` green, 8/8 existing + tests unedited (T02 adds no tests of its own). +- T03: pass - 6 new tests appended to `tests/todo.test.js` covering SC-1/2/3/5 and AC-1/2/3/6 + (allowed priorities, default-on-omitted/undefined, rejection of `'urgent'`/`'HIGH'`/`''`/`null`/ + `42`, title-before-priority ordering, direct `validatePriority` accept/reject). `npm test`: 14/14 + pass, original 8 unedited. +- T04: pass - `TodoService.addTodo(title, priority)` forwards to `createTodo` per D2 (no priority + literal, defaulting, or validation added in Application). `npm test`: 14/14 pass, unedited. +- T05: pass - 4 new tests appended to `tests/todo-service.test.js` covering SC-1/2/3/4 and + AC-4/5/7 (explicit priority through the service, default-to-medium, priority survives + `completeTodo`, invalid priority stores nothing - no id-sequence assertion, per scope). `npm + test`: 18/18 pass, original 8 (4+4) unedited. +- T06: pass - `.specs/constitution.md` §1.1 (line 23) and §7 "Aggregate root" (line 141) refreshed + from `{ id, title, done }` to `{ id, title, done, priority }`; §1.2 "Mapping" (line 31) and §7 + "DTO" (line 143) left byte-identical, per scope. `version`/`last_reviewed`/§8 untouched. `npm + test`: 18/18 pass (no code changed by this task). + +All 6 tasks (T01-T06) complete. All 18 tests pass. + +## Integration + batch review (Phase 5) + +- `npm test`: 18/18 pass. `commands.lint`: n/a (no linter configured for this fixture). +- `sd-reviewer` (holistic): **0 BLOCK, 0 WARN, 5 SUGGEST, 7 PASS.** + - S1: §7 DTO bullet (`.specs/constitution.md:143`) is now inconsistent with the refreshed + Aggregate-root bullet (`:141`) - the spec's own "widen AC-9 by one line" escape hatch expired + at Gate 2. Deferred to close-out for a user decision (follow-up spec, ADR, or accept as-is). + - S2: the constitution exception (AC-9) is recorded in this spec but has no ADR of its own, so + provenance will not outlive this spec's eventual archival. Suggested: a short ADR under + `.specs/_adr/`. + - S3: `validatePriority` uses `== false` where sibling code uses `!`/`!==` - likely intentional + (a global coding-standard convention), no action needed. + - S4: explicit-priority tests check `priority` but not the full returned shape alongside it - a + minor test-strengthening opportunity, not a coverage gap (the single unconditional return path + is already pinned by the default-path tests). + - S5: close-out prerequisites (`/sd:verify`, AC checkboxes) are not yet done - correctly Phase + 6's job, not a Phase 5 finding. + +## Close-out (Phase 6) + +- [2026-07-31T16:20:00Z] Status: in-progress -> done. Reason: Gate 3 passed clean (0 BLOCK, + 0 WARN); `/sd:verify FEAT-todo-priority` recorded `result: pass` (0 failures, all 5 SC and 9 AC + traced to a task and a passing test or diff inspection). + +**Tasks completed**: 6/6 (T01-T06). + +**Surprises encountered**: the constitution's stale `{ id, title, done }` example shape appeared +in 4 places, not the 2 the user initially named at Gate 1 (`03-decisions.md`'s impact analysis +surfaced this before planning). Resolved by asking the user explicitly whether to widen scope; +they chose to keep it to the 2 originally approved. The reviewer's S1 finding shows this decision +has a real downstream cost (§7 now internally inconsistent between its Aggregate-root and DTO +bullets) - a legitimate deferred follow-up, not a mistake. + +**Deferred follow-ups** (no spec ID reserved yet): +- S1/S2: the remaining 2 stale constitution occurrences (§1.2 Mapping, §7 DTO), and an ADR to + record the Gate 1 §6 exception so its provenance outlives this spec's eventual archival. +- S4: strengthen the two explicit-priority tests to assert the full returned shape + (`done: false` alongside `priority`), not just the priority field. +- Sorting/filtering by priority (already recorded as out of scope in `00-spec.md`). + +**Constitution exceptions taken**: one, explicitly approved at Gate 1 and recorded in `00-spec.md`'s +Constitution check section - a scoped 2-line example refresh in `.specs/constitution.md` (a +protected path), authorized by the user rather than done silently. + +**Cost note**: this spec was driven through the real `sd-spec-architect` / `sd-code-explorer` / +`sd-implementer` / `sd-reviewer` subagents (one `create`, one `refine`, one `impact-map`, one +`plan`, six `sd-implementer` task invocations, one `holistic` review - 10 subagent calls total), +not hand-authored. No token/dollar cost was tracked for this run. diff --git a/examples/fixture-project/.specs/FEAT-todo-priority/06-verify.md b/examples/fixture-project/.specs/FEAT-todo-priority/06-verify.md new file mode 100644 index 0000000..a746e39 --- /dev/null +++ b/examples/fixture-project/.specs/FEAT-todo-priority/06-verify.md @@ -0,0 +1,37 @@ +--- +spec: FEAT-todo-priority +result: pass +date: 2026-07-31 +failures: 0 +--- + +# Verification report - FEAT-todo-priority + +## Traceability + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| SC-1 | scenario | T02, T03, T04, T05 | tests/todo.test.js, tests/todo-service.test.js | PASS | +| SC-2 | scenario | T02, T03, T04, T05 | tests/todo.test.js, tests/todo-service.test.js | PASS | +| SC-3 | scenario | T02, T03, T05 | tests/todo.test.js, tests/todo-service.test.js | PASS | +| SC-4 | scenario | T05 | tests/todo-service.test.js | PASS | +| SC-5 | scenario | T02, T03 | tests/todo.test.js | PASS | +| AC-1 | criterion | T02, T03 | tests/todo.test.js | PASS | +| AC-2 | criterion | T02, T03 | tests/todo.test.js | PASS | +| AC-3 | criterion | T01, T03 | tests/todo.test.js | PASS | +| AC-4 | criterion | T04, T05 | tests/todo-service.test.js | PASS | +| AC-5 | criterion | T05 | tests/todo-service.test.js | PASS | +| AC-6 | criterion | T02, T03 | tests/todo.test.js | PASS | +| AC-7 | criterion | T03, T05 | tests/todo.test.js, tests/todo-service.test.js | PASS | +| AC-8 | criterion | T06 | diff inspection (.specs/constitution.md unchanged outside 2 lines; store.js/demo.js untouched) | PASS | +| AC-9 | criterion | T06 | diff inspection (.specs/constitution.md:23,141) | PASS | + +## Test run + +- Command: `npm test` +- Exit code: 0 +- Summary: 18 passed, 0 failed, 0 skipped (up from 8 at spec creation) + +## Findings + +none diff --git a/examples/fixture-project/.specs/_adr/.gitkeep b/examples/fixture-project/.specs/_adr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/fixture-project/.specs/_explorations/.gitkeep b/examples/fixture-project/.specs/_explorations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/fixture-project/.specs/_reviews/.gitkeep b/examples/fixture-project/.specs/_reviews/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/fixture-project/.specs/constitution.md b/examples/fixture-project/.specs/constitution.md new file mode 100644 index 0000000..9ce8075 --- /dev/null +++ b/examples/fixture-project/.specs/constitution.md @@ -0,0 +1,153 @@ +--- +version: 1.0.0 +project: fixture-project +maintainers: + - specwright maintainers +last_reviewed: 2026-07-31 +review_cadence: quarterly +--- + +# Constitution + +The constitution is the **single source of truth** for architectural and quality rules in this project. Subagents read this file at runtime; if a rule is not here, it is not enforced. + +**Mutation protocol:** changes require a `/sd:refactor` spec or an ADR under `.specs/_adr/`. Never edit silently. + +--- + +## §1. Architectural non-negotiables + +### §1.1. Layer rules + +- **Dependency direction**: Domain -> Application -> Infrastructure. A layer NEVER depends on an outer layer (Domain never imports Application or Infrastructure; Application never imports Infrastructure directly). +- **Cross-layer data**: only plain object literals (e.g. `{ id, title, done, priority }`). Never pass a storage-specific object (a `Map` entry, a driver row) into an inner layer. +- **Inversion**: Application defines the store contract implicitly by the methods it calls (`nextId`, `save`, `get`, `list`, `update`); Infrastructure implements that contract. The concrete store is injected via the `TodoService` constructor - Application never `import`s `InMemoryStore` itself (only test/wiring code does, e.g. `src/demo.js`). +- **Forbidden cross-cuts**: no file under `src/domain/` may import from `src/application/` or `src/infrastructure/`. + +### §1.2. Pattern rules + +- **CQRS**: not applicable - this library has no read/write path split; it is a single small object graph. +- **Validation**: input validation (e.g. `validateTitle`) lives in Domain, never in Infrastructure or inline in a test. +- **Mapping**: not applicable - one plain-object shape (`{ id, title, done }`) is used end-to-end; no DTO/entity translation layer exists at this size. +- **Logging**: none configured. If added, structured logging only - never a bare `console.log` in library code (the one exception is `src/demo.js`, which is a CLI demo entry point, not library code). + +--- + +## §2. Code conventions + +### §2.1. Language style + +- Plain modern JavaScript (ES2022+), ES modules (`"type": "module"`). No TypeScript, no build step. +- Private class fields (`#field`) for internal state (see `InMemoryStore`, `TodoService`); never a `_prefixed` convention for privacy. + +### §2.2. Async + +- Not applicable - every operation in this fixture is synchronous (in-memory store, no I/O). If a future change adds async I/O, every touched method becomes `async`/`await` end-to-end; no mixing of callbacks and promises. + +### §2.3. Error handling + +- Custom error classes per failure mode: `InvalidTitleError` (Domain), `TodoNotFoundError` (Application). Never a bare `throw new Error(...)` for an expected failure. +- Never catch-and-swallow. If a caller needs to react to a specific failure, it catches the named error class. + +### §2.4. Naming + +- One class or cohesive concept per file; filename mirrors the exported concept (`todo-service.js` exports `TodoService`). +- Test files mirror source layout under `tests/` (e.g. `src/domain/todo.js` -> `tests/todo.test.js`). + +--- + +## §3. Quality bars + +| Metric | Threshold | Enforcement | +|---|---|---| +| Test coverage | Every exported Domain/Application function has >= 1 passing test | `sd-reviewer` checks; `node --test --experimental-test-coverage` reports the number | +| Integration tests | Required for any change crossing the Domain/Application boundary | `sd-reviewer` checks | +| Mutation tests | Not used at this size | n/a | +| Build warnings | n/a - no build step | n/a | +| API response time | n/a - no network endpoint in this fixture | n/a | + +**Refactor prerequisite**: refactor is blocked if the touched file has no passing test. Write a characterization test first. + +**Performance prerequisite**: not applicable - this fixture has no measured hot path. + +--- + +## §4. Tech stack declared + +- **Language and runtime**: JavaScript (ES2022+) / Node.js 20+ +- **Web framework**: none +- **ORM**: none +- **Database**: none - in-memory `Map` only +- **Cache**: none +- **Messaging**: none +- **Frontend**: none +- **Container**: none +- **Observability**: none +- **CI/CD**: none dedicated to this fixture (exercised via the parent specwright repo's own `.github/workflows/`) + +Adding a stack element requires a constitution amendment (this section + glossary). + +--- + +## §5. Workflow rules + +### §5.1. Spec-driven + +- Every non-trivial change starts with a spec under `.specs//`. +- "Non-trivial" = more than one file edited OR any behavior change OR any cross-layer change. +- Typo fixes, dependency bumps without behavior change, and pure formatting are exempt. + +### §5.2. Lifecycle states + +``` +draft -> approved -> in-progress -> done -> archived + ^ + +-- revive (from archived) for follow-up +``` + +| State | Meaning | Who can transition | +|---|---|---| +| `draft` | Spec exists, not yet approved | Author | +| `approved` | Reviewed and ready to plan | User (explicit approval at Gate 1) | +| `in-progress` | Implementation underway | Auto on Phase 4 start | +| `done` | Closed; retro written; CI green | User (explicit approval at close gate) | +| `archived` | No active work | Auto after N days in `done` (configurable) | + +All transitions logged to the spec's `05-retro.md` with timestamp + reason. + +### §5.3. Gate discipline + +- Workflows **refuse** to proceed without explicit approval at hard gates. +- "Looks fine, go ahead" is acceptable approval. Silence is not. +- A skipped gate (override) is logged to retro and constitutes a constitution exception. + +--- + +## §6. Forbidden patterns + +- **Service locator** - resolve dependencies via constructor injection only. +- **Static singletons holding state** - configuration is fine; mutable state is not. +- **`// TODO` or `// HACK` in committed code** - either fix, ticket, or spec it. +- **Hardcoded secrets** - n/a today (no external services), but the rule stands if one is ever added. +- **Type-safety escapes** - n/a, this project has no `any`/`dynamic` equivalent (plain JS, no TS). +- **Catch-and-swallow** - `catch { }` or a catch that only logs, without re-throw, is forbidden. +- **Direct store access from outside Application** - a test or demo script may construct an `InMemoryStore` directly, but production code paths always go through `TodoService`. +- **Opportunistic refactor inside a feature/bug spec** - separate spec; one concern per workflow. + +--- + +## §7. Glossary + +- **Aggregate root**: the `todo` object itself (`{ id, title, done, priority }`) - this fixture has no nested entity graph. +- **Handler**: not applicable - no command/query dispatcher; `TodoService` methods are called directly. +- **DTO**: data transfer object; serializable, no behavior, used at layer boundaries. In this fixture the plain `{ id, title, done }` object doubles as both entity and DTO given its size. +- **Hot path**: not applicable - no throughput/latency-sensitive code in this fixture. +- **Characterization test**: test capturing **current** behavior (correct or not) before a refactor. + +--- + +## §8. Changelog + +| Version | Date | Change | Spec | +|---|---|---|---| +| 1.0.0 | 2026-07-31 | Initial constitution | - | diff --git a/examples/fixture-project/.specs/index.md b/examples/fixture-project/.specs/index.md new file mode 100644 index 0000000..715d816 --- /dev/null +++ b/examples/fixture-project/.specs/index.md @@ -0,0 +1,7 @@ +# Spec index + +Active specs (auto-updated by /sd:spec status transitions): + +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-todo-priority | feature | done | 2026-07-31 | Add a validated priority field to todos | diff --git a/examples/fixture-project/CLAUDE.md b/examples/fixture-project/CLAUDE.md new file mode 100644 index 0000000..bcb0d00 --- /dev/null +++ b/examples/fixture-project/CLAUDE.md @@ -0,0 +1,72 @@ +# fixture-project + +> Thin orchestrator. Heavy context lives in `.specs/constitution.md` and `.specs/index.md`. +> This file should stay short (~50 lines). If it grows, move detail into the constitution. + +## Read on demand + +- `.specs/constitution.md` - architectural rules, conventions, quality bars (read for any non-trivial change). +- `.specs/index.md` - active spec registry with lifecycle states (read at start of every workflow). +- `.claude/project-config.json` - machine-readable paths, commands, models, MCP servers. + +## Workflows (spec-driven) + +| Command | Use when | +|---|---| +| `/sd:feature ` | New behavior or non-trivial change. | +| `/sd:bug ` | Defect with reproduction; root-cause-first. | +| `/sd:rca ` | Incident analysis. **No code change.** Output is the spec. | +| `/sd:refactor ` | Restructure without behavior change. Requires test coverage. | +| `/sd:perf ` | Optimization. Requires measured baseline. | +| `/sd:spec ` | Spec registry management (list, show, status, link, ...). | +| `/sd:explore ` | Read-only code navigation. | +| `/sd:review ` | Standalone compliance review. | +| `/sd:setup` | Idempotent project scaffold. | +| `/sd:adr ` | Author an ADR from a spec's decisions. | + +## Stack + +- **Language**: JavaScript (ES2022+), Node.js 20+ +- **Framework**: none - plain library, no web/app framework +- **Database**: none - in-memory storage only +- **Container**: none + +## Commands (CLI) + +- **Build**: n/a - no compile step (plain JavaScript, no bundler/transpiler) +- **Test**: `npm test` (`node --test`) +- **Lint**: n/a - no linter configured +- **Run**: `npm start` (`node src/demo.js`) +- **Coverage**: `npm run coverage` (`node --test --experimental-test-coverage`) + +## Architecture + +- **Style**: Layered (inside-out), no framework +- **Layers**: Domain -> Application -> Infrastructure +- **Namespace / package convention**: plain ES modules, one class/concept per file, path mirrors layer (`src//.js`) + +## Code conventions (apply silently) + +- Domain functions are pure - no I/O, no imports from Application or Infrastructure. +- Application receives its store via constructor injection; never imports `InMemoryStore` directly outside tests/wiring (`src/demo.js`). +- Plain object shapes cross layer boundaries (e.g. `{ id, title, done }`); never a storage-specific object. +- Custom error classes for domain/application failures (e.g. `InvalidTitleError`, `TodoNotFoundError`); never a bare `throw new Error(...)` for an expected failure mode. + +## Forbidden patterns + +- Service locator / static singletons holding mutable state. +- `// TODO` or `// HACK` left in committed code. +- Hardcoded secrets or connection strings (n/a today - no external services - but the rule stands if one is ever added). +- Infrastructure imported directly by Domain, or by Application outside constructor injection. + +## Quality bars + +- **Test coverage**: every exported Domain and Application function has at least one passing test. +- **Integration test**: required for any change crossing the Domain/Application boundary (i.e. anything routed through `TodoService`). +- **Performance**: not a concern for this fixture - no hot path, no measured baseline required. + +## When ambiguous + +1. Constitution silent and no precedent? -> `/sd:explore` to find precedent in repo. +2. Still unclear? -> Spec it under "Open questions" and ask before implementing. +3. Two equally valid options? -> Pick the one that minimizes future churn; document the choice in `03-decisions.md`. diff --git a/examples/fixture-project/README.md b/examples/fixture-project/README.md new file mode 100644 index 0000000..d6b83ba --- /dev/null +++ b/examples/fixture-project/README.md @@ -0,0 +1,53 @@ +# fixture-project + +specwright's runnable non-.NET example: a tiny, dependency-free in-memory todo-list library +(plain JavaScript, Node.js built-in test runner). It exists to prove two things by demonstration +rather than assertion: + +1. `/sd:setup` and `/sd:feature` work the same way on a non-.NET stack as they do on .NET. +2. A committed `.specs/` tree, generated by real workflow runs, is a better worked example than a + fictional walkthrough (see [`../../docs/walkthrough.md`](../../docs/walkthrough.md) for that + narrative version). + +## Layout + +``` +src/domain/todo.js - pure validation + entity shape, no I/O, no imports from outer layers +src/application/todo-service.js - orchestrates Domain, receives its store via constructor injection +src/infrastructure/store.js - in-memory storage adapter +src/demo.js - tiny CLI demo wiring the three layers together +tests/ - one test file per source file, using node:test + node:assert/strict +``` + +`.claude/project-config.json` and `CLAUDE.md` were generated by `/sd:setup`; `.specs/constitution.md` +declares this project's real architectural rules (not a copy-pasted template). `.specs/FEAT-todo-priority/` +is a complete, real `/sd:feature` run - spec through verify - kept as the worked example. + +## Try it yourself + +```bash +node --test # run the test suite (zero dependencies, uses Node's built-in runner) +node src/demo.js # see the library in action +``` + +To run a new spec-driven change against this fixture: + +```bash +# from the specwright repo root, install the engine somewhere first if you haven't: +# ./install/install.sh --base-path ~/.claude (or install.ps1 -BasePath ...) +cd examples/fixture-project +claude +# then, inside the session: +# /sd:feature +``` + +`/sd:setup` will report "already set up" here since `CLAUDE.md`, `.specs/`, and `.claude/` are +already committed - that's intentional, so this fixture also serves as a reference for what a +filled-in Layer 2 looks like. + +## Read the worked example + +Start at [`.specs/FEAT-todo-priority/00-spec.md`](.specs/FEAT-todo-priority/00-spec.md) and follow +the numbered files through to [`06-verify.md`](.specs/FEAT-todo-priority/06-verify.md) - a small, +real, layer-crossing change (adding a validated `priority` field) taken from a one-line ask to a +closed-out, reviewed, tested spec. diff --git a/examples/fixture-project/package.json b/examples/fixture-project/package.json new file mode 100644 index 0000000..8d4d2ff --- /dev/null +++ b/examples/fixture-project/package.json @@ -0,0 +1,15 @@ +{ + "name": "fixture-project", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Tiny in-memory todo-list library - specwright's runnable non-.NET example.", + "scripts": { + "test": "node --test", + "coverage": "node --test --experimental-test-coverage", + "start": "node src/demo.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/examples/fixture-project/src/application/todo-service.js b/examples/fixture-project/src/application/todo-service.js new file mode 100644 index 0000000..4ed9a31 --- /dev/null +++ b/examples/fixture-project/src/application/todo-service.js @@ -0,0 +1,34 @@ +import { createTodo, completeTodo } from '../domain/todo.js'; + +export class TodoNotFoundError extends Error { + constructor(id) { + super(`Todo not found: ${id}`); + this.name = 'TodoNotFoundError'; + } +} + +export class TodoService { + #store; + + constructor(store) { + this.#store = store; + } + + addTodo(title, priority) { + const id = this.#store.nextId(); + const todo = createTodo({ id, title, priority }); + return this.#store.save(todo); + } + + completeTodo(id) { + const todo = this.#store.get(id); + if (!todo) { + throw new TodoNotFoundError(id); + } + return this.#store.update(completeTodo(todo)); + } + + listTodos() { + return this.#store.list(); + } +} diff --git a/examples/fixture-project/src/demo.js b/examples/fixture-project/src/demo.js new file mode 100644 index 0000000..a25d717 --- /dev/null +++ b/examples/fixture-project/src/demo.js @@ -0,0 +1,14 @@ +import { TodoService } from './application/todo-service.js'; +import { InMemoryStore } from './infrastructure/store.js'; + +const service = new TodoService(new InMemoryStore()); + +service.addTodo('Write the specwright example fixture'); +service.addTodo('Run /sd:feature end to end'); + +const [first] = service.listTodos(); +service.completeTodo(first.id); + +for (const todo of service.listTodos()) { + console.log(`[${todo.done ? 'x' : ' '}] ${todo.title}`); +} diff --git a/examples/fixture-project/src/domain/todo.js b/examples/fixture-project/src/domain/todo.js new file mode 100644 index 0000000..a6ef2bd --- /dev/null +++ b/examples/fixture-project/src/domain/todo.js @@ -0,0 +1,43 @@ +export class InvalidTitleError extends Error { + constructor(title) { + super(`Invalid todo title: ${JSON.stringify(title)}`); + this.name = 'InvalidTitleError'; + } +} + +export class InvalidPriorityError extends Error { + constructor(priority) { + super(`Invalid todo priority: ${JSON.stringify(priority)}`); + this.name = 'InvalidPriorityError'; + } +} + +const MAX_TITLE_LENGTH = 200; + +const ALLOWED_PRIORITIES = ['low', 'medium', 'high']; +const DEFAULT_PRIORITY = 'medium'; + +export function validateTitle(title) { + if (typeof title !== 'string' || title.trim().length === 0) { + throw new InvalidTitleError(title); + } + if (title.length > MAX_TITLE_LENGTH) { + throw new InvalidTitleError(title); + } +} + +export function validatePriority(priority) { + if (ALLOWED_PRIORITIES.includes(priority) == false) { + throw new InvalidPriorityError(priority); + } +} + +export function createTodo({ id, title, priority = DEFAULT_PRIORITY }) { + validateTitle(title); + validatePriority(priority); + return { id, title, done: false, priority }; +} + +export function completeTodo(todo) { + return { ...todo, done: true }; +} diff --git a/examples/fixture-project/src/infrastructure/store.js b/examples/fixture-project/src/infrastructure/store.js new file mode 100644 index 0000000..10b65b2 --- /dev/null +++ b/examples/fixture-project/src/infrastructure/store.js @@ -0,0 +1,29 @@ +export class InMemoryStore { + #todos = new Map(); + #nextId = 1; + + nextId() { + return String(this.#nextId++); + } + + save(todo) { + this.#todos.set(todo.id, todo); + return todo; + } + + get(id) { + return this.#todos.get(id); + } + + list() { + return [...this.#todos.values()]; + } + + update(todo) { + if (!this.#todos.has(todo.id)) { + throw new Error(`Cannot update unknown todo: ${todo.id}`); + } + this.#todos.set(todo.id, todo); + return todo; + } +} diff --git a/examples/fixture-project/tests/todo-service.test.js b/examples/fixture-project/tests/todo-service.test.js new file mode 100644 index 0000000..4a95475 --- /dev/null +++ b/examples/fixture-project/tests/todo-service.test.js @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { TodoService, TodoNotFoundError } from '../src/application/todo-service.js'; +import { InMemoryStore } from '../src/infrastructure/store.js'; +import { InvalidPriorityError } from '../src/domain/todo.js'; + +function makeService() { + return new TodoService(new InMemoryStore()); +} + +test('addTodo stores and returns a new todo', () => { + const service = makeService(); + const todo = service.addTodo('Buy milk'); + assert.equal(todo.title, 'Buy milk'); + assert.equal(todo.done, false); + assert.equal(service.listTodos().length, 1); +}); + +test('completeTodo marks an existing todo done', () => { + const service = makeService(); + const todo = service.addTodo('Buy milk'); + const completed = service.completeTodo(todo.id); + assert.equal(completed.done, true); +}); + +test('completeTodo throws for an unknown id', () => { + const service = makeService(); + assert.throws(() => service.completeTodo('missing'), TodoNotFoundError); +}); + +test('listTodos returns all added todos', () => { + const service = makeService(); + service.addTodo('One'); + service.addTodo('Two'); + assert.equal(service.listTodos().length, 2); +}); + +test('addTodo with an explicit priority stores and returns it', () => { + const service = makeService(); + const todo = service.addTodo('Buy milk', 'high'); + assert.equal(todo.priority, 'high'); + assert.equal(service.listTodos()[0].priority, 'high'); +}); + +test('addTodo without a priority defaults to medium', () => { + const service = makeService(); + const todo = service.addTodo('Buy milk'); + assert.equal(todo.priority, 'medium'); +}); + +test('completeTodo preserves the priority of a stored todo', () => { + const service = makeService(); + const todo = service.addTodo('Buy milk', 'high'); + const completed = service.completeTodo(todo.id); + assert.equal(completed.done, true); + assert.equal(completed.priority, 'high'); +}); + +test('addTodo with an invalid priority throws and stores nothing', () => { + const service = makeService(); + assert.throws(() => service.addTodo('Buy milk', 'urgent'), InvalidPriorityError); + assert.equal(service.listTodos().length, 0); +}); diff --git a/examples/fixture-project/tests/todo.test.js b/examples/fixture-project/tests/todo.test.js new file mode 100644 index 0000000..61e225f --- /dev/null +++ b/examples/fixture-project/tests/todo.test.js @@ -0,0 +1,65 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + createTodo, + completeTodo, + validateTitle, + InvalidTitleError, + validatePriority, + InvalidPriorityError, +} from '../src/domain/todo.js'; + +test('createTodo builds a todo with done=false', () => { + const todo = createTodo({ id: '1', title: 'Write tests' }); + assert.equal(todo.id, '1'); + assert.equal(todo.title, 'Write tests'); + assert.equal(todo.done, false); +}); + +test('createTodo rejects an empty title', () => { + assert.throws(() => createTodo({ id: '1', title: ' ' }), InvalidTitleError); +}); + +test('completeTodo returns a new object with done=true', () => { + const todo = createTodo({ id: '1', title: 'Write tests' }); + const completed = completeTodo(todo); + assert.equal(completed.done, true); + assert.equal(todo.done, false, 'original todo is left untouched'); +}); + +test('validateTitle rejects a non-string title', () => { + assert.throws(() => validateTitle(42), InvalidTitleError); +}); + +test('createTodo accepts each allowed priority', () => { + for (const priority of ['low', 'medium', 'high']) { + const todo = createTodo({ id: '1', title: 'Write tests', priority }); + assert.equal(todo.priority, priority); + } +}); + +test('createTodo defaults priority to medium when omitted or undefined', () => { + const withoutPriority = createTodo({ id: '1', title: 'Write tests' }); + assert.equal(withoutPriority.priority, 'medium'); + + const withUndefinedPriority = createTodo({ id: '1', title: 'Write tests', priority: undefined }); + assert.equal(withUndefinedPriority.priority, 'medium'); +}); + +test('createTodo rejects a priority outside the allowed set', () => { + for (const priority of ['urgent', 'HIGH', '', null, 42]) { + assert.throws(() => createTodo({ id: '1', title: 'Write tests', priority }), InvalidPriorityError); + } +}); + +test('createTodo rejects an invalid title even when the priority is also invalid', () => { + assert.throws(() => createTodo({ id: '1', title: ' ', priority: 'urgent' }), InvalidTitleError); +}); + +test('validatePriority does not throw for an allowed priority', () => { + assert.doesNotThrow(() => validatePriority('high')); +}); + +test('validatePriority rejects a priority outside the allowed set', () => { + assert.throws(() => validatePriority('urgent'), InvalidPriorityError); +}); diff --git a/examples/port-parity-fixture/README.md b/examples/port-parity-fixture/README.md new file mode 100644 index 0000000..c099f47 --- /dev/null +++ b/examples/port-parity-fixture/README.md @@ -0,0 +1,107 @@ +# Port parity fixture + +Two `.specs/` trees sharing one port spec's fidelity tables, for exercising `sd-reviewer`'s +`port-parity` mode, backing the SW-40 acceptance criteria: a seeded-broken host surfaces every +BLOCK class and both whole-artifact checks at the right severity; a clean host returns all-PASS and +a correctly justified deviation is never reported in either tree. + +| Tree | Expectation | +|---|---| +| [`clean/`](clean/) | `_No findings._`. Member completeness `5/5`. Path conformance PASS. Three `justified` hunks counted in the summary, none written up. | +| [`broken/`](broken/) | Every finding in the table below, at the stated severity, and nothing else. | + +Both trees carry the *same* `00-spec.md` fidelity tables (path mapping, member manifest, deviation +table) - `orders/order-intake.txt` (members `CreateOrder`, `ValidateTotals`, `ArchiveDraft`) and +`orders/order-notify.txt` (members `NotifyDispatch`, `NotifyFailure`), donor snapshot under +`04-artifacts/source/`. What differs is only the host under `src/orders/` and the parity diffs under +`04-artifacts/parity/` that were generated against it. + +--- + +## How to run it + +`sd-reviewer`'s `port-parity` mode is a prompt, not executable code, so no script can run it and CI +cannot gate on it - see "What this does not do" below. From a session rooted in one of the trees, +invoke the `sd-reviewer` subagent with: + +``` +TASK_TYPE = port-parity +SPEC_REF = .specs/PORT-order-intake-20260809/00-spec.md +DIFF_REF = .specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md +CHANGED_FILES = src/orders/order-intake.txt, src/orders/order-notify.txt (+ src/orders/order-audit.txt in broken/) +``` + +`clean/` must come back `_No findings._`, member completeness `5/5`, path conformance PASS, and a +summary line counting 3 `justified` hunks (D01, D02, D03) with none of them written up individually. + +`broken/` must come back with exactly the findings below and nothing else. + +--- + +## Expected findings in `broken/` + +| Case | Class or check | Severity | Seeded defect | +|---|---|---|---| +| 1 | `unjustified` | BLOCK | `CreateOrder` step 5 log text reads `"Order accepted for "`; no deviation row covers a log-text change | +| 2 | `missing` | BLOCK | `ArchiveDraft` (`orders/order-intake.txt`, ordinal 3) has no counterpart in `src/orders/order-intake.txt` | +| 3 | `overreached` | BLOCK | `D02` licenses only the rename `ValidateTotals` -> `CheckTotals`; the host hunk also swaps steps 2 and 3 | +| 4 | `overreached` | BLOCK | `D03` licenses only the rename `NotifyFailure` -> `HandleFailure`; the host hunk also swaps steps 1 and 2 | +| 5 | `extra` | BLOCK | `src/orders/order-notify.txt` gains a `NotifyEscalation` member with no donor counterpart and no deviation row | +| 6 | Member completeness | BLOCK | `4/5 members present` - `ArchiveDraft` named as the absent row | +| 7 | Path conformance | BLOCK | `src/orders/order-audit.txt` exists in `CHANGED_FILES` with no row in the path mapping table | + +Case 2 and case 6 are the same underlying gap (`ArchiveDraft` is absent) reported through two +different lenses on purpose: case 2 is the hunk-level `missing` classification against the specific +manifest row's diff, case 6 is the whole-artifact member-completeness count the skill requires +regardless of which hunks were classified. A parity review that reports one but not the other has +implemented only half the gate. + +### Why `D01`, `D02`, and `D03` are the interesting rows + +All three deviations are correctly cited and, for `D01`, correctly and completely applied - the +host hunk does exactly what `D01` licenses and nothing more. It must **not** appear in the findings +in either tree. `D02` and `D03` are each correctly applied in `clean/` (same control) but exceeded +in `broken/` (cases 3 and 4) - the same deviation ID demonstrating both the accept path and the +`overreached` path depending on host compliance, rather than needing a fourth, unrelated deviation +just to prove the negative. A run that reports `D01`, or that reports `D02`/`D03` as `unjustified` +instead of `overreached`, has regressed the gate's precision - flagging correctly-cited work is just +as damaging to the gate's usefulness as missing a real BLOCK, since a reviewer that cries wolf on +correct deviations trains its own users to stop trusting the report. + +## Why the seed markers live in the spec, not in the ported files + +`` comments appear only in `broken/.specs/PORT-order-intake-20260809/00-spec.md`, +next to the table row each defect relates to. A comment line inside a host `.txt` file would be +itself content with no donor counterpart - i.e. it would seed an `extra` hunk the expected-findings +table above does not claim, corrupting the very diff the fixture exists to exercise. + +## Why each seed gets its own diff hunk + +`sd-port-fidelity` requires a diff artifact with "at least 3 lines of context" per hunk. Two nearby +changes closer than that context merge into a single hunk under standard unified-diff rules, which +would force one hunk to carry two different classifications. Every member in both donor files is +followed by an identical, unchanged `-- member boundary --` padding block precisely so each seeded +change - and the trailing `NotifyEscalation` addition - lands in its own isolated hunk. Run +`diff -u` yourself between any donor/host pair under this fixture to confirm: `clean/` produces 2 +hunks in `order-intake.txt` and 1 in `order-notify.txt`; `broken/` produces 3 in each. + +## What this does not do + +**It is not automated.** `sd-reviewer`'s `port-parity` mode is a prompt executed by a model, so +`scripts/` cannot run it the way `selftest-docs.{ps1,sh}` runs Check 7 of `scripts/validate.{ps1,sh}`. +Automating it in CI would mean reimplementing the adjudicator as an executable script - a second +copy of the rules, the same drift `sd-port-fidelity` itself exists to prevent. Until that trade-off +is decided, this fixture makes the SW-40 acceptance criteria **reproducible**, not **enforced**. + +**The parity diffs are checked in, not generated live.** `/sd:port` Phase 8 generates +`04-artifacts/parity/` from a live host; this fixture predates that pipeline landing, so its +`.diff` files were produced by hand with `diff -u` against the donor snapshot and are kept as a +static worked example rather than regenerated - see `docs/troubleshooting.md`. + +## Cross-fixture invariant + +Both trees must also be clean under `/sd:spec validate` - the seeds here are parity defects, not +spec-lint defects, so `SL080`-`SL083` (real-looking `source_repo`/`source_commit` sentinel values, +a non-empty member manifest, every `Citation` non-empty with `Group` in 1-4, a `Reason` on every +non-`mirror` mapping row) must hold in `broken/` too. An `SL0xx` finding on this fixture is a +fixture bug, not a parity finding. diff --git a/examples/port-parity-fixture/broken/.claude/project-config.json b/examples/port-parity-fixture/broken/.claude/project-config.json new file mode 100644 index 0000000..3c23aa2 --- /dev/null +++ b/examples/port-parity-fixture/broken/.claude/project-config.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + + "project": { + "name": "port-parity-fixture-broken", + "description": "Fixture: a port whose host seeds one defect per port-parity BLOCK class", + "owner": "specwright", + "repo": "https://github.com/Developzone/specwright" + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA", + "port": "PORT" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + } +} diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/00-spec.md b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/00-spec.md new file mode 100644 index 0000000..b7ff5c1 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/00-spec.md @@ -0,0 +1,126 @@ +--- +id: PORT-order-intake-20260809 +type: port +status: in-progress +jira: none +created: 2026-08-09 +scope: pattern +source_repo: none +source_commit: none +source_license: proprietary +snapshot: contract+source +linked_specs: [] +--- + +# Port order-intake and order-notify to order-service + +> **The donor is the specification. Every departure is a row in the deviation table, or it is a +> defect.** The three fidelity tables below (Path mapping, Member manifest, Deviation table) are +> defined and enforced by the **sd-port-fidelity** skill - read it before filling them. + +## Why + +This is the SW-40 `port-parity` fixture's `broken/` half: the identical intended contract as +`clean/` (same three tables below), against a host that seeds exactly one defect per BLOCK class +plus both whole-artifact checks, and one correctly-justified deviation that must NOT be reported - +the regression guard for the gate's noise floor, not only for its BLOCKs. `` +comments below name each defect; see the fixture README for the full expected-findings table. +`scope: pattern` and `source_repo/source_commit: none` because there is no real donor repository +behind this fixture - the "donor" is the frozen snapshot under `04-artifacts/source/`. + +## Donor provenance + +- **Donor**: none (pattern-scope fixture - see fixture README) +- **License**: proprietary - none +- **Snapshot mode**: contract+source +- **Snapshot root**: `04-artifacts/source/` +- **Manifest**: `04-artifacts/source/MANIFEST.md` +- **Frozen**: no - fixture only, never appended to `paths.protected` + +## Behavioral contract + +| Facet | Donor behavior (verbatim) | +|---|---| +| Route / entry point | n/a - library-style procedure cards, no entry route | +| Input shape | a customer reference and an order total, per member | +| Output shape | a ledger entry or a notification send | +| Status / result codes | n/a - plain-text procedure cards carry no status codes | +| Auth requirement | none | +| Side effects | ledger append, archive flag, message send | +| Error paths | reference already in ledger; totals mismatch | + +## Behavioral invariants (non-obvious) + +- INV-1: `CreateOrder` rejects a duplicate reference BEFORE appending to the ledger, so the + ledger-conflict check always runs ahead of any write. + +## Path mapping table + + + +| Donor path | Host path | Kind | Reason | +|---|---|---|---| +| `orders/order-intake.txt` | `src/orders/order-intake.txt` | mirror | - | +| `orders/order-notify.txt` | `src/orders/order-notify.txt` | mirror | - | + +## Member manifest + + + +| Donor path | Member | Ordinal | Host path | Status | Deviation ID | +|---|---|---|---|---|---| +| `orders/order-intake.txt` | `CreateOrder` | 1 | `src/orders/order-intake.txt` | deviated | D01 | +| `orders/order-intake.txt` | `ValidateTotals` | 2 | `src/orders/order-intake.txt` | deviated | D02 | +| `orders/order-intake.txt` | `ArchiveDraft` | 3 | `src/orders/order-intake.txt` | ported | - | +| `orders/order-notify.txt` | `NotifyDispatch` | 1 | `src/orders/order-notify.txt` | ported | - | +| `orders/order-notify.txt` | `NotifyFailure` | 2 | `src/orders/order-notify.txt` | deviated | D03 | + +## Deviation table + + + + + + + +| ID | Donor form | Host form | Group | Citation | +|---|---|---|---|---| +| D01 | identifier `ledger` | identifier `orderLedger` | 1 | `ledger` (conflicts with an existing host symbol of the same name) | +| D02 | member name `ValidateTotals` | member name `CheckTotals`, step order and log text unchanged | 3 | `CLAUDE.md:8` (host convention: validation members named `Check`) | +| D03 | member name `NotifyFailure` | member name `HandleFailure`, step order and log text unchanged | 3 | `CLAUDE.md:9` (host convention: failure-handling members named `Handle`) | + +## Spawned specs + +None. + +## Success criteria + +- [ ] AC-1: Every host hunk in this port is either a structural mirror of its member-manifest row, + or is covered by a deviation-table row whose citation satisfies its group and whose `Host form` + accounts for the whole hunk. No `unjustified`, `missing`, `extra`, or `overreached` hunk remains + (see sd-port-fidelity). Blocked by the five seeds above - see the fixture README's expected + findings table. +- [ ] AC-2: Member completeness is 5/5. Currently 4/5 - `ArchiveDraft` (ordinal 3) is absent. +- [ ] AC-3: Path conformance holds. Currently fails - `src/orders/order-audit.txt` is unmapped. + +## Out of scope + +Semantic equivalence checking of the ported logic - this fixture is about diff-based fidelity +adjudication only, not behavior pinning. + +## Open questions + +None. + +## Constitution check + +- **§1.1 Layer rules**: n/a - flat procedure-card host, no layers. +- **Risk of violation**: none. diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md new file mode 100644 index 0000000..b976395 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md @@ -0,0 +1,5 @@ +| Diff file | Snapshot path | Host path | Mapping row | +|---|---|---|---| +| `src__orders__order-intake.txt.diff` | `orders/order-intake.txt` | `src/orders/order-intake.txt` | 1 | +| `src__orders__order-notify.txt.diff` | `orders/order-notify.txt` | `src/orders/order-notify.txt` | 2 | +| `src__orders__order-audit.txt.diff` | `-` | `src/orders/order-audit.txt` | `-` | diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-audit.txt.diff b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-audit.txt.diff new file mode 100644 index 0000000..3f956d5 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-audit.txt.diff @@ -0,0 +1,8 @@ +--- /dev/null 2026-08-09 18:08:13.000000000 +0700 ++++ examples/port-parity-fixture/broken/src/orders/order-audit.txt 2026-08-09 18:01:03.163315000 +0700 +@@ -0,0 +1,5 @@ ++member AuditTrail ++ 1. record the order id ++ 2. record the timestamp ++ 3. log "audit recorded: " ++end member diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff new file mode 100644 index 0000000..6b8acd5 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff @@ -0,0 +1,31 @@ +--- examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt 2026-08-09 18:07:59.810670800 +0700 ++++ examples/port-parity-fixture/broken/src/orders/order-intake.txt 2026-08-09 18:07:40.843676800 +0700 +@@ -1,8 +1,8 @@ + member CreateOrder + 1. trim the customer reference + 2. reject an empty reference +- 3. reject a reference already in ledger +- 4. append the order to ledger ++ 3. reject a reference already in orderLedger ++ 4. append the order to orderLedger + 5. log "order accepted: " + end member + +@@ -12,7 +12,7 @@ + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- + +-member ValidateTotals ++member CheckTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch +@@ -24,8 +24,3 @@ + -- seeded change lands in its own diff hunk. -- + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- +- +-member ArchiveDraft +- 1. mark the draft archived +- 2. log "draft archived: " +-end member diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff new file mode 100644 index 0000000..4ff5740 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff @@ -0,0 +1,34 @@ +--- examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt 2026-08-09 18:07:59.831344300 +0700 ++++ examples/port-parity-fixture/broken/src/orders/order-notify.txt 2026-08-09 18:07:47.231471900 +0700 +@@ -1,7 +1,7 @@ + member NotifyDispatch + 1. build the dispatch message + 2. send the dispatch message +- 3. log "dispatch sent: " ++ 3. log "Dispatch message sent for " + end member + + -- member boundary -- +@@ -10,9 +10,9 @@ + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- + +-member NotifyFailure +- 1. build the failure message +- 2. send the failure message ++member HandleFailure ++ 1. send the failure message ++ 2. build the failure message + 3. log "failure sent: " + end member + +@@ -21,3 +21,9 @@ + -- seeded change lands in its own diff hunk. -- + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- ++ ++member NotifyEscalation ++ 1. check retry count against the escalation threshold ++ 2. page the on-call queue ++ 3. log "escalated: " ++end member diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md new file mode 100644 index 0000000..bcd4972 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md @@ -0,0 +1,10 @@ +- **Donor**: none (pattern-scope fixture, no real donor repo - see fixture README) +- **Commit**: none +- **Captured**: 2026-08-09 +- **Mode**: contract+source +- **Hash algorithm**: sha256, lowercase hex, over the bytes as captured + +| Snapshot path | Donor path | Commit | Bytes | SHA-256 | Member ranges | +|---|---|---|---|---|---| +| `orders/order-intake.txt` | `orders/order-intake.txt` | `none` | 905 | `84c0dcf1c385843ec17e1c932ab8c6799f23f2f97bec2f1a833a3504dc635f23` | `1: CreateOrder 1-7; 2: ValidateTotals 15-20; 3: ArchiveDraft 28-31` | +| `orders/order-notify.txt` | `orders/order-notify.txt` | `none` | 699 | `820646bc9c5c6500683bf5c756a9fb0efd50fd912ab001795d78f308f4672aca` | `1: NotifyDispatch 1-5; 2: NotifyFailure 13-17` | diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt new file mode 100644 index 0000000..f957d14 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt @@ -0,0 +1,31 @@ +member CreateOrder + 1. trim the customer reference + 2. reject an empty reference + 3. reject a reference already in ledger + 4. append the order to ledger + 5. log "order accepted: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member ValidateTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch + 4. log "totals validated: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member ArchiveDraft + 1. mark the draft archived + 2. log "draft archived: " +end member diff --git a/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt new file mode 100644 index 0000000..f7b6ecb --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt @@ -0,0 +1,23 @@ +member NotifyDispatch + 1. build the dispatch message + 2. send the dispatch message + 3. log "dispatch sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member NotifyFailure + 1. build the failure message + 2. send the failure message + 3. log "failure sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- diff --git a/examples/port-parity-fixture/broken/.specs/constitution.md b/examples/port-parity-fixture/broken/.specs/constitution.md new file mode 100644 index 0000000..13e4649 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/constitution.md @@ -0,0 +1,14 @@ +# Constitution - port-parity-fixture (broken) + +Minimal constitution so `sd-reviewer`'s bootstrap read has something to load. This fixture exercises +`port-parity` mode only; no other section is exercised. + +## §1 Architectural non-negotiables + +### §1.1 Layer rules + +N/A - a flat `.txt` procedure-card host with no layers. + +## §6 Forbidden patterns + +None declared for this fixture. diff --git a/examples/port-parity-fixture/broken/.specs/index.md b/examples/port-parity-fixture/broken/.specs/index.md new file mode 100644 index 0000000..8d8bdf0 --- /dev/null +++ b/examples/port-parity-fixture/broken/.specs/index.md @@ -0,0 +1,5 @@ +# Spec index + +| ID | Status | Title | +|---|---|---| +| PORT-order-intake-20260809 | in-progress | Port order-intake and order-notify to order-service | diff --git a/examples/port-parity-fixture/broken/CLAUDE.md b/examples/port-parity-fixture/broken/CLAUDE.md new file mode 100644 index 0000000..119c3fd --- /dev/null +++ b/examples/port-parity-fixture/broken/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md - order-service (fixture) + +Plain-text "procedure card" host, ported from a fictional donor for the SW-40 `port-parity` +fixture. No toolchain: `.txt` files under `src/`, one `member ` block per procedure, numbered +steps, `end member` terminator. + +## Conventions + +- Validation members are named `Check`. +- Failure-handling members are named `Handle`. diff --git a/examples/port-parity-fixture/broken/src/orders/order-audit.txt b/examples/port-parity-fixture/broken/src/orders/order-audit.txt new file mode 100644 index 0000000..4064088 --- /dev/null +++ b/examples/port-parity-fixture/broken/src/orders/order-audit.txt @@ -0,0 +1,5 @@ +member AuditTrail + 1. record the order id + 2. record the timestamp + 3. log "audit recorded: " +end member diff --git a/examples/port-parity-fixture/broken/src/orders/order-intake.txt b/examples/port-parity-fixture/broken/src/orders/order-intake.txt new file mode 100644 index 0000000..ed3f568 --- /dev/null +++ b/examples/port-parity-fixture/broken/src/orders/order-intake.txt @@ -0,0 +1,26 @@ +member CreateOrder + 1. trim the customer reference + 2. reject an empty reference + 3. reject a reference already in orderLedger + 4. append the order to orderLedger + 5. log "order accepted: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member CheckTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch + 4. log "totals validated: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- diff --git a/examples/port-parity-fixture/broken/src/orders/order-notify.txt b/examples/port-parity-fixture/broken/src/orders/order-notify.txt new file mode 100644 index 0000000..fcdaa76 --- /dev/null +++ b/examples/port-parity-fixture/broken/src/orders/order-notify.txt @@ -0,0 +1,29 @@ +member NotifyDispatch + 1. build the dispatch message + 2. send the dispatch message + 3. log "Dispatch message sent for " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member HandleFailure + 1. send the failure message + 2. build the failure message + 3. log "failure sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member NotifyEscalation + 1. check retry count against the escalation threshold + 2. page the on-call queue + 3. log "escalated: " +end member diff --git a/examples/port-parity-fixture/clean/.claude/project-config.json b/examples/port-parity-fixture/clean/.claude/project-config.json new file mode 100644 index 0000000..53ce9f3 --- /dev/null +++ b/examples/port-parity-fixture/clean/.claude/project-config.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + + "project": { + "name": "port-parity-fixture-clean", + "description": "Fixture: a port whose host fully satisfies its spec's fidelity tables", + "owner": "specwright", + "repo": "https://github.com/Developzone/specwright" + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA", + "port": "PORT" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + } +} diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/00-spec.md b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/00-spec.md new file mode 100644 index 0000000..165bb31 --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/00-spec.md @@ -0,0 +1,109 @@ +--- +id: PORT-order-intake-20260809 +type: port +status: in-progress +jira: none +created: 2026-08-09 +scope: pattern +source_repo: none +source_commit: none +source_license: proprietary +snapshot: contract+source +linked_specs: [] +--- + +# Port order-intake and order-notify to order-service + +> **The donor is the specification. Every departure is a row in the deviation table, or it is a +> defect.** The three fidelity tables below (Path mapping, Member manifest, Deviation table) are +> defined and enforced by the **sd-port-fidelity** skill - read it before filling them. + +## Why + +This is the SW-40 `port-parity` fixture's `clean/` half: a host that fully satisfies every row of +its own fidelity tables, so a parity review over it must return `_No findings._` with every +manifest member present and every deviation correctly justified. `scope: pattern` and +`source_repo/source_commit: none` because there is no real donor repository behind this fixture - +the "donor" is the frozen snapshot under `04-artifacts/source/`, invented for this fixture and +treated exactly as a real one would be for review purposes. + +## Donor provenance + +- **Donor**: none (pattern-scope fixture - see fixture README) +- **License**: proprietary - none +- **Snapshot mode**: contract+source +- **Snapshot root**: `04-artifacts/source/` +- **Manifest**: `04-artifacts/source/MANIFEST.md` +- **Frozen**: no - fixture only, never appended to `paths.protected` + +## Behavioral contract + +| Facet | Donor behavior (verbatim) | +|---|---| +| Route / entry point | n/a - library-style procedure cards, no entry route | +| Input shape | a customer reference and an order total, per member | +| Output shape | a ledger entry or a notification send | +| Status / result codes | n/a - plain-text procedure cards carry no status codes | +| Auth requirement | none | +| Side effects | ledger append, archive flag, message send | +| Error paths | reference already in ledger; totals mismatch | + +## Behavioral invariants (non-obvious) + +- INV-1: `CreateOrder` rejects a duplicate reference BEFORE appending to the ledger, so the + ledger-conflict check always runs ahead of any write. + +## Path mapping table + +| Donor path | Host path | Kind | Reason | +|---|---|---|---| +| `orders/order-intake.txt` | `src/orders/order-intake.txt` | mirror | - | +| `orders/order-notify.txt` | `src/orders/order-notify.txt` | mirror | - | + +## Member manifest + +| Donor path | Member | Ordinal | Host path | Status | Deviation ID | +|---|---|---|---|---|---| +| `orders/order-intake.txt` | `CreateOrder` | 1 | `src/orders/order-intake.txt` | deviated | D01 | +| `orders/order-intake.txt` | `ValidateTotals` | 2 | `src/orders/order-intake.txt` | deviated | D02 | +| `orders/order-intake.txt` | `ArchiveDraft` | 3 | `src/orders/order-intake.txt` | ported | - | +| `orders/order-notify.txt` | `NotifyDispatch` | 1 | `src/orders/order-notify.txt` | ported | - | +| `orders/order-notify.txt` | `NotifyFailure` | 2 | `src/orders/order-notify.txt` | deviated | D03 | + +## Deviation table + +| ID | Donor form | Host form | Group | Citation | +|---|---|---|---|---| +| D01 | identifier `ledger` | identifier `orderLedger` | 1 | `ledger` (conflicts with an existing host symbol of the same name) | +| D02 | member name `ValidateTotals` | member name `CheckTotals`, step order and log text unchanged | 3 | `CLAUDE.md:8` (host convention: validation members named `Check`) | +| D03 | member name `NotifyFailure` | member name `HandleFailure`, step order and log text unchanged | 3 | `CLAUDE.md:9` (host convention: failure-handling members named `Handle`) | + +## Spawned specs + +None. + +## Success criteria + +- [x] AC-1: Every host hunk in this port is either a structural mirror of its member-manifest row, + or is covered by a deviation-table row whose citation satisfies its group and whose `Host form` + accounts for the whole hunk. No `unjustified`, `missing`, `extra`, or `overreached` hunk remains + (see sd-port-fidelity). Evidence: `04-artifacts/parity/INDEX.md` + both `.diff` files - every + hunk maps to D01, D02, or D03 and no hunk exceeds its row. +- [x] AC-2: Member completeness is 5/5 - every manifest row has a host counterpart. Evidence: both + host files under `src/orders/`. +- [x] AC-3: Path conformance holds - every file under `src/orders/` is a `Host path` in the path + mapping table above. Evidence: exactly two files, both mapped. + +## Out of scope + +Semantic equivalence checking of the ported logic - this fixture is about diff-based fidelity +adjudication only, not behavior pinning. + +## Open questions + +None. + +## Constitution check + +- **§1.1 Layer rules**: n/a - flat procedure-card host, no layers. +- **Risk of violation**: none. diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md new file mode 100644 index 0000000..c77c36e --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/INDEX.md @@ -0,0 +1,4 @@ +| Diff file | Snapshot path | Host path | Mapping row | +|---|---|---|---| +| `src__orders__order-intake.txt.diff` | `orders/order-intake.txt` | `src/orders/order-intake.txt` | 1 | +| `src__orders__order-notify.txt.diff` | `orders/order-notify.txt` | `src/orders/order-notify.txt` | 2 | diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff new file mode 100644 index 0000000..21d943f --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-intake.txt.diff @@ -0,0 +1,22 @@ +--- examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt 2026-08-09 18:07:35.902512800 +0700 ++++ examples/port-parity-fixture/clean/src/orders/order-intake.txt 2026-08-09 18:07:38.605667300 +0700 +@@ -1,8 +1,8 @@ + member CreateOrder + 1. trim the customer reference + 2. reject an empty reference +- 3. reject a reference already in ledger +- 4. append the order to ledger ++ 3. reject a reference already in orderLedger ++ 4. append the order to orderLedger + 5. log "order accepted: " + end member + +@@ -12,7 +12,7 @@ + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- + +-member ValidateTotals ++member CheckTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff new file mode 100644 index 0000000..c992f15 --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/parity/src__orders__order-notify.txt.diff @@ -0,0 +1,11 @@ +--- examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt 2026-08-09 18:07:42.998918800 +0700 ++++ examples/port-parity-fixture/clean/src/orders/order-notify.txt 2026-08-09 18:07:45.169949800 +0700 +@@ -10,7 +10,7 @@ + -- this block is unchanged across every donor/host pair. -- + -- no further remarks. -- + +-member NotifyFailure ++member HandleFailure + 1. build the failure message + 2. send the failure message + 3. log "failure sent: " diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md new file mode 100644 index 0000000..bcd4972 --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/MANIFEST.md @@ -0,0 +1,10 @@ +- **Donor**: none (pattern-scope fixture, no real donor repo - see fixture README) +- **Commit**: none +- **Captured**: 2026-08-09 +- **Mode**: contract+source +- **Hash algorithm**: sha256, lowercase hex, over the bytes as captured + +| Snapshot path | Donor path | Commit | Bytes | SHA-256 | Member ranges | +|---|---|---|---|---|---| +| `orders/order-intake.txt` | `orders/order-intake.txt` | `none` | 905 | `84c0dcf1c385843ec17e1c932ab8c6799f23f2f97bec2f1a833a3504dc635f23` | `1: CreateOrder 1-7; 2: ValidateTotals 15-20; 3: ArchiveDraft 28-31` | +| `orders/order-notify.txt` | `orders/order-notify.txt` | `none` | 699 | `820646bc9c5c6500683bf5c756a9fb0efd50fd912ab001795d78f308f4672aca` | `1: NotifyDispatch 1-5; 2: NotifyFailure 13-17` | diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt new file mode 100644 index 0000000..f957d14 --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-intake.txt @@ -0,0 +1,31 @@ +member CreateOrder + 1. trim the customer reference + 2. reject an empty reference + 3. reject a reference already in ledger + 4. append the order to ledger + 5. log "order accepted: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member ValidateTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch + 4. log "totals validated: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member ArchiveDraft + 1. mark the draft archived + 2. log "draft archived: " +end member diff --git a/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt new file mode 100644 index 0000000..f7b6ecb --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/PORT-order-intake-20260809/04-artifacts/source/orders/order-notify.txt @@ -0,0 +1,23 @@ +member NotifyDispatch + 1. build the dispatch message + 2. send the dispatch message + 3. log "dispatch sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member NotifyFailure + 1. build the failure message + 2. send the failure message + 3. log "failure sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- diff --git a/examples/port-parity-fixture/clean/.specs/constitution.md b/examples/port-parity-fixture/clean/.specs/constitution.md new file mode 100644 index 0000000..4aa682a --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/constitution.md @@ -0,0 +1,14 @@ +# Constitution - port-parity-fixture (clean) + +Minimal constitution so `sd-reviewer`'s bootstrap read has something to load. This fixture exercises +`port-parity` mode only; no other section is exercised. + +## §1 Architectural non-negotiables + +### §1.1 Layer rules + +N/A - a flat `.txt` procedure-card host with no layers. + +## §6 Forbidden patterns + +None declared for this fixture. diff --git a/examples/port-parity-fixture/clean/.specs/index.md b/examples/port-parity-fixture/clean/.specs/index.md new file mode 100644 index 0000000..8d8bdf0 --- /dev/null +++ b/examples/port-parity-fixture/clean/.specs/index.md @@ -0,0 +1,5 @@ +# Spec index + +| ID | Status | Title | +|---|---|---| +| PORT-order-intake-20260809 | in-progress | Port order-intake and order-notify to order-service | diff --git a/examples/port-parity-fixture/clean/CLAUDE.md b/examples/port-parity-fixture/clean/CLAUDE.md new file mode 100644 index 0000000..119c3fd --- /dev/null +++ b/examples/port-parity-fixture/clean/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md - order-service (fixture) + +Plain-text "procedure card" host, ported from a fictional donor for the SW-40 `port-parity` +fixture. No toolchain: `.txt` files under `src/`, one `member ` block per procedure, numbered +steps, `end member` terminator. + +## Conventions + +- Validation members are named `Check`. +- Failure-handling members are named `Handle`. diff --git a/examples/port-parity-fixture/clean/src/orders/order-intake.txt b/examples/port-parity-fixture/clean/src/orders/order-intake.txt new file mode 100644 index 0000000..0ff473a --- /dev/null +++ b/examples/port-parity-fixture/clean/src/orders/order-intake.txt @@ -0,0 +1,31 @@ +member CreateOrder + 1. trim the customer reference + 2. reject an empty reference + 3. reject a reference already in orderLedger + 4. append the order to orderLedger + 5. log "order accepted: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member CheckTotals + 1. sum the line amounts + 2. compare the sum to the declared total + 3. reject on mismatch + 4. log "totals validated: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member ArchiveDraft + 1. mark the draft archived + 2. log "draft archived: " +end member diff --git a/examples/port-parity-fixture/clean/src/orders/order-notify.txt b/examples/port-parity-fixture/clean/src/orders/order-notify.txt new file mode 100644 index 0000000..ff2274b --- /dev/null +++ b/examples/port-parity-fixture/clean/src/orders/order-notify.txt @@ -0,0 +1,23 @@ +member NotifyDispatch + 1. build the dispatch message + 2. send the dispatch message + 3. log "dispatch sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- + +member HandleFailure + 1. build the failure message + 2. send the failure message + 3. log "failure sent: " +end member + +-- member boundary -- +-- unrelated members are separated by this block so each -- +-- seeded change lands in its own diff hunk. -- +-- this block is unchanged across every donor/host pair. -- +-- no further remarks. -- diff --git a/examples/spec-lint-fixture/README.md b/examples/spec-lint-fixture/README.md index 4d5950c..fa2e2bd 100644 --- a/examples/spec-lint-fixture/README.md +++ b/examples/spec-lint-fixture/README.md @@ -41,6 +41,12 @@ remaining") this correct spec FAILED, while `broken/PERF-BROKEN-002` — the sam baseline invented from memory — PASSED. The two perf specs are a matched pair: any change that makes one behave like the other has reintroduced the bug SW-4 seam 1 fixed. +`clean/BUG-CLEAN-003` is `done` and its "Fix approach" section names a follow-up deferred to a +separate spec - but that follow-up carries a reserved ID (`REF-CLEAN-004`) in its +`## Spawned specs` table, so `SL090` stays silent. `broken/FEAT-BROKEN-015` is the same shape with +the table left empty, and `SL090` fires. The two are a second matched pair: any change that makes +`BUG-CLEAN-003` behave like `FEAT-BROKEN-015` has broken `SL090`. + --- ## Expected findings in `broken/` @@ -75,6 +81,7 @@ can be traced to an intentional seed rather than an accident. | `PERF-BROKEN-012` | `SL021` | BLOCK | `done` with a `05-retro.md` that has only its header | | `REF-BROKEN-013` | `SL041` | BLOCK | Retro jumps `approved` -> an entry opening at `in-progress` | | `FEAT-BROKEN-014` | `SL044` | WARN | `archived -> in-progress` logged with an empty reason | +| `FEAT-BROKEN-015` | `SL090` | SUGGEST | `done` spec names deferred work; `## Spawned specs` table is empty | | _(tree-wide)_ | `SL032` | BLOCK | `BUG-GHOST-006` row in `index.md` has no folder | `FEAT-BROKEN-011` is the one spec that seeds two rules on purpose. An illegal `status` cannot @@ -92,6 +99,7 @@ own rule fire. Each `00-spec.md` explains its own boundary; the summary: | `PERF-BROKEN-012` | `SL021` | `SL043` (the retro file exists) and `SL042` (no last entry to disagree with) | | `REF-BROKEN-013` | `SL041` | `SL040` (both edges are legal), `SL042` (last entry matches frontmatter), `SL043` (a retro exists) | | `FEAT-BROKEN-014` | `SL044` at **WARN** | `SL040` / `SL041` / `SL042` - the chain is contiguous, legal, and ends where frontmatter says | +| `FEAT-BROKEN-015` | `SL090` at **SUGGEST** | `SL010` / `SL012` / `SL020` / `SL021` / `SL055` - plan, tasks, retro and a passing `06-verify.md` are all present, and the feature template has no phase-deferred token | --- @@ -104,7 +112,9 @@ of the rules, which is precisely the drift that SW-1 and SW-3 exist to prevent. trade-off is decided, this fixture makes the acceptance criterion **reproducible**, not **enforced**. -**Rule coverage is partial: 24 of the 26 rules are seeded.** Not seeded, and why: +**Rule coverage is partial: 25 of the 37 rules are seeded.** The `SL06x` task-content, `SL07x` +revision-log, and `SL08x` port-fidelity bands are exercised by their own fixtures and are not +duplicated here. Not seeded in this fixture, and why: | Rule | Why not seeded | |---|---| diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/00-spec.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/00-spec.md new file mode 100644 index 0000000..ef5663a --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/00-spec.md @@ -0,0 +1,73 @@ +--- +id: FEAT-BROKEN-015 +type: feature +status: done +jira: none +created: 2026-07-14 +linked_specs: [] +--- + +# Add saved-view sharing + + + + +## Why + +Teams re-create the same filtered views by hand. Sharing a saved view removes the copying and +the drift between two people's "same" view. + +## What + +### SC-1: Share a view + +- **Given** a user owns a saved view +- **When** they share it with a team +- **Then** every member of that team sees the view read-only + +### SC-2: Revoke a share + +- **Given** a view is shared with a team +- **When** the owner revokes the share +- **Then** the view disappears for the team and stays for the owner + +## Success criteria + +- [x] AC-1: POST /api/views/{id}/share returns 200 and the view is visible to the team +- [x] AC-2: DELETE /api/views/{id}/share removes team visibility only +- [x] AC-3: Unit + integration tests cover both scenarios + +## Out of scope + +- Editable shares - read-only in this iteration. + +## Open questions + +- None outstanding. + +## Spawned specs + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| + +## Constitution check + +- **§1.1 Layer rules**: sharing stays in the application layer. +- **§2.3 Error handling**: reuses `ViewNotFoundException`. +- **§3 Quality bars**: 80% line coverage, one integration test per scenario. +- **Risk of violation**: none. The permission cache is not invalidated on revoke, so a revoked + share stays visible for up to 60s - left as-is here, since the fix belongs in the caching layer + and is a separate spec, not an exception taken by this one. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/01-plan.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/01-plan.md new file mode 100644 index 0000000..3c00be6 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/01-plan.md @@ -0,0 +1,9 @@ +# Plan: FEAT-BROKEN-015 + +Present so that the `done` status does not also raise SL020. The content is not what this +fixture exercises - only its existence is. + +## Approach + +Add a share record beside the saved view, resolve visibility at read time, and revoke by +deleting the record. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/02-tasks.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/02-tasks.md new file mode 100644 index 0000000..04f62e5 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/02-tasks.md @@ -0,0 +1,7 @@ +# Tasks: FEAT-BROKEN-015 + +Present so that the `done` status does not also raise SL020. + +- [x] T1: Add the ViewShare record and its migration. +- [x] T2: Resolve share visibility in the view read path. +- [x] T3: Add the revoke endpoint. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/05-retro.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/05-retro.md new file mode 100644 index 0000000..80f454a --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/05-retro.md @@ -0,0 +1,12 @@ +# Retro log: FEAT-BROKEN-015 + +Append-only. Never edit prior entries. + +- [2026-07-14T09:00:00Z] Status: draft -> approved. Reason: scenarios agreed with the product lead. +- [2026-07-14T13:00:00Z] Status: approved -> in-progress. Reason: plan and tasks accepted. +- [2026-07-16T15:00:00Z] Status: in-progress -> done. Reason: all success criteria met. + +## Notes + +The team-membership lookup runs per row rather than per request; that is a follow-up for whoever +touches the read path next, and it has no reserved ID. diff --git a/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/06-verify.md b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/06-verify.md new file mode 100644 index 0000000..ec413e2 --- /dev/null +++ b/examples/spec-lint-fixture/broken/.specs/FEAT-BROKEN-015/06-verify.md @@ -0,0 +1,29 @@ +--- +spec: FEAT-BROKEN-015 +result: pass +date: 2026-07-16 +failures: 0 +--- + +# Verification report - FEAT-BROKEN-015 + +## Traceability + +| ID | Kind | Covered by | Test(s) | Status | +|---|---|---|---|---| +| SC-1 | scenario | T1, T2 | tests/views/share.test.ts | PASS | +| SC-2 | scenario | T3 | tests/views/revoke.test.ts | PASS | +| AC-1 | criterion | T2 | tests/views/share.test.ts | PASS | +| AC-2 | criterion | T3 | tests/views/revoke.test.ts | PASS | +| AC-3 | criterion | T1, T2, T3 | tests/views/ | PASS | + +## Test run + +- Command: `npm run test:views` +- Exit code: 0 + +## Findings + +none + + diff --git a/examples/spec-lint-fixture/broken/.specs/index.md b/examples/spec-lint-fixture/broken/.specs/index.md index e6fea04..e39ec6e 100644 --- a/examples/spec-lint-fixture/broken/.specs/index.md +++ b/examples/spec-lint-fixture/broken/.specs/index.md @@ -18,3 +18,4 @@ Active specs (auto-updated by /sd:spec status transitions): | PERF-BROKEN-012 | perf | done | 2026-07-11 | Cut peak memory on the nightly export job | | REF-BROKEN-013 | refactor | done | 2026-07-12 | Split InvoiceHandler | | FEAT-BROKEN-014 | feature | in-progress | 2026-07-13 | Add bulk tag assignment | +| FEAT-BROKEN-015 | feature | done | 2026-07-14 | Add saved-view sharing | diff --git a/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md index 1ffee67..095a51b 100644 --- a/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md +++ b/examples/spec-lint-fixture/clean/.specs/BUG-CLEAN-003/00-spec.md @@ -50,6 +50,8 @@ moved ahead of the write, verified by the failing test added in Phase 4. **Status**: Confirmed after root cause. - Move the idempotency check ahead of the write in `src/Stock/StockDeductionService.cs`. +- The check could move into a guard clause for readability - deferred to a separate spec, since + that is a pure structural cleanup and not part of this fix. **Scope discipline check**: - [x] Fix touches only files implicated by root cause @@ -59,3 +61,16 @@ moved ahead of the write, verified by the failing test added in Phase 4. - [x] Failing test added that reproduces the bug (Phase 4 Gate 4) - [x] Failing test now passes with fix applied + +## Spawned specs + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| +| REF-CLEAN-004 | refactor | Move the idempotency check into a guard clause | ops | + + diff --git a/hooks/bash/prompt-router.sh b/hooks/bash/prompt-router.sh index 77ff952..e3aaaaa 100644 --- a/hooks/bash/prompt-router.sh +++ b/hooks/bash/prompt-router.sh @@ -105,6 +105,7 @@ match_keywords feature $'feature\nadd\nimplement\nnew\nsupport' match_keywords refactor $'refactor\nrestructure\nclean up\nextract\nrename' match_keywords perf $'perf\nperformance\nslow\noptimize\nlatency\nthroughput' match_keywords rca $'incident\noutage\nrca\nroot cause\npost-mortem\npostmortem' +match_keywords port $'backport\nport from\nport the\ndonor repo\nmirror from\nreplicate from' # --- ticket detection --------------------------------------------------------- diff --git a/hooks/bash/spec-gate.sh b/hooks/bash/spec-gate.sh index 919e361..3904096 100644 --- a/hooks/bash/spec-gate.sh +++ b/hooks/bash/spec-gate.sh @@ -391,6 +391,73 @@ emit_transition_metrics() { done } +# --- metrics: complexity-gate split detection (observational, SW-31) ---------- +# Gate Complexity (ADR 0002) is decided as model-executed prose inside +# /sd:feature Phase 3 Gate 2 - no hook observes that decision directly. What +# IS observable here is one of its two possible outcomes: an "approve split" +# resolution always leaves a structural trace in THIS index.md edit or an +# earlier one - the parent row moves to `archived` (commands/feature.md +# Face B, "make the parent an umbrella record") and each child is registered +# under the `FEAT--` naming convention (feature.md's child-ID +# step). A FEAT-X row newly transitioning to `archived` alongside any +# already-registered FEAT-X- row (in the on-disk registry OR this same +# pending edit) is read as a completed split. +# +# This can only ever detect a SPLIT, never a bare trip: Face A (never +# tripped) and Face B "no-split" (tripped, user declined) both leave the +# parent `in-progress` with no distinguishing mark in index.md, so trip rate +# on its own is not recoverable from this signal. See ADR +# docs/adr/0004-threshold-calibration.md "Scope declined" - deliberately not +# fixed here to avoid making a HARD gate's prose responsible for feeding a +# metrics pipeline the rest of this file keeps strictly hook-authored. +# +# Callers MUST only invoke this on a path where the edit was actually +# ALLOWED through (Rule 0's verify-allow exit, Rule 2's allow-listed exit). +# On a block exit the edit never reached disk, so recording "split" there +# would assert a split that did not happen - never add a call site here on a +# block/deny path. +# +# Scoped to FEAT- parents only: Gate Complexity decompose is a /sd:feature +# mechanism (commands/feature.md Face B); BUG-/REF-/PERF-/RCA- rows can never +# go through it, so matching their prefix too would only add false-positive +# surface for coincidental id-prefix collisions with no corresponding +# real-world case. +emit_complexity_split_metrics() { + [[ ${#transition_id[@]} -eq 0 ]] && return 0 + + local registry_pairs="" new_fragment="" new_pairs="" + if [[ -f "${index_path}" ]]; then + registry_pairs="$(extract_id_status_pairs < "${index_path}")" + fi + case "${tool_name}" in + Edit) new_fragment="$(printf '%s' "${input}" | jq -r '.tool_input.new_string // empty' 2>/dev/null)" ;; + Write) new_fragment="$(printf '%s' "${input}" | jq -r '.tool_input.content // empty' 2>/dev/null)" ;; + MultiEdit) new_fragment="$(printf '%s' "${input}" | jq -r '[.tool_input.edits[]?.new_string // empty] | join("\n")' 2>/dev/null)" ;; + esac + [[ -n "${new_fragment}" ]] && new_pairs="$(printf '%s' "${new_fragment}" | extract_id_status_pairs)" + + local all_ids + all_ids="$(printf '%s\n%s\n' "${registry_pairs}" "${new_pairs}" | awk -F'\t' '{if ($1!="") print $1}' | LC_ALL=C sort -u)" + + local i id other child_found + for i in "${!transition_id[@]}"; do + id="${transition_id[$i]}" + [[ "${transition_phase[$i]}" == "archived" ]] || continue + case "${id}" in + FEAT-*) ;; + *) continue ;; + esac + child_found=0 + while IFS= read -r other; do + [[ -z "${other}" || "${other}" == "${id}" ]] && continue + case "${other}" in + "${id}-"*) child_found=1; break ;; + esac + done <<< "${all_ids}" + [[ ${child_found} -eq 1 ]] && emit_gate_metric "${id}" "archived" "complexity" "split" + done +} + # --- Rule 0: verify gate on the spec index ------------------------------------ # A row transitioning to done requires a passing /sd:verify artifact; a # verified close-out is allowed through the protected-path rule. Any other @@ -491,6 +558,10 @@ if [[ "${verify_gate}" == "true" && "${rel_lower}" == "${index_rel_lower}" ]]; t emit_gate_metric "${id}" "done" "verify" "${id_decision}" done <<< "${transition_ids}" emit_transition_metrics "block" + # No emit_complexity_split_metrics here: the whole edit is + # denied, so nothing in it - including any bundled parent + # archive + child registration - actually reached disk. See + # the function's own comment. exit 0 fi # Every transitioning spec has a passing artifact - allow the close-out. @@ -499,6 +570,7 @@ if [[ "${verify_gate}" == "true" && "${rel_lower}" == "${index_rel_lower}" ]]; t emit_gate_metric "${id}" "done" "verify" "allow" done <<< "${transition_ids}" emit_transition_metrics "allow" + emit_complexity_split_metrics exit 0 fi fi @@ -525,6 +597,8 @@ if [[ ${is_protected} -eq 1 ]]; then emit_block "spec-gate: '${rel}' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly." emit_gate_metric "-" "-" "protected" "block" emit_transition_metrics "block" + # No emit_complexity_split_metrics here: the edit is denied, so a + # detected parent-archive-plus-child pattern in it never reached disk. exit 0 fi @@ -563,6 +637,7 @@ fi if [[ ${is_allowed} -eq 1 ]]; then emit_transition_metrics "allow" + emit_complexity_split_metrics exit 0 fi diff --git a/hooks/powershell/prompt-router.ps1 b/hooks/powershell/prompt-router.ps1 index b713c46..9c693c1 100644 --- a/hooks/powershell/prompt-router.ps1 +++ b/hooks/powershell/prompt-router.ps1 @@ -8,7 +8,7 @@ project cwd. Loads .claude/project-config.json (or sane defaults if absent) and: 1. Matches the prompt against workflow keywords (bug / feature / refactor - / perf / rca) and suggests the relevant /sd:* command. + / perf / rca / port) and suggests the relevant /sd:* command. 2. Detects ticket IDs in the prompt using ticket.pattern and looks up matching folders under .specs/. 3. Reads .specs/index.md and surfaces any spec currently in-progress. @@ -45,6 +45,7 @@ $script:DefaultKeywords = [pscustomobject]@{ refactor = @('refactor','restructure','clean up','extract','rename') perf = @('perf','performance','slow','optimize','latency','throughput') rca = @('incident','outage','rca','root cause','post-mortem','postmortem') + port = @('backport','port from','port the','donor repo','mirror from','replicate from') } function Get-ProjectConfig { @@ -109,7 +110,7 @@ function Get-KeywordMatches { ) $matches = @{} $lower = $Prompt.ToLowerInvariant() - foreach ($workflow in @('bug','feature','refactor','perf','rca')) { + foreach ($workflow in @('bug','feature','refactor','perf','rca','port')) { $list = $null if ($null -ne $KeywordMap) { $list = $KeywordMap.$workflow } if ($null -eq $list -or @($list).Count -eq 0) { diff --git a/hooks/powershell/spec-gate.ps1 b/hooks/powershell/spec-gate.ps1 index f8cb587..7ed2df5 100644 --- a/hooks/powershell/spec-gate.ps1 +++ b/hooks/powershell/spec-gate.ps1 @@ -516,6 +516,88 @@ function Write-TransitionMetrics { } } +# Gate Complexity (ADR 0002) is decided as model-executed prose inside +# /sd:feature Phase 3 Gate 2 - no hook observes that decision directly. What +# IS observable here is one of its two possible outcomes: an "approve split" +# resolution always leaves a structural trace in THIS index.md edit or an +# earlier one - the parent row moves to 'archived' (commands/feature.md +# Face B, "make the parent an umbrella record") and each child is registered +# under the 'FEAT--' naming convention (feature.md's child-ID +# step). A FEAT-X row newly transitioning to 'archived' alongside any +# already-registered FEAT-X- row (in the on-disk registry OR this same +# pending edit) is read as a completed split. +# +# This can only ever detect a SPLIT, never a bare trip: Face A (never +# tripped) and Face B "no-split" (tripped, user declined) both leave the +# parent 'in-progress' with no distinguishing mark in index.md, so trip rate +# on its own is not recoverable from this signal. See ADR +# docs/adr/0004-threshold-calibration.md "Scope declined" - deliberately not +# fixed here to avoid making a HARD gate's prose responsible for feeding a +# metrics pipeline the rest of this file keeps strictly hook-authored. +# +# Callers MUST only invoke this on a path where the edit was actually +# ALLOWED through (Rule 0's verify-allow exit, Rule 2's allow-listed exit). +# On a block exit the edit never reached disk, so recording "split" there +# would assert a split that did not happen - never add a call site here on a +# block/deny path. +# +# Scoped to FEAT- parents only: Gate Complexity decompose is a /sd:feature +# mechanism (commands/feature.md Face B); BUG-/REF-/PERF-/RCA- rows can never +# go through it, so matching their prefix too would only add false-positive +# surface for coincidental id-prefix collisions with no corresponding +# real-world case. +function Write-ComplexitySplitMetrics { + param( + [string]$Cwd, + [object]$Config, + [object[]]$Transitions, + [string]$IndexPath, + [object]$HookInput + ) + if (-not $Transitions -or $Transitions.Count -eq 0) { return } + try { + $rowPattern = '\|\s*((?:FEAT|BUG|REF|PERF|RCA)-[A-Za-z0-9_\-]+)\s*\|\s*[^|]*\|\s*(draft|approved|in-progress|done|archived)\s*\|' + $allIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) + if (Test-Path -LiteralPath $IndexPath) { + try { + foreach ($line in (Get-Content -LiteralPath $IndexPath -Encoding UTF8 -ErrorAction Stop)) { + if ($line -match $rowPattern) { [void]$allIds.Add($Matches[1]) } + } + } catch { } + } + + $fragments = New-Object System.Collections.Generic.List[string] + $tool = $HookInput.tool_name + if ($tool -eq 'Edit') { + if ($HookInput.tool_input.new_string) { $fragments.Add([string]$HookInput.tool_input.new_string) | Out-Null } + } elseif ($tool -eq 'Write') { + if ($HookInput.tool_input.content) { $fragments.Add([string]$HookInput.tool_input.content) | Out-Null } + } elseif ($tool -eq 'MultiEdit') { + foreach ($e in @($HookInput.tool_input.edits)) { + if ($e.new_string) { $fragments.Add([string]$e.new_string) | Out-Null } + } + } + foreach ($frag in $fragments) { + foreach ($line in ($frag -split "`n")) { + if ($line -match $rowPattern) { [void]$allIds.Add($Matches[1]) } + } + } + + foreach ($t in $Transitions) { + if ($t.Phase -ne 'archived') { continue } + if ($t.Id -notlike 'FEAT-*') { continue } + $childFound = $false + foreach ($other in $allIds) { + if ($other -eq $t.Id) { continue } + if ($other.StartsWith("$($t.Id)-", [System.StringComparison]::Ordinal)) { $childFound = $true; break } + } + if ($childFound) { + Write-GateMetric -Cwd $Cwd -Config $Config -SpecId $t.Id -Phase 'archived' -Gate 'complexity' -Decision 'split' + } + } + } catch { } +} + function Test-VerifyArtifactPass { param( [string]$Cwd, @@ -657,6 +739,10 @@ if ($verifyGateOn -and [string]::Equals($rel, $indexRel, [System.StringCompariso Write-GateMetric -Cwd $cwd -Config $config -SpecId $id -Phase 'done' -Gate 'verify' -Decision $idDecision } Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'block' + # No Write-ComplexitySplitMetrics here: the whole edit is denied, + # so nothing in it - including any bundled parent archive + child + # registration - actually reached disk. See the function's own + # comment. exit 0 } # Every transitioning spec has a passing artifact - allow the close-out. @@ -666,6 +752,7 @@ if ($verifyGateOn -and [string]::Equals($rel, $indexRel, [System.StringCompariso Write-GateMetric -Cwd $cwd -Config $config -SpecId $id -Phase 'done' -Gate 'verify' -Decision 'allow' } Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'allow' + Write-ComplexitySplitMetrics -Cwd $cwd -Config $config -Transitions $transitions -IndexPath $indexAbs -HookInput $hookInput exit 0 } } @@ -677,12 +764,15 @@ if (Test-IsProtected -RelPath $rel -Protected $protected) { Write-BlockDecision "spec-gate: '$rel' is listed under paths.protected in .claude/project-config.json. Update via /sd:refactor or an ADR; never edit directly." Write-GateMetric -Cwd $cwd -Config $config -SpecId '-' -Phase '-' -Gate 'protected' -Decision 'block' Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'block' + # No Write-ComplexitySplitMetrics here: the edit is denied, so a detected + # parent-archive-plus-child pattern in it never reached disk. exit 0 } # Rule 2: allow-listed paths -> always allow if (Test-IsAllowListed -RelPath $rel) { Write-TransitionMetrics -Cwd $cwd -Config $config -Transitions $transitions -Decision 'allow' + Write-ComplexitySplitMetrics -Cwd $cwd -Config $config -Transitions $transitions -IndexPath (Join-Path $cwd $indexRel) -HookInput $hookInput exit 0 } diff --git a/install/README.md b/install/README.md index 7f14fab..48d6db6 100644 --- a/install/README.md +++ b/install/README.md @@ -4,11 +4,11 @@ The installer copies the engine (commands, agents, hooks, templates) into a Clau ``` / -├── commands/sd/ 13 slash commands +├── commands/sd/ 14 slash commands ├── agents/sd/ 6 subagent definitions ├── hooks/sd/ 3 hook scripts (.ps1 on Windows, .sh on Unix) -├── templates/sd/ 9 templates (4 setup + 5 spec) -└── skills/sd/ 8 skills (one folder per skill with SKILL.md) +├── templates/sd/ 10 templates (4 setup + 6 spec) +└── skills/sd/ 9 skills (one folder per skill with SKILL.md) ``` Default base is `$HOME/.claude` (Unix) or `$env:USERPROFILE\.claude` (Windows). @@ -46,14 +46,15 @@ Default base is `$HOME/.claude` (Unix) or `$env:USERPROFILE\.claude` (Windows). | Source (in repo) | Target (under `/`) | Files | Notes | |---|---|---|---| -| `commands/` | `commands/sd/` | 13 | `feature`, `bug`, `rca`, `refactor`, `perf`, `spec`, `explore`, `review`, `setup`, `release`, `adr`, `verify`, `status` | +| `commands/` | `commands/sd/` | 14 | `feature`, `bug`, `rca`, `refactor`, `perf`, `port`, `spec`, `explore`, `review`, `setup`, `release`, `adr`, `verify`, `status` | | `agents/` | `agents/sd/` | 6 | `sd-spec-architect`, `sd-code-explorer`, `sd-debugger`, `sd-implementer`, `sd-reviewer`, `sd-docs-writer` | | `hooks/powershell/` (Windows installer) | `hooks/sd/` | 3 | `prompt-router.ps1`, `spec-gate.ps1`, `subagent-retro.ps1` | | `hooks/bash/` (Unix installer) | `hooks/sd/` | 3 | `prompt-router.sh`, `spec-gate.sh`, `subagent-retro.sh` (chmod +x applied) | -| `templates/` | `templates/sd/` | 4 + 5 | Setup templates + `specs/` subfolder with 5 spec templates | -| `skills/` | `skills/sd/` | 8 | One folder per skill, each with a `SKILL.md` | +| `templates/` | `templates/sd/` | 4 + 6 | Setup templates + `specs/` subfolder with 6 spec templates | +| `skills/` | `skills/sd/` | 9 | One folder per skill, each with a `SKILL.md` | +| _(generated at install)_ | `/sd/specwright-version.txt` | 5 | One version stamp per installed area, derived from `CHANGELOG.md`'s newest release, removed by uninstall | -**Total**: 39 files per OS. +**Total**: 47 files per OS. --- @@ -81,19 +82,19 @@ After install, check the target directories: **Windows:** ```powershell -Get-ChildItem $env:USERPROFILE\.claude\commands\sd\ # expect 12 .md files +Get-ChildItem $env:USERPROFILE\.claude\commands\sd\ # expect 14 .md files Get-ChildItem $env:USERPROFILE\.claude\agents\sd\ # expect 6 .md files Get-ChildItem $env:USERPROFILE\.claude\hooks\sd\ # expect 3 .ps1 files -Get-ChildItem $env:USERPROFILE\.claude\templates\sd\ # expect 4 files + specs\ folder +Get-ChildItem $env:USERPROFILE\.claude\templates\sd\ # expect 5 files + specs\ folder ``` **Unix:** ```bash -ls ~/.claude/commands/sd/ # 13 .md files +ls ~/.claude/commands/sd/ # 14 .md files ls ~/.claude/agents/sd/ # 6 .md files ls ~/.claude/hooks/sd/ # 3 .sh files (executable) ls -l ~/.claude/hooks/sd/ # confirm +x bits set -ls ~/.claude/templates/sd/ # 4 files + specs/ folder +ls ~/.claude/templates/sd/ # 5 files + specs/ folder ``` Then in a real project: diff --git a/install/install.ps1 b/install/install.ps1 index 45eeceb..8fe54bb 100644 --- a/install/install.ps1 +++ b/install/install.ps1 @@ -75,6 +75,19 @@ function Get-FileHashSafe { } } +function Get-EngineVersion { + param([string]$RepoRoot) + $changelogPath = Join-Path $RepoRoot 'CHANGELOG.md' + if (-not (Test-Path -LiteralPath $changelogPath -PathType Leaf)) { + return $null + } + foreach ($line in (Get-Content -LiteralPath $changelogPath)) { + $m = [regex]::Match($line, '^##\s+\[([0-9]+\.[0-9]+\.[0-9]+)\]\s+-\s+\S') + if ($m.Success) { return $m.Groups[1].Value } + } + return $null +} + # ---- prefix safety guard --------------------------------------------------- # Mirrors uninstall.ps1's guard exactly - install and uninstall must accept the # same set of prefixes, or a prefix legal for one and rejected by the other @@ -127,6 +140,16 @@ if ($missing.Count -gt 0) { exit 1 } +# ---- resolve engine version ------------------------------------------------- + +$changelogPath = Join-Path $repoRoot 'CHANGELOG.md' +$engineVersion = Get-EngineVersion -RepoRoot $repoRoot +if ([string]::IsNullOrEmpty($engineVersion)) { + Write-Host '' + Write-Fail "$changelogPath : no dated release heading found (## [x.y.z] - )" + exit 1 +} + # ---- install plan ---------------------------------------------------------- # Each entry: source rel path, target rel path under BasePath, recursive copy @@ -239,6 +262,32 @@ foreach ($p in $plan) { } } +# ---- write version stamp ---------------------------------------------------- +# Route the resolved version through the existing Copy-OneFile so hash-skip, +# backup, decline and dry-run logic apply identically to every other file. +# Copy-OneFile returns a status string; increment counters the same way the +# main copy loop above does. + +$stampTempPath = [System.IO.Path]::GetTempFileName() +try { + $stampBytes = [System.Text.Encoding]::ASCII.GetBytes("$engineVersion`n") + [System.IO.File]::WriteAllBytes($stampTempPath, $stampBytes) + + foreach ($p in $plan) { + $targetRoot = Join-Path $BasePath $p.Target + $stampTarget = Join-Path $targetRoot 'specwright-version.txt' + $result = Copy-OneFile -SourceFile $stampTempPath -TargetFile $stampTarget + switch ($result) { + 'installed' { $installed++ } + 'same' { $skippedSame++ } + 'decline' { $skippedDecline++ } + 'plan' { $installed++ } # dry-run counts as planned + } + } +} finally { + Remove-Item -LiteralPath $stampTempPath -Force -ErrorAction SilentlyContinue +} + # ---- summary --------------------------------------------------------------- Write-Section 'Summary' diff --git a/install/install.sh b/install/install.sh index 9e1a558..815b71b 100755 --- a/install/install.sh +++ b/install/install.sh @@ -113,6 +113,21 @@ now_stamp() { date +'%Y%m%d-%H%M%S' } +engine_version() { + local repo_root="$1" + local changelog="$repo_root/CHANGELOG.md" + if [[ ! -f "$changelog" ]]; then + return 1 + fi + local line + line="$(grep -m1 -E '^##[[:space:]]+\[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]+-[[:space:]]+[^[:space:]]' "$changelog" || true)" + if [[ "$line" =~ \[([0-9]+\.[0-9]+\.[0-9]+)\] ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + # ---- repo root ------------------------------------------------------------- # Resolve script directory portably @@ -162,6 +177,16 @@ if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 warn "Neither sha256sum nor shasum found; content-hash dedup will be skipped (all existing files will be treated as differing)." fi +# ---- resolve engine version ------------------------------------------------- + +CHANGELOG_PATH="$REPO_ROOT/CHANGELOG.md" +ENGINE_VERSION="$(engine_version "$REPO_ROOT")" || true +if [[ -z "$ENGINE_VERSION" ]]; then + echo + fail "$CHANGELOG_PATH : no dated release heading found (## [x.y.z] - )" + exit 1 +fi + # ---- install plan ---------------------------------------------------------- # Format: "source_rel:target_rel:executable_flag" @@ -282,6 +307,22 @@ for entry in "${PLAN[@]}"; do done < <(find "$src_root" -type f -print0) done +# ---- write version stamp ---------------------------------------------------- +# Route the resolved version through the existing copy_one so hash-skip, +# backup, decline and dry-run logic apply identically to every other file. +# copy_one increments the counters itself; do not increment again here. The +# EXIT trap here is additional to (and does not replace) the ERR trap above. + +STAMP_TMP="$(mktemp)" +trap 'rm -f "$STAMP_TMP"' EXIT +printf '%s\n' "$ENGINE_VERSION" > "$STAMP_TMP" + +for entry in "${PLAN[@]}"; do + IFS=':' read -r src tgt _ <<< "$entry" + tgt_root="$BASE_PATH/$tgt" + copy_one "$STAMP_TMP" "$tgt_root/specwright-version.txt" "0" +done + # ---- summary --------------------------------------------------------------- section "Summary" diff --git a/scripts/contract-lint.ps1 b/scripts/contract-lint.ps1 new file mode 100644 index 0000000..f1c4a2b --- /dev/null +++ b/scripts/contract-lint.ps1 @@ -0,0 +1,1259 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Cross-file contract linter for the specwright ENGINE PRODUCT (Windows / PowerShell). + +.DESCRIPTION + Twin of scripts/contract-lint.sh. Both read specwright.manifest.json's + `contractLint` subtree and MUST report the same rule ids, in the same order, + for the same tree. Check 8 of scripts/validate.{ps1,sh} runs this as a child + process; tests/contract-lint/run-selftest.ps1 runs both and diffs them. + + Where validate's Check 7 guards INVENTORY (how many files exist), this guards + the RELATIONSHIPS between them: which agent a command invokes, which skill an + agent loads, how many hard gates a workflow declares. + + Wave 1 rule bands (the manifest's rules[] is the authoritative registry): + CL0xx reference resolution + CL3xx gate integrity + CL9xx suppression hygiene + + Output is TSV on stdout, one finding per line, and nothing else: + + Paths are root-relative with forward slashes. Sort order is ordinal on file, + then numeric line, then rule id, then message. The human-readable summary + goes to stderr and is never parsed or compared. + + Exit codes: + 0 no BLOCK findings + 1 at least one BLOCK finding + 2 cannot run (bad -Root, missing manifest, registry parity mismatch) + + Exit 2 is separate on purpose: a validator that cannot distinguish "clean" + from "crashed" is worthless. + + THIS FILE MUST STAY PURE ASCII. Check 1 of validate scans every *.ps1 + recursively, so this script self-polices. The gate marker is a non-ASCII + character and is NEVER encoded here - see Get-GateClassification. + +.PARAMETER Root + Tree to lint. Defaults to the repo this script lives in. The manifest is read + from /specwright.manifest.json, which is what lets a fixture tree + configure itself. + +.PARAMETER Rule + Comma-separated rule ids; filters the EMITTED findings only. Every rule still + runs, so CL902 (suppresses nothing) stays truthful. + +.PARAMETER Quiet + Suppress the stderr summary line. +#> + +[CmdletBinding()] +param( + [string]$Root = '', + [string]$Rule = '', + [switch]$Quiet +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ---- constants -------------------------------------------------------------- +# +# Every pattern below must behave identically in .NET and POSIX ERE (the twin). +# Use [0-9] not \d, [ \t] not \s, no lookarounds. All matching goes through +# [regex]::Match / [regex]::Matches - NEVER the -match operator, which is +# case-insensitive and would silently diverge from bash's case-sensitive grep. + +$RE_FENCE = '^[ \t]*```' +$RE_HEADING = '^(#{2,3}) (.+)$' +$RE_SDREF = 'sd-[a-z0-9]+(-[a-z0-9]+)*' +$RE_CMDREF = '/sd:[a-z][a-z0-9-]*' +$RE_TPLPATH = 'templates/[A-Za-z0-9_./-]+' +# The leading boundary alternative is load-bearing: without it the pattern also +# matches '07-cqrs-read-path.md' inside the ADR filename '0007-cqrs-read-path.md' +# (agents/docs-writer.md), which is not a spec artifact at all. +$RE_ARTIFACT = '(^|[^0-9A-Za-z_.-])[0-9][0-9]-[a-z0-9-]+\.md' +$RE_SUPPRESS = '', [System.StringComparison]::Ordinal) + if ($cut -ge 0) { $reason = $reason.Substring(0, $cut) } + # Measure the payload with separators removed, mirroring `tr -d ' \t-'`. + $bare = $reason.Replace(' ', '').Replace([string][char]9, '').Replace('-', '') + $bad = $false + if (-not $ruleIds.Contains($sRule)) { + $bad = $true + Add-Finding 'CL901' $rel ($i + 1) "suppression names unknown rule '$sRule'" + } elseif ($bare.Length -lt 10) { + Add-Finding 'CL900' $rel ($i + 1) "suppression for $sRule carries no usable reason" + } + [void]$suppressions.Add([PSCustomObject]@{ + File = $rel; Line = ($i + 1); Rule = $sRule; Used = $false; Bad = $bad + }) + } +} + +# ---- Phase B: rules --------------------------------------------------------- +# +# Each rule reads the index and calls Add-Finding. SEVERITY IS NEVER PASSED BY A +# RULE - it is looked up from the manifest at emit time, so a BLOCK/WARN +# divergence between the twins is structurally impossible. + +# CL002 owns the lines of an agent's `skills:` frontmatter list. Without this +# set, both CL001 and CL002 would fire on the same missing skill - one problem +# reported twice, the same "one error, not two" doctrine that exempts a +# CL901-flagged suppression from CL902. +$skillEntryLines = New-OrdinalSet +foreach ($a in $agentSkillRefs) { [void]$skillEntryLines.Add($a.File + ':' + $a.Line) } + +# CL001 / CL003 +foreach ($r in $refs) { + if ($r.Kind -cne 'sdref') { continue } + if ($agentNames.Contains($r.Target)) { continue } + if ($skillNames.Contains($r.Target)) { continue } + if ($skillEntryLines.Contains($r.File + ':' + $r.Line)) { continue } + $txt = $fileLines[$r.File][$r.Line - 1].ToLowerInvariant() + if ($txt.Contains('skill')) { + Add-Finding 'CL003' $r.File $r.Line "unresolved skill reference '$($r.Target)'" + } else { + Add-Finding 'CL001' $r.File $r.Line "unresolved sd- reference '$($r.Target)'" + } +} + +# CL002 +foreach ($a in $agentSkillRefs) { + $sm = Join-Path (Join-Path (Join-Path $Root 'skills') $a.Skill) 'SKILL.md' + if (Test-Path -LiteralPath $sm -PathType Leaf) { continue } + Add-Finding 'CL002' $a.File $a.Line "skills: entry '$($a.Skill)' has no skills/$($a.Skill)/SKILL.md" +} + +# CL004 +foreach ($s in $skillOrder) { + if ($skillConsumers.Contains($s)) { continue } + $self = $skillFileOf[$s] + $referenced = $false + foreach ($r in $refs) { + if ($r.Kind -cne 'sdref') { continue } + if ($r.Target -cne $s) { continue } + if ($r.File -ceq $self) { continue } + $referenced = $true; break + } + if (-not $referenced) { + foreach ($a in $agentSkillRefs) { + if ($a.Skill -ceq $s) { $referenced = $true; break } + } + } + if (-not $referenced) { + Add-Finding 'CL004' $self 1 "skill '$s' is referenced by nothing in scan scope" + } +} + +# CL005 +$nsPrefix = 'templates/' + $nsSegment + '/' +foreach ($r in $refs) { + if ($r.Kind -cne 'templatePath') { continue } + $p = $r.Target + # templates//... is the INSTALL target (~/.claude/templates/sd/), not a + # repo path. Fold the namespace segment away before testing disk. + if ($p.StartsWith($nsPrefix, [System.StringComparison]::Ordinal)) { + $p = 'templates/' + $p.Substring($nsPrefix.Length) + } elseif ($p -ceq ('templates/' + $nsSegment)) { + $p = 'templates' + } + if ($p.EndsWith('.', [System.StringComparison]::Ordinal)) { $p = $p.Substring(0, $p.Length - 1) } + if ($p.EndsWith('/', [System.StringComparison]::Ordinal)) { $p = $p.Substring(0, $p.Length - 1) } + $abs = Join-Path $Root $p.Replace('/', [System.IO.Path]::DirectorySeparatorChar) + if (Test-Path -LiteralPath $abs) { continue } + Add-Finding 'CL005' $r.File $r.Line "templates path does not exist: '$($r.Target)'" +} + +# CL006 +foreach ($r in $refs) { + if ($r.Kind -cne 'commandRef') { continue } + $name = $r.Target.Substring(4) + if ($commandNames.Contains($name)) { continue } + Add-Finding 'CL006' $r.File $r.Line "no command file for '$($r.Target)'" +} + +# CL007 +foreach ($a in $agentOrder) { + $seen = $false + foreach ($r in $refs) { + if ($r.Kind -cne 'sdref') { continue } + if ($r.Target -cne $a) { continue } + if ($r.File.StartsWith('commands/', [System.StringComparison]::Ordinal)) { $seen = $true; break } + } + if (-not $seen) { + Add-Finding 'CL007' $agentFileOf[$a] 1 "agent '$a' is invoked by no command" + } +} + +# CL008 +foreach ($r in $refs) { + if ($r.Kind -cne 'specArtifact') { continue } + if ($specArtifacts.Contains($r.Target)) { continue } + Add-Finding 'CL008' $r.File $r.Line "unknown spec artifact filename '$($r.Target)'" +} + +# CL100 / CL102 / CL103 - each invocation against the mode it names. A target +# agent that CL001 already flagged as unresolved gets no CL100 pile-on. +foreach ($inv in $invocations) { + if (-not $agentNames.Contains($inv.Agent)) { continue } + $md = $null + foreach ($d in $modeDecls) { + if ($d.Agent -cne $inv.Agent) { continue } + if ($d.Key -cne $inv.Key) { continue } + if ($d.Value -cne $inv.Value) { continue } + $md = $d; break + } + if ($null -eq $md) { + Add-Finding 'CL100' $inv.File $inv.Line "invocation sets $($inv.Key) = $($inv.Value) but agent '$($inv.Agent)' declares no such mode" + continue + } + foreach ($t in $md.Required) { + if ($inv.Tokens.Contains($t)) { continue } + Add-Finding 'CL102' $inv.File $inv.Line "invocation of '$($inv.Agent)' ($($inv.Key) = $($inv.Value)) omits required input '$t'" + } + foreach ($t in $inv.Tokens) { + if ($t -ceq $inv.Key) { continue } + if ($md.Required.Contains($t) -or $md.Optional.Contains($t)) { continue } + Add-Finding 'CL103' $inv.File $inv.Line "invocation of '$($inv.Agent)' ($($inv.Key) = $($inv.Value)) passes undeclared input '$t'" + } +} + +# CL101 - the inverse of CL100: a declared mode nobody ever selects. +foreach ($d in $modeDecls) { + $seen = $false + foreach ($inv in $invocations) { + if ($inv.Agent -cne $d.Agent) { continue } + if ($inv.Key -cne $d.Key) { continue } + if ($inv.Value -cne $d.Value) { continue } + $seen = $true; break + } + if (-not $seen) { + Add-Finding 'CL101' $d.File $d.Line "agent '$($d.Agent)' declares mode $($d.Key) = $($d.Value) that no command ever invokes" + } +} + +# CL200 - disk-only, no manifest input. Any agent whose OWN tools: line carries +# none of Write/Edit/MultiEdit is a candidate; its body is then scanned for a +# line-initial Write/Append/Create, which is what lets every negated, +# third-person or mid-sentence use of those words pass untouched. +foreach ($name in $agentOrder) { + $hasWriteTool = $false + foreach ($t in $agentToolRefs) { + if ($t.Agent -cne $name) { continue } + if ($writeTools.Contains($t.Tool)) { $hasWriteTool = $true; break } + } + if ($hasWriteTool) { continue } + $rel = $agentFileOf[$name] + $lines = $fileLines[$rel] + $fence = $fileFence[$rel] + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($fence[$i]) { continue } + $mv = [regex]::Match($lines[$i], $RE_WRITEVERB) + if (-not $mv.Success) { continue } + $verb = $mv.Groups[2].Value + Add-Finding 'CL200' $rel ($i + 1) "agent '$name' has no write tool but this line instructs it to $verb" + } +} + +# CL201 - the declared-vs-disk half: an agent this manifest promises will stay +# read-only, but whose own tools: line has grown a write tool since. +foreach ($t in $agentToolRefs) { + if (-not $readOnlyAgents.Contains($t.Agent)) { continue } + if (-not $writeTools.Contains($t.Tool)) { continue } + Add-Finding 'CL201' $t.File $t.Line "agent '$($t.Agent)' is declared read-only in contractLint.readOnlyAgents but its tools: line includes write tool '$($t.Tool)'" +} + +# CL202 - every mcp__* token anywhere in scan scope (frontmatter tools: lines +# included, which is exactly where the historical typo'd tool name lived) +# against the hand-maintained allowlist. +foreach ($r in $refs) { + if ($r.Kind -cne 'mcpTool') { continue } + if ($knownMcpTools.Contains($r.Target)) { continue } + Add-Finding 'CL202' $r.File $r.Line "mcp tool name '$($r.Target)' is absent from contractLint.knownMcpTools" +} + +# CL203 - a tool this agent's own frontmatter declares, that its own body +# (everything after the closing ---) never mentions by name. +foreach ($t in $agentToolRefs) { + $lines = $fileLines[$t.File] + $start = $agentBodyStart[$t.Agent] + $used = $false + for ($i = $start; $i -lt $lines.Length; $i++) { + if ($lines[$i].IndexOf($t.Tool, [System.StringComparison]::Ordinal) -ge 0) { $used = $true; break } + } + if (-not $used) { + Add-Finding 'CL203' $t.File $t.Line "agent '$($t.Agent)' declares tool '$($t.Tool)' but its body never mentions it" + } +} + +# The seven steps below are a CONTRACT with contract-lint.sh's normalize_option. +# Both must produce byte-identical tokens or CL305 diverges between the twins. +# 1. truncate at the first backtick 5. drop every ` and " character +# 2. trim spaces/tabs 6. lowercase A-Z only +# 3. truncate at the first " - " 7. trim spaces/tabs again +# 4. truncate at the first " <" +function Get-NormalizedOption([string]$Raw) { + $trimChars = [char[]]@([char]32, [char]9) + $s = $Raw + $i = $s.IndexOf('`', [System.StringComparison]::Ordinal) + if ($i -ge 0) { $s = $s.Substring(0, $i) } + $s = $s.Trim($trimChars) + $i = $s.IndexOf(' - ', [System.StringComparison]::Ordinal) + if ($i -ge 0) { $s = $s.Substring(0, $i) } + $i = $s.IndexOf(' <', [System.StringComparison]::Ordinal) + if ($i -ge 0) { $s = $s.Substring(0, $i) } + $s = $s.Replace('`', '').Replace('"', '').ToLowerInvariant() + return $s.Trim($trimChars) +} + +function Get-GateOptions([string]$Rel, [int]$StartLine, [int]$BlockEnd) { + $lines = $fileLines[$Rel] + $tokens = New-Object 'System.Collections.Generic.List[object]' + $hasSet = $false + $bullets = 0 + for ($i = $StartLine - 1; $i -lt $BlockEnd; $i++) { + $line = $lines[$i] + $mp = [regex]::Match($line, $RE_OPTPAREN) + if ($mp.Success) { + $hasSet = $true + $inner = $mp.Value + $inner = $inner.Substring(1, $inner.Length - 2) + foreach ($piece in $inner.Split([char]47)) { + $tok = Get-NormalizedOption $piece + if ($tok.Length -gt 0) { + [void]$tokens.Add([PSCustomObject]@{ Line = ($i + 1); Token = $tok }) + } + } + } + if ([regex]::IsMatch($line, $RE_BULLET)) { + $bullets = $bullets + 1 + $mb = [regex]::Match($line, $RE_BULLETTOK) + if ($mb.Success) { + $tok = Get-NormalizedOption $mb.Groups[1].Value + if ($tok.Length -gt 0) { + [void]$tokens.Add([PSCustomObject]@{ Line = ($i + 1); Token = $tok }) + } + } + } + } + if ($bullets -ge 2) { $hasSet = $true } + return @{ HasSet = $hasSet; Tokens = $tokens } +} + +# CL300 / CL301 / CL305 / CL306 +foreach ($g in $gates) { + $lines = $fileLines[$g.File] + $fence = $fileFence[$g.File] + $hasStop = $false + for ($i = $g.Line - 1; $i -lt $g.BlockEnd; $i++) { + if ($lines[$i].Contains('STOP')) { $hasStop = $true; break } + } + if (-not $hasStop) { + Add-Finding 'CL300' $g.File $g.Line 'gate block contains no literal STOP' + } + $opts = Get-GateOptions $g.File $g.Line $g.BlockEnd + if (-not $opts.HasSet) { + Add-Finding 'CL301' $g.File $g.Line 'gate block offers no option set' + } + if ($g.HardMarked) { + foreach ($t in $opts.Tokens) { + if ($overrideTokens.Contains($t.Token)) { + Add-Finding 'CL305' $g.File $t.Line "HARD gate offers override option '$($t.Token)'" + } + } + # CL306 - a naive phrase scan over gate-block PROSE, deliberately + # excluding whatever CL305 already governs: CLAUDE.md rule 6 frames + # "listed as a choice" and "described in prose" as the two mutually + # exclusive halves of a HARD gate's escape-hatch surface, so a line + # that is itself part of the option-set syntax (the slash + # parenthetical, or a backtick-led option bullet) is CL305's + # territory, not CL306's - without this exclusion, perf.md's already + # CL305-suppressed Case A ('proceed anyway' inside the option set) + # false-fires CL306 too, on both the option line and its own + # suppression comment's reason text. It fires on commands/bug.md's + # logged insist-and-proceed sentence and commands/release.md's "may + # override the version" bullet (neither is option-set syntax) unless + # annotated - fixing that is the point of this rule, not a bug in it. + # See gateProseEscapeTokens in the manifest. + for ($i = $g.Line - 1; $i -lt $g.BlockEnd; $i++) { + if ($fence[$i]) { continue } + if ([regex]::IsMatch($lines[$i], $RE_SUPPRESS)) { continue } + if ([regex]::IsMatch($lines[$i], $RE_OPTPAREN)) { continue } + if ([regex]::IsMatch($lines[$i], $RE_BULLETTOK)) { continue } + foreach ($phrase in $gateProseEscapeTokens) { + if ($lines[$i].Contains($phrase)) { + Add-Finding 'CL306' $g.File ($i + 1) "HARD gate prose describes an escape hatch ('$phrase')" + break + } + } + } + } +} + +# CL400 / CL401 / CL402 - stack-agnostic prose + hardcoded absolute paths. +# One line-scan pass over scanScope, deliberately narrow (word-bounded +# vocabulary hits, positive-boundary path regex) to keep the false-positive +# band this wave occupies under control - see the manifest's +# $stackTokensComment. +foreach ($rel in $scanFiles) { + $lines = $fileLines[$rel] + $fence = $fileFence[$rel] + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($fence[$i]) { continue } + $scrubbed = [regex]::Replace($lines[$i], $RE_PLACEHOLDER, '') + foreach ($tok in $stackCommands) { + $pat = '(^|[^A-Za-z0-9_])' + [regex]::Escape($tok) + '([^A-Za-z0-9_]|$)' + if ([regex]::IsMatch($scrubbed, $pat)) { + Add-Finding 'CL400' $rel ($i + 1) "hardcoded stack command token '$tok'" + } + } + foreach ($tok in $stackLanguages) { + $pat = '(^|[^A-Za-z0-9_])' + [regex]::Escape($tok) + '([^A-Za-z0-9_]|$)' + if ([regex]::IsMatch($scrubbed, $pat)) { + Add-Finding 'CL401' $rel ($i + 1) "hardcoded language/framework name '$tok'" + } + } + $mw = [regex]::Match($scrubbed, $RE_ABSPATH_WIN) + if ($mw.Success) { + Add-Finding 'CL402' $rel ($i + 1) "hardcoded absolute path '$($mw.Groups[2].Value)'" + } else { + $mp = [regex]::Match($scrubbed, $RE_ABSPATH_POSIX) + if ($mp.Success) { + Add-Finding 'CL402' $rel ($i + 1) "hardcoded absolute path '$($mp.Groups[2].Value)'" + } + } + } +} + +# CL500 - file budgets. Byte count is NORMALIZED (sum of each line's UTF-8 +# byte length, plus one separator per line boundary), never a raw disk read: +# this repo's *.md scanScope is 'text=auto', checking out LF on Linux CI and +# CRLF on Windows, so [System.IO.File]::ReadAllBytes(...).Length would make +# CL500 disagree with itself across platforms for byte-identical content - +# see the manifest's $budgetsComment. +foreach ($rel in $scanFiles) { + $budget = Get-BudgetForFile $rel + if ($null -eq $budget) { continue } + $lines = $fileLines[$rel] + $bytes = 0 + for ($i = 0; $i -lt $lines.Length; $i++) { + $bytes += [System.Text.Encoding]::UTF8.GetByteCount($lines[$i]) + } + if ($lines.Length -gt 1) { $bytes += ($lines.Length - 1) } + if ($bytes -gt $budget) { + $over = $bytes - $budget + Add-Finding 'CL500' $rel 1 "file is $bytes bytes, $over over the $budget-byte budget" + } +} + +# CL302 / CL303 / CL304 +foreach ($rel in $scanFiles) { + $count = 0 + $labels = New-Object 'System.Collections.Generic.List[int]' + foreach ($g in $gates) { + if ($g.File -cne $rel) { continue } + if ($g.Kind -cne 'hard') { continue } + $count = $count + 1 + if ($g.Label.Length -gt 0) { [void]$labels.Add([int]$g.Label) } + } + + $declHard = 0 + $declCond = @() + if ($gateFiles.Contains($rel)) { + $declHard = $gateHard[$rel] + $declCond = $gateCond[$rel] + } + if ($count -ne $declHard) { + Add-Finding 'CL302' $rel 1 "hard gate count is $count on disk, manifest declares $declHard" + } + + # CL303 is SET-based, never file order: commands/bug.md authors + # '### Gate 3a' before '### Gate 3' and must still pass. + if ($labels.Count -gt 0) { + $sorted = @($labels | Sort-Object) + $bad = $false + $seen = New-OrdinalSet + foreach ($v in $sorted) { + if (-not $seen.Add([string]$v)) { $bad = $true } + } + $want = 1 + foreach ($v in $sorted) { + if ($v -ne $want) { $bad = $true; break } + $want = $want + 1 + } + if ($bad) { + Add-Finding 'CL303' $rel 1 "hard gate numbering is not 1..$($labels.Count) without duplicates" + } + } + + # CL304 - symmetric set difference, both directions BLOCK. The + # declared-but-absent half is the anti-rot direction. + $onDisk = New-OrdinalSet + $declSet = New-OrdinalSet + foreach ($c in $declCond) { [void]$declSet.Add($c) } + foreach ($g in $gates) { + if ($g.File -cne $rel) { continue } + if ($g.Kind -cne 'conditional') { continue } + [void]$onDisk.Add($g.Label) + if (-not $declSet.Contains($g.Label)) { + Add-Finding 'CL304' $rel $g.Line "conditional gate '$($g.Label)' is not declared in the manifest" + } + } + foreach ($c in $declCond) { + if (-not $onDisk.Contains($c)) { + Add-Finding 'CL304' $rel 1 "manifest declares conditional gate '$c' but it is absent from disk" + } + } +} + +# ---- Phase C: suppressions, sort, emit ------------------------------------- +# +# A suppression can never suppress CL900, CL901 or CL902 - otherwise +# '' would be a self-authorizing loophole. +# That exclusion is hardcoded, never manifest-driven. + +$kept = New-Object 'System.Collections.Generic.List[object]' +foreach ($f in $findings) { + $hit = $false + if ($f.Rule -cne 'CL900' -and $f.Rule -cne 'CL901' -and $f.Rule -cne 'CL902') { + foreach ($s in $suppressions) { + if ($s.Bad) { continue } + if ($s.File -cne $f.File) { continue } + if ($s.Rule -cne $f.Rule) { continue } + if ($s.Line -eq $f.Line -or $s.Line -eq ($f.Line - 1)) { + $s.Used = $true + $hit = $true + break + } + } + } + if (-not $hit) { [void]$kept.Add($f) } +} +$findings = $kept + +# CL902 runs LAST, over the used flags. A CL901-flagged suppression is exempt - +# one error per broken suppression, never two. +foreach ($s in $suppressions) { + if ($s.Bad) { continue } + if ($s.Used) { continue } + Add-Finding 'CL902' $s.File $s.Line "suppression for $($s.Rule) suppressed nothing" +} + +$blocks = 0 +$warns = 0 +$rows = New-Object 'System.Collections.Generic.List[string]' +foreach ($f in $findings) { + if ($ruleFilter.Count -gt 0 -and -not $ruleFilter.Contains($f.Rule)) { continue } + $sev = 'BLOCK' + if ($ruleSeverity.ContainsKey($f.Rule)) { $sev = $ruleSeverity[$f.Rule] } + if ($sev -ceq 'BLOCK') { $blocks = $blocks + 1 } else { $warns = $warns + 1 } + # Sort key: file, then zero-padded line so lexical order IS numeric order, + # then rule id, then message. The twin builds the identical key and sorts it + # byte-wise, which is what makes the two outputs comparable line for line. + $key = '{0}{1}{2:D9}{1}{3}{1}{4}' -f $f.File, [char]1, $f.Line, $f.Rule, $f.Message + $row = '{0}{1}{2}{1}{3}{1}{4}{1}{5}' -f $f.Rule, [char]9, $sev, $f.File, $f.Line, $f.Message + [void]$rows.Add(($key + [char]9 + $row)) +} +$rows.Sort([StringComparer]::Ordinal) + +foreach ($r in $rows) { + # Write-Output, never [Console]::Out.WriteLine: the latter writes straight to + # the console handle and silently bypasses PowerShell's '>' redirection, so + # the parity capture in run-selftest.ps1 would collect an empty file while + # the findings scrolled past on screen. + $tab = $r.IndexOf([char]9) + Write-Output $r.Substring($tab + 1) +} + +if (-not $Quiet) { + Write-Err "contract-lint: $blocks block, $warns warn (root: $Root)" +} + +if ($blocks -gt 0) { exit 1 } +exit 0 diff --git a/scripts/contract-lint.sh b/scripts/contract-lint.sh new file mode 100644 index 0000000..92a9617 --- /dev/null +++ b/scripts/contract-lint.sh @@ -0,0 +1,1537 @@ +#!/usr/bin/env bash +# Cross-file contract linter for the specwright ENGINE PRODUCT (Unix / bash). +# +# Twin of scripts/contract-lint.ps1. Both read specwright.manifest.json's +# `contractLint` subtree and MUST report the same rule ids, in the same order, +# for the same tree. Check 8 of scripts/validate.{sh,ps1} runs this as a child +# process; tests/contract-lint/run-selftest.ps1 runs both and diffs them. +# +# Where validate's Check 7 guards INVENTORY (how many files exist), this guards +# the RELATIONSHIPS between them: which agent a command invokes, which skill an +# agent loads, how many hard gates a workflow declares. +# +# Wave 1 rule bands (see the manifest's rules[] for the authoritative registry): +# CL0xx reference resolution +# CL3xx gate integrity +# CL9xx suppression hygiene +# +# Usage: +# contract-lint.sh [--root ] [--rule ] [--quiet] +# +# --root tree to lint (default: the repo this script lives in). The manifest +# is read from /specwright.manifest.json, which is what lets a +# fixture tree configure itself. +# --rule comma-separated rule ids; filters the EMITTED findings only. Every +# rule still runs, so CL902 (suppresses nothing) stays truthful. +# --quiet suppress the stderr summary line. +# +# Output is TSV on stdout, one finding per line, and nothing else: +# \t\t\t\t +# Paths are root-relative with forward slashes. Sort order is byte order on +# file, then numeric line, then rule id. The human-readable summary goes to +# stderr and is never parsed or compared. +# +# Exit codes: +# 0 no BLOCK findings +# 1 at least one BLOCK finding +# 2 cannot run (bad --root, missing manifest, missing jq, registry mismatch) +# +# Exit 2 is separate on purpose: a validator that cannot distinguish "clean" +# from "crashed" is worthless. + +set -euo pipefail + +# Byte-indexed string ops and byte-order sorting. The gate-marker strip below +# slices a leading run of non-ASCII BYTES; under a UTF-8 locale ${var:n} would +# slice characters instead and the two implementations would diverge. +export LC_ALL=C + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$script_dir/.." && pwd)" +RULE_FILTER="" +QUIET=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) ROOT="${2:-}"; shift 2 ;; + --rule) RULE_FILTER="${2:-}"; shift 2 ;; + --quiet) QUIET=1; shift ;; + -h|--help) + grep -E '^# ' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "contract-lint: unknown argument '$1'" >&2 + exit 2 ;; + esac +done + +if [[ -z "$ROOT" || ! -d "$ROOT" ]]; then + echo "contract-lint: --root is not a directory: '$ROOT'" >&2 + exit 2 +fi +ROOT="$(cd "$ROOT" && pwd)" + +MANIFEST="$ROOT/specwright.manifest.json" +if [[ ! -f "$MANIFEST" ]]; then + echo "contract-lint: manifest not found: $MANIFEST" >&2 + exit 2 +fi +if ! command -v jq >/dev/null 2>&1; then + # Hooks exit 0 silently without jq so they never block a user on their own + # bugs. A linter must do the opposite - a silent pass would turn CI green + # while checking nothing. + echo "contract-lint: jq is required to parse specwright.manifest.json" >&2 + exit 2 +fi + +# Some jq builds (notably jq.exe on Windows) emit CRLF. An unstripped \r rides +# on the last field of every record and silently breaks set membership. +mjq() { jq -r "$1" "$MANIFEST" | tr -d '\r'; } + +# ---- bash 3.2 collections --------------------------------------------------- +# +# macOS ships /bin/bash 3.2: no `declare -A`, no `mapfile`, no `${var^^}`. +# Two shapes only: +# * SETS - one newline-delimited global per set, membership-tested in-process +# with `case` glob matching. No subshell, no loop, no eval. +# * RECORD TABLES - one newline-delimited scalar of records whose fields are +# separated by US (0x1f), iterated with `while IFS=$'\x1f' read ...`. +# US, not TAB: TAB is an IFS *whitespace* character, so bash collapses runs +# of them and one empty middle field shifts every later field left. +# Nothing here is looked up by key at O(1); the sets are small and the tables +# are walked, so a linear scan is the right shape. + +set_has() { # set_var_name value -> 0 if present + local _set="${!1}" _val="$2" + case $'\n'"$_set"$'\n' in + *$'\n'"$_val"$'\n'*) return 0 ;; + esac + return 1 +} + +set_add() { # set_var_name value + local _cur="${!1}" + if [[ -z "$_cur" ]]; then + printf -v "$1" '%s' "$2" + else + printf -v "$1" '%s\n%s' "$_cur" "$2" + fi +} + +# CL1xx token sets: comma-joined, never newline-delimited (a mode's token set is +# small and always inline in one record-table field). "none" (the literal the +# Inputs (optional): line uses for an empty set) normalizes to the empty string. +normalize_tokens() { # raw "A, B, C" or "none" -> stdout "A,B,C" + local _raw="$1" + [[ "$_raw" == "none" ]] && return + printf '%s' "$_raw" | tr -d ' \t' +} + +csv_has() { # csv value -> 0 if present + local _csv="$1" _val="$2" + case ",$_csv," in + *",$_val,"*) return 0 ;; + esac + return 1 +} + +# ERE-escape a hand-maintained vocabulary token before it is spliced into a +# built regex. A plain bash character loop, never sed: GNU sed's bracket +# expression treats a LEADING '.' right after '[' as the start of a +# '[.symbol.]' collating construct, not a literal dot, which silently +# corrupts the escape for '.NET' specifically. This loop has no such +# ambiguity, and - critically - callers must invoke it ONCE per token and +# cache the result: `$(ere_escape ...)` forks a subshell, and CL400/CL401 +# test every token against every scanScope line, so calling it from inside +# that per-line loop turned a few dozen forks into ~135k and blew the +# runtime past two minutes on the Windows runner. +ere_escape() { # raw_token -> stdout ERE-escaped + local _s="$1" _out="" _c _i + for ((_i = 0; _i < ${#_s}; _i++)); do + _c="${_s:_i:1}" + case "$_c" in + '.'|'\'|'*'|'^'|'$'|'['|']'|'('|')'|'+'|'?'|'{'|'}'|'|'|'/') + _out="${_out}\\${_c}" ;; + *) + _out="${_out}${_c}" ;; + esac + done + printf '%s' "$_out" +} + +# ---- regex constants -------------------------------------------------------- +# +# Every pattern below must behave identically in POSIX ERE (here) and .NET +# (the twin). Use [0-9] not \d, [[:blank:]] not \s, no lookarounds. + +RE_FENCE='^[[:blank:]]*```' +RE_HEADING='^(#{2,3}) (.+)$' +RE_SDREF='sd-[a-z0-9]+(-[a-z0-9]+)*' +RE_CMDREF='/sd:[a-z][a-z0-9-]*' +RE_TPLPATH='templates/[A-Za-z0-9_./-]+' +# The leading boundary alternative is load-bearing: without it the pattern also +# matches '07-cqrs-read-path.md' inside the ADR filename '0007-cqrs-read-path.md' +# (agents/docs-writer.md), which is not a spec artifact at all. +RE_ARTIFACT='(^|[^0-9A-Za-z_.-])[0-9][0-9]-[a-z0-9-]+\.md' +RE_SUPPRESS='*}" + # Strip a leading separator, then measure the non-space payload. + _bare="$(printf '%s' "$_reason" | tr -d ' \t-')" + s_file[$S_N]="$_rel" + s_line[$S_N]=$((_i + 1)) + s_rule[$S_N]="$_rule" + s_used[$S_N]=0 + s_bad[$S_N]=0 + if ! set_has RULE_IDS "$_rule"; then + s_bad[$S_N]=1 + add_finding CL901 "$_rel" "$((_i + 1))" "suppression names unknown rule '$_rule'" + elif [[ ${#_bare} -lt 10 ]]; then + add_finding CL900 "$_rel" "$((_i + 1))" "suppression for $_rule carries no usable reason" + fi + S_N=$((S_N + 1)) + done + done <<< "$SCAN_FILES" +} + +collect_suppressions + +# ---- Phase B: rules --------------------------------------------------------- +# +# Each rule takes no arguments, reads the index, and calls add_finding. SEVERITY +# IS NEVER PASSED BY A RULE - it is looked up from the manifest at emit time, so +# a BLOCK/WARN divergence between the twins is structurally impossible. + +line_text() { # file line -> stdout (1-based) + local _rel="$1" _n="$2" + if [[ "$CUR_REL" != "$_rel" ]]; then load_file "$_rel"; fi + printf '%s' "${CUR_LINES[$((_n - 1))]}" +} + +# True when (file, line) is an entry in an agent's `skills:` frontmatter list. +# CL002 owns those lines; without this both CL001 and CL002 would fire on the +# same missing skill, which is one problem reported twice - the same "one error, +# not two" doctrine that exempts a CL901 suppression from CL902. +is_agent_skill_entry() { # file line + local _f="$1" _l="$2" _af _al _as + while IFS=$'\x1f' read -r _af _al _as; do + if [[ "$_af" == "$_f" && "$_al" == "$_l" ]]; then return 0; fi + done <<< "$AGENT_SKILL_REFS" + return 1 +} + +rule_CL001_CL003() { + local _k _t _f _l _txt _lower + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "sdref" ]] || continue + set_has AGENT_NAMES "$_t" && continue + set_has SKILL_NAMES "$_t" && continue + is_agent_skill_entry "$_f" "$_l" && continue + _txt="$(line_text "$_f" "$_l")" + _lower="$(printf '%s' "$_txt" | tr 'A-Z' 'a-z')" + case "$_lower" in + *skill*) + add_finding CL003 "$_f" "$_l" "unresolved skill reference '$_t'" ;; + *) + add_finding CL001 "$_f" "$_l" "unresolved sd- reference '$_t'" ;; + esac + done <<< "$REFS" +} + +rule_CL002() { + local _f _l _s + while IFS=$'\x1f' read -r _f _l _s; do + [[ -z "$_f" ]] && continue + [[ -f "$ROOT/skills/$_s/SKILL.md" ]] && continue + add_finding CL002 "$_f" "$_l" "skills: entry '$_s' has no skills/$_s/SKILL.md" + done <<< "$AGENT_SKILL_REFS" +} + +rule_CL004() { + local _s _self _k _t _f _l _referenced _af _al _as + while IFS=$'\x1f' read -r _s _self; do + [[ -z "$_s" ]] && continue + set_has SKILL_CONSUMERS "$_s" && continue + _referenced=0 + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "sdref" ]] || continue + [[ "$_t" == "$_s" ]] || continue + [[ "$_f" == "$_self" ]] && continue + _referenced=1; break + done <<< "$REFS" + if [[ $_referenced -eq 0 ]]; then + while IFS=$'\x1f' read -r _af _al _as; do + if [[ "$_as" == "$_s" ]]; then _referenced=1; break; fi + done <<< "$AGENT_SKILL_REFS" + fi + if [[ $_referenced -eq 0 ]]; then + add_finding CL004 "$_self" 1 "skill '$_s' is referenced by nothing in scan scope" + fi + done <<< "$SKILL_NAME_FILE_TABLE" +} + +rule_CL005() { + local _k _t _f _l _p + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "templatePath" ]] || continue + _p="$_t" + # templates//... is the INSTALL target (~/.claude/templates/sd/), + # not a repo path. Fold the namespace segment away before testing disk. + case "$_p" in + "templates/$NS_SEGMENT/"*) _p="templates/${_p#"templates/$NS_SEGMENT/"}" ;; + "templates/$NS_SEGMENT") _p="templates" ;; + esac + _p="${_p%.}" + _p="${_p%/}" + [[ -e "$ROOT/$_p" ]] && continue + add_finding CL005 "$_f" "$_l" "templates path does not exist: '$_t'" + done <<< "$REFS" +} + +rule_CL006() { + local _k _t _f _l _name + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "commandRef" ]] || continue + _name="${_t#/sd:}" + set_has COMMAND_NAMES "$_name" && continue + add_finding CL006 "$_f" "$_l" "no command file for '$_t'" + done <<< "$REFS" +} + +rule_CL007() { + local _a _af _k _t _f _l _seen + while IFS=$'\x1f' read -r _a _af; do + [[ -z "$_a" ]] && continue + _seen=0 + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "sdref" ]] || continue + [[ "$_t" == "$_a" ]] || continue + case "$_f" in commands/*) _seen=1; break ;; esac + done <<< "$REFS" + if [[ $_seen -eq 0 ]]; then + add_finding CL007 "$_af" 1 "agent '$_a' is invoked by no command" + fi + done <<< "$AGENT_NAME_FILE_TABLE" +} + +rule_CL008() { + local _k _t _f _l + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "specArtifact" ]] || continue + set_has SPEC_ARTIFACTS "$_t" && continue + add_finding CL008 "$_f" "$_l" "unknown spec artifact filename '$_t'" + done <<< "$REFS" +} + +# CL100 / CL102 / CL103 - each invocation against the mode it names. A target +# agent that CL001 already flagged as unresolved gets no CL100 pile-on. +rule_CL100_CL102_CL103() { + local _ia _ik _iv _if _il _itok _da _dk _dv _df _dl _dreq _dopt + local _found _t _k + while IFS=$'\x1f' read -r _ia _ik _iv _if _il _itok; do + [[ -z "$_ia" ]] && continue + set_has AGENT_NAMES "$_ia" || continue + _found=0 + while IFS=$'\x1f' read -r _da _dk _dv _df _dl _dreq _dopt; do + [[ "$_da" == "$_ia" && "$_dk" == "$_ik" && "$_dv" == "$_iv" ]] || continue + _found=1 + break + done <<< "$MODE_DECLS" + if [[ $_found -eq 0 ]]; then + add_finding CL100 "$_if" "$_il" "invocation sets $_ik = $_iv but agent '$_ia' declares no such mode" + continue + fi + if [[ -n "$_dreq" ]]; then + while IFS= read -r _t; do + [[ -z "$_t" ]] && continue + csv_has "$_itok" "$_t" && continue + add_finding CL102 "$_if" "$_il" "invocation of '$_ia' ($_ik = $_iv) omits required input '$_t'" + done <<< "$(printf '%s' "$_dreq" | tr ',' '\n')" + fi + if [[ -n "$_itok" ]]; then + while IFS= read -r _k; do + [[ -z "$_k" ]] && continue + [[ "$_k" == "$_ik" ]] && continue + csv_has "$_dreq" "$_k" && continue + csv_has "$_dopt" "$_k" && continue + add_finding CL103 "$_if" "$_il" "invocation of '$_ia' ($_ik = $_iv) passes undeclared input '$_k'" + done <<< "$(printf '%s' "$_itok" | tr ',' '\n')" + fi + done <<< "$INVOCATIONS" +} + +# CL101 - the inverse of CL100: a declared mode nobody ever selects. +rule_CL101() { + local _da _dk _dv _df _dl _dreq _dopt _seen + local _ia _ik _iv _iif _iil _iitok + while IFS=$'\x1f' read -r _da _dk _dv _df _dl _dreq _dopt; do + [[ -z "$_da" ]] && continue + _seen=0 + while IFS=$'\x1f' read -r _ia _ik _iv _iif _iil _iitok; do + [[ "$_ia" == "$_da" && "$_ik" == "$_dk" && "$_iv" == "$_dv" ]] || continue + _seen=1; break + done <<< "$INVOCATIONS" + if [[ $_seen -eq 0 ]]; then + add_finding CL101 "$_df" "$_dl" "agent '$_da' declares mode $_dk = $_dv that no command ever invokes" + fi + done <<< "$MODE_DECLS" +} + +# CL200 - disk-only, no manifest input. Any agent whose OWN tools: line carries +# none of Write/Edit/MultiEdit is a candidate; its body is then scanned for a +# line-initial Write/Append/Create, which is what lets every negated, +# third-person or mid-sentence use of those words pass untouched. +rule_CL200() { + local _name _file _has_write _at _atool _afile _aline _i _verb + while IFS=$'\x1f' read -r _name _file; do + [[ -z "$_name" ]] && continue + _has_write=0 + while IFS=$'\x1f' read -r _at _atool _afile _aline; do + [[ "$_at" == "$_name" ]] || continue + if is_write_tool "$_atool"; then _has_write=1; break; fi + done <<< "$AGENT_TOOL_REFS" + [[ $_has_write -eq 1 ]] && continue + load_file "$_file" + for ((_i = 0; _i < CUR_N; _i++)); do + [[ "${CUR_FENCE[$_i]}" == "1" ]] && continue + [[ "${CUR_LINES[$_i]}" =~ $RE_WRITEVERB ]] || continue + _verb="${BASH_REMATCH[2]}" + add_finding CL200 "$_file" "$((_i + 1))" "agent '$_name' has no write tool but this line instructs it to $_verb" + done + done <<< "$AGENT_NAME_FILE_TABLE" +} + +# CL201 - the declared-vs-disk half: an agent this manifest promises will stay +# read-only, but whose own tools: line has grown a write tool since. +rule_CL201() { + local _a _tool _f _l + while IFS=$'\x1f' read -r _a _tool _f _l; do + [[ -z "$_a" ]] && continue + set_has READONLY_AGENTS "$_a" || continue + is_write_tool "$_tool" || continue + add_finding CL201 "$_f" "$_l" "agent '$_a' is declared read-only in contractLint.readOnlyAgents but its tools: line includes write tool '$_tool'" + done <<< "$AGENT_TOOL_REFS" +} + +# CL202 - every mcp__* token anywhere in scan scope (frontmatter tools: lines +# included, which is exactly where the historical typo'd tool name lived) +# against the hand-maintained allowlist. +rule_CL202() { + local _k _t _f _l + while IFS=$'\x1f' read -r _k _t _f _l; do + [[ "$_k" == "mcpTool" ]] || continue + set_has KNOWN_MCP_TOOLS "$_t" && continue + add_finding CL202 "$_f" "$_l" "mcp tool name '$_t' is absent from contractLint.knownMcpTools" + done <<< "$REFS" +} + +# CL203 - a tool this agent's own frontmatter declares, that its own body +# (everything after the closing ---) never mentions by name. +rule_CL203() { + local _a _tool _f _l _start _i _used + while IFS=$'\x1f' read -r _a _tool _f _l; do + [[ -z "$_a" ]] && continue + load_file "$_f" + _start="$(body_start_of "$_a")" + _used=0 + for ((_i = _start - 1; _i < CUR_N; _i++)); do + case "${CUR_LINES[$_i]}" in + *"$_tool"*) _used=1; break ;; + esac + done + if [[ $_used -eq 0 ]]; then + add_finding CL203 "$_f" "$_l" "agent '$_a' declares tool '$_tool' but its body never mentions it" + fi + done <<< "$AGENT_TOOL_REFS" +} + +# Collect a gate block's selectable OPTIONS: the slash-separated tokens of a +# parenthetical, plus the backticked leading token of each top-level bullet. +# Sets OPT_TOKENS (newline-delimited "linetoken" records) and OPT_HAS_SET. +gate_options() { # file blockStartLine blockEndExclusive0 + local _rel="$1" _start="$2" _end="$3" _i _line _inner _piece _tok _bullets=0 + OPT_TOKENS=""; OPT_HAS_SET=0 + if [[ "$CUR_REL" != "$_rel" ]]; then load_file "$_rel"; fi + for ((_i = _start - 1; _i < _end; _i++)); do + _line="${CUR_LINES[$_i]}" + if [[ "$_line" =~ $RE_OPTPAREN ]]; then + OPT_HAS_SET=1 + _inner="${BASH_REMATCH[0]}" + _inner="${_inner#\(}" + _inner="${_inner%\)}" + while IFS= read -r _piece; do + _tok="$(normalize_option "$_piece")" + [[ -n "$_tok" ]] && OPT_TOKENS="${OPT_TOKENS}$((_i + 1))"$'\x1f'"${_tok}"$'\n' + done <<< "$(printf '%s' "$_inner" | tr '/' '\n')" + fi + if [[ "$_line" =~ ^-[[:blank:]] ]]; then + _bullets=$((_bullets + 1)) + if [[ "$_line" =~ $RE_BULLETTOK ]]; then + _tok="$(normalize_option "${BASH_REMATCH[1]}")" + [[ -n "$_tok" ]] && OPT_TOKENS="${OPT_TOKENS}$((_i + 1))"$'\x1f'"${_tok}"$'\n' + fi + fi + done + [[ $_bullets -ge 2 ]] && OPT_HAS_SET=1 + return 0 +} + +trim_blank() { # string -> stdout, leading/trailing spaces and tabs removed + local _s="$1" _c + while [[ -n "$_s" ]]; do + _c="${_s:0:1}" + [[ "$_c" == " " || "$_c" == "$(printf '\t')" ]] || break + _s="${_s:1}" + done + while [[ -n "$_s" ]]; do + _c="${_s: -1}" + [[ "$_c" == " " || "$_c" == "$(printf '\t')" ]] || break + _s="${_s:0:${#_s}-1}" + done + printf '%s' "$_s" +} + +# The seven steps below are a CONTRACT with contract-lint.ps1's Get-NormalizedOption. +# Both must produce byte-identical tokens or CL305 diverges between the twins. +# 1. truncate at the first backtick 5. drop every ` and " character +# 2. trim spaces/tabs 6. lowercase A-Z only +# 3. truncate at the first " - " 7. trim spaces/tabs again +# 4. truncate at the first " <" +normalize_option() { # raw piece -> stdout lowercase leading token + local _s="$1" + _s="${_s%%\`*}" + _s="$(trim_blank "$_s")" + _s="${_s%% - *}" + _s="${_s%% <*}" + _s="$(printf '%s' "$_s" | tr -d '`"' | tr 'A-Z' 'a-z')" + _s="$(trim_blank "$_s")" + printf '%s' "$_s" +} + +rule_CL300_CL301_CL305_CL306() { + local _f _l _kind _label _hard _end _i _has_stop _ol _ot _phrase _hit + while IFS=$'\x1f' read -r _f _l _kind _label _hard _end; do + [[ -z "$_f" ]] && continue + if [[ "$CUR_REL" != "$_f" ]]; then load_file "$_f"; fi + _has_stop=0 + for ((_i = _l - 1; _i < _end; _i++)); do + case "${CUR_LINES[$_i]}" in + *STOP*) _has_stop=1; break ;; + esac + done + if [[ $_has_stop -eq 0 ]]; then + add_finding CL300 "$_f" "$_l" "gate block contains no literal STOP" + fi + gate_options "$_f" "$_l" "$_end" + if [[ $OPT_HAS_SET -eq 0 ]]; then + add_finding CL301 "$_f" "$_l" "gate block offers no option set" + fi + if [[ $_hard -eq 1 && -n "$OPT_TOKENS" ]]; then + while IFS=$'\x1f' read -r _ol _ot; do + [[ -z "$_ot" ]] && continue + if set_has OVERRIDE_TOKENS "$_ot"; then + add_finding CL305 "$_f" "$_ol" "HARD gate offers override option '$_ot'" + fi + done <<< "$OPT_TOKENS" + fi + if [[ $_hard -eq 1 ]]; then + # CL306 - a naive phrase scan over gate-block PROSE, deliberately + # excluding whatever CL305 already governs: CLAUDE.md rule 6 + # frames "listed as a choice" and "described in prose" as the two + # mutually exclusive halves of a HARD gate's escape-hatch + # surface, so a line that is itself part of the option-set + # syntax (the slash parenthetical, or a backtick-led option + # bullet) is CL305's territory, not CL306's - without this + # exclusion, perf.md's already CL305-suppressed Case A ('proceed + # anyway' inside the option set) false-fires CL306 too, on both + # the option line and its own suppression comment's reason text. + # It fires on commands/bug.md's logged insist-and-proceed + # sentence and commands/release.md's "may override the version" + # bullet (neither is option-set syntax) unless annotated - + # fixing that is the point of this rule, not a bug in it. See + # GATE_PROSE_ESCAPE_TOKENS above. + for ((_i = _l - 1; _i < _end; _i++)); do + [[ "${CUR_FENCE[$_i]}" == "1" ]] && continue + [[ "${CUR_LINES[$_i]}" =~ $RE_SUPPRESS ]] && continue + [[ "${CUR_LINES[$_i]}" =~ $RE_OPTPAREN ]] && continue + [[ "${CUR_LINES[$_i]}" =~ $RE_BULLETTOK ]] && continue + _hit=0 + while IFS= read -r _phrase; do + [[ -z "$_phrase" ]] && continue + case "${CUR_LINES[$_i]}" in + *"$_phrase"*) _hit=1; break ;; + esac + done <<< "$GATE_PROSE_ESCAPE_TOKENS" + if [[ $_hit -eq 1 ]]; then + add_finding CL306 "$_f" "$((_i + 1))" "HARD gate prose describes an escape hatch ('$_phrase')" + fi + done + fi + done <<< "$GATES" +} + +# CL400 / CL401 / CL402 - stack-agnostic prose + hardcoded absolute paths. +# One line-scan pass over scanScope, deliberately narrow (word-bounded +# vocabulary hits, positive-boundary path regex) to keep the false-positive +# band this wave occupies under control - see STACK_CMD_PATTERNS / +# STACK_LANG_PATTERNS above and the manifest's $stackTokensComment. Every +# pattern is PRE-COMPILED before this function runs - no command +# substitution and no external process anywhere in this per-line loop. The +# combined alternation is tested first; the per-token table only runs on a +# line that already matched it, which is what keeps ~5k lines x 28 vocabulary +# entries from paying for 28 regex tests each. +rule_CL400_CL401_CL402() { + local _rel _i _line _scrubbed _tok _pat + while IFS= read -r _rel; do + [[ -z "$_rel" ]] && continue + load_file "$_rel" + for ((_i = 0; _i < CUR_N; _i++)); do + [[ "${CUR_FENCE[$_i]}" == "1" ]] && continue + _line="${CUR_LINES[$_i]}" + # Fork-free scrub: a per-line sed subprocess across ~5k scanScope + # lines is the exact anti-pattern collect_refs already warns + # about (a Windows-runner fork is orders of magnitude slower than + # a native bash op), so this only touches lines that actually + # contain '<<' and uses bash's own glob substitution, never sed. + _scrubbed="$_line" + if [[ "$_scrubbed" == *"<<"* ]]; then + _scrubbed="${_scrubbed//<<*>>/}" + fi + if [[ -n "$STACK_CMD_COMBINED" && "$_scrubbed" =~ $STACK_CMD_COMBINED ]]; then + while IFS=$'\x1f' read -r _tok _pat; do + [[ -z "$_tok" ]] && continue + if [[ "$_scrubbed" =~ $_pat ]]; then + add_finding CL400 "$_rel" "$((_i + 1))" "hardcoded stack command token '$_tok'" + fi + done <<< "$STACK_CMD_PATTERNS" + fi + if [[ -n "$STACK_LANG_COMBINED" && "$_scrubbed" =~ $STACK_LANG_COMBINED ]]; then + while IFS=$'\x1f' read -r _tok _pat; do + [[ -z "$_tok" ]] && continue + if [[ "$_scrubbed" =~ $_pat ]]; then + add_finding CL401 "$_rel" "$((_i + 1))" "hardcoded language/framework name '$_tok'" + fi + done <<< "$STACK_LANG_PATTERNS" + fi + if [[ "$_scrubbed" =~ $RE_ABSPATH_WIN ]]; then + add_finding CL402 "$_rel" "$((_i + 1))" "hardcoded absolute path '${BASH_REMATCH[2]}'" + elif [[ "$_scrubbed" =~ $RE_ABSPATH_POSIX ]]; then + add_finding CL402 "$_rel" "$((_i + 1))" "hardcoded absolute path '${BASH_REMATCH[2]}'" + fi + done + done <<< "$SCAN_FILES" +} + +# CL500 - file budgets. Byte count is NORMALIZED (sum of each line's byte +# length, plus one separator per line boundary), never a raw disk read: this +# repo's *.md scanScope is 'text=auto', checking out LF on Linux CI and CRLF on +# Windows, so ReadAllBytes().Length / `wc -c` would make CL500 disagree with +# itself across platforms for byte-identical content. LC_ALL=C at the top of +# this script already makes bash's ${#line} a true byte count, not a +# multibyte character count, so no extra decode is needed here - see the +# manifest's $budgetsComment. +rule_CL500() { + local _rel _budget _bytes _i _over + while IFS= read -r _rel; do + [[ -z "$_rel" ]] && continue + _budget="$(budget_for_file "$_rel")" + [[ -z "$_budget" ]] && continue + load_file "$_rel" + _bytes=0 + for ((_i = 0; _i < CUR_N; _i++)); do + _bytes=$((_bytes + ${#CUR_LINES[$_i]})) + done + if ((CUR_N > 1)); then + _bytes=$((_bytes + CUR_N - 1)) + fi + if ((_bytes > _budget)); then + _over=$((_bytes - _budget)) + add_finding CL500 "$_rel" 1 \ + "file is $_bytes bytes, $_over over the $_budget-byte budget" + fi + done <<< "$SCAN_FILES" +} + +rule_CL302_CL303_CL304() { + local _rel _f _l _kind _label _hard _end + local _count _labels _decl_hard _decl_cond _c _want _dup _seen _n + while IFS= read -r _rel; do + [[ -z "$_rel" ]] && continue + _count=0; _labels="" + while IFS=$'\x1f' read -r _f _l _kind _label _hard _end; do + [[ "$_f" == "$_rel" ]] || continue + if [[ "$_kind" == "hard" ]]; then + _count=$((_count + 1)) + [[ -n "$_label" ]] && _labels="${_labels}${_label}"$'\n' + fi + done <<< "$GATES" + + _decl_hard=0; _decl_cond="" + if set_has GATE_DECL_FILES "$_rel"; then + while IFS=$'\x1f' read -r _f _c _want; do + if [[ "$_f" == "$_rel" ]]; then _decl_hard="$_c"; _decl_cond="$_want"; fi + done <<< "$GATE_DECL_TABLE" + fi + if [[ "$_count" -ne "$_decl_hard" ]]; then + add_finding CL302 "$_rel" 1 "hard gate count is $_count on disk, manifest declares $_decl_hard" + fi + + # CL303 is SET-based, never file order: commands/bug.md authors + # '### Gate 3a' before '### Gate 3' and must still pass. + _labels="$(printf '%s' "$_labels" | grep -v '^$' | sort -n || true)" + _n=0; _dup=0; _seen="" + while IFS= read -r _c; do + [[ -z "$_c" ]] && continue + _n=$((_n + 1)) + if set_has _seen "$_c"; then _dup=1; fi + set_add _seen "$_c" + done <<< "$_labels" + if [[ $_n -gt 0 ]]; then + _want=1 + while IFS= read -r _c; do + [[ -z "$_c" ]] && continue + if [[ "$_c" != "$_want" ]]; then _dup=1; break; fi + _want=$((_want + 1)) + done <<< "$_labels" + if [[ $_dup -ne 0 ]]; then + add_finding CL303 "$_rel" 1 "hard gate numbering is not 1..$_n without duplicates" + fi + fi + + # CL304 - symmetric set difference, both directions BLOCK. The + # declared-but-absent half is the anti-rot direction. + _seen="" + while IFS=$'\x1f' read -r _f _l _kind _label _hard _end; do + [[ "$_f" == "$_rel" ]] || continue + [[ "$_kind" == "conditional" ]] || continue + set_add _seen "$_label" + case ",$_decl_cond," in + *",$_label,"*) ;; + *) add_finding CL304 "$_rel" "$_l" "conditional gate '$_label' is not declared in the manifest" ;; + esac + done <<< "$GATES" + if [[ -n "$_decl_cond" ]]; then + while IFS= read -r _c; do + [[ -z "$_c" ]] && continue + if ! set_has _seen "$_c"; then + add_finding CL304 "$_rel" 1 "manifest declares conditional gate '$_c' but it is absent from disk" + fi + done <<< "$(printf '%s' "$_decl_cond" | tr ',' '\n')" + fi + done <<< "$SCAN_FILES" +} + +rule_CL001_CL003 +rule_CL002 +rule_CL004 +rule_CL005 +rule_CL006 +rule_CL007 +rule_CL008 +rule_CL100_CL102_CL103 +rule_CL101 +rule_CL200 +rule_CL201 +rule_CL202 +rule_CL203 +rule_CL300_CL301_CL305_CL306 +rule_CL302_CL303_CL304 +rule_CL400_CL401_CL402 +rule_CL500 + +# ---- Phase C: suppressions, sort, emit ------------------------------------- +# +# A suppression can never suppress CL900, CL901 or CL902 - otherwise +# '' would be a self-authorizing loophole. +# That exclusion is hardcoded, never manifest-driven. + +resolve_suppressions() { + local _i _j _keep_rule=() _keep_file=() _keep_line=() _keep_msg=() _n=0 _hit + for ((_i = 0; _i < F_N; _i++)); do + _hit=0 + case "${f_rule[$_i]}" in + CL900|CL901|CL902) _hit=0 ;; + *) + for ((_j = 0; _j < S_N; _j++)); do + [[ "${s_bad[$_j]}" == "1" ]] && continue + [[ "${s_file[$_j]}" == "${f_file[$_i]}" ]] || continue + [[ "${s_rule[$_j]}" == "${f_rule[$_i]}" ]] || continue + if [[ "${s_line[$_j]}" -eq "${f_line[$_i]}" \ + || "${s_line[$_j]}" -eq $(( ${f_line[$_i]} - 1 )) ]]; then + s_used[$_j]=1 + _hit=1 + break + fi + done ;; + esac + if [[ $_hit -eq 0 ]]; then + _keep_rule[$_n]="${f_rule[$_i]}" + _keep_file[$_n]="${f_file[$_i]}" + _keep_line[$_n]="${f_line[$_i]}" + _keep_msg[$_n]="${f_msg[$_i]}" + _n=$((_n + 1)) + fi + done + F_N=$_n + f_rule=(); f_file=(); f_line=(); f_msg=() + for ((_i = 0; _i < _n; _i++)); do + f_rule[$_i]="${_keep_rule[$_i]}" + f_file[$_i]="${_keep_file[$_i]}" + f_line[$_i]="${_keep_line[$_i]}" + f_msg[$_i]="${_keep_msg[$_i]}" + done + # CL902 runs LAST, over the used flags. A CL901-flagged suppression is + # exempt - one error per broken suppression, never two. + for ((_j = 0; _j < S_N; _j++)); do + [[ "${s_bad[$_j]}" == "1" ]] && continue + [[ "${s_used[$_j]}" == "1" ]] && continue + add_finding CL902 "${s_file[$_j]}" "${s_line[$_j]}" \ + "suppression for ${s_rule[$_j]} suppressed nothing" + done +} + +resolve_suppressions + +rule_wanted() { # rule_id -> 0 if it should be emitted + [[ -z "$RULE_FILTER" ]] && return 0 + case ",$RULE_FILTER," in + *",$1,"*) return 0 ;; + esac + return 1 +} + +blocks=0 +warns=0 +emit="" +for ((i = 0; i < F_N; i++)); do + rule_wanted "${f_rule[$i]}" || continue + sev="$(severity_of "${f_rule[$i]}")" + [[ -z "$sev" ]] && sev="BLOCK" + if [[ "$sev" == "BLOCK" ]]; then blocks=$((blocks + 1)); else warns=$((warns + 1)); fi + # Sort key: file, then zero-padded line so lexical order IS numeric order, + # then rule id, then message. The message is in the key so two findings that + # agree on the first three components still order deterministically - `sort` + # is not stable, and the twin's List.Sort is not stable either. + printf -v padded '%09d' "${f_line[$i]}" + emit="${emit}${f_file[$i]}"$'\x01'"${padded}"$'\x01'"${f_rule[$i]}"$'\x01'"${f_msg[$i]}"$'\t'"${f_rule[$i]}"$'\t'"${sev}"$'\t'"${f_file[$i]}"$'\t'"${f_line[$i]}"$'\t'"${f_msg[$i]}"$'\n' +done + +if [[ -n "$emit" ]]; then + printf '%s' "$emit" | grep -v '^$' | sort | cut -f2- +fi + +if [[ $QUIET -eq 0 ]]; then + echo "contract-lint: $blocks block, $warns warn (root: $ROOT)" >&2 +fi + +if [[ $blocks -gt 0 ]]; then exit 1; fi +exit 0 diff --git a/scripts/selftest-docs.ps1 b/scripts/selftest-docs.ps1 index e4b98cc..ae18dba 100644 --- a/scripts/selftest-docs.ps1 +++ b/scripts/selftest-docs.ps1 @@ -16,13 +16,17 @@ 4. An undeclared new claim -> fails as undeclared. 5. A spelled-out CAPITALISED -> fails as undeclared. (SW-24) 6. A bare-noun claim -> fails as undeclared. (SW-24) + 7. A wrong published version -> fails, naming the version and CHANGELOG's truth. (SW-28) Scenarios 3 and 4 are what stop the check rotting: without them someone could reword or add docs and quietly leave Check 7 guarding nothing. Scenarios 5 and 6 cover the two escapes SW-24 found, both of which had let a real wrong claim sit in a tracked doc through many green runs. They are deliberately separate: a fix that only adds a lowercase word alternation passes 4 and fails 5, and a fix that - only handles decorated nouns passes 5 and fails 6. + only handles decorated nouns passes 5 and fails 6. Scenario 7 covers the SW-28 + blind spot: Check 7's docClaims/claimPhrases vocabulary is entirely count-based, + so a stale version string (e.g. ROADMAP.md) escaped detection until versionClaims + was added. Exit code 0 = the check behaves correctly; 1 = the check is broken. @@ -69,6 +73,16 @@ function New-RepoCopy { $TrueCommands = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot 'commands') -Filter '*.md' -File).Count $WrongCommands = $TrueCommands + 1 +# Same anti-hardcoding rationale as $TrueCommands above, applied to the version claim +# added by SW-28: derived from THIS repo's own CHANGELOG.md at run time, never +# hardcoded, since a literal here would itself rot on the next release cut. +$TrueVersion = $null +foreach ($line in (Get-Content -LiteralPath (Join-Path $repoRoot 'CHANGELOG.md'))) { + $m = [regex]::Match($line, '^##\s+\[([0-9]+\.[0-9]+\.[0-9]+)\]\s+-\s+\S') + if ($m.Success) { $TrueVersion = $m.Groups[1].Value; break } +} +$WrongVersion = '9.9.9' + function Edit-File { param([string]$Path, [string]$From, [string]$To) $text = Get-Content -LiteralPath $Path -Raw @@ -141,14 +155,14 @@ try { # ---- Scenario 1: clean copy passes ------------------------------------- - Write-Section 'Scenario 1/6: clean copy passes' + Write-Section 'Scenario 1/7: clean copy passes' $clean = Join-Path $workRoot 'clean' New-RepoCopy -Dest $clean Invoke-Case -Name 'clean' -ExpectPass $true -Needle '' -Dir $clean # ---- Scenario 2: a wrong published number fails ------------------------- - Write-Section 'Scenario 2/6: wrong README number fails' + Write-Section 'Scenario 2/7: wrong README number fails' $wrong = Join-Path $workRoot 'wrong-number' New-RepoCopy -Dest $wrong $planted = Assert-Corruption -Name 'wrong-number' -Path (Join-Path $wrong 'README.md') ` @@ -160,7 +174,7 @@ try { # ---- Scenario 3: a reworded claim fails as vacuous ---------------------- - Write-Section 'Scenario 3/6: reworded claim fails as vacuous' + Write-Section 'Scenario 3/7: reworded claim fails as vacuous' $reworded = Join-Path $workRoot 'reworded' New-RepoCopy -Dest $reworded $planted = Assert-Corruption -Name 'reworded' -Path (Join-Path $reworded 'README.md') ` @@ -172,7 +186,7 @@ try { # ---- Scenario 4: an undeclared claim fails ------------------------------ - Write-Section 'Scenario 4/6: undeclared claim in a new doc fails' + Write-Section 'Scenario 4/7: undeclared claim in a new doc fails' $undeclared = Join-Path $workRoot 'undeclared' New-RepoCopy -Dest $undeclared Add-Content -LiteralPath (Join-Path $undeclared 'docs\usage.md') ` @@ -186,7 +200,7 @@ try { # Capitalised on purpose: a spelled-out count in prose is usually sentence-initial, # which is exactly the form a lowercase-only word alternation misses. - Write-Section 'Scenario 5/6: spelled-out capitalised claim fails' + Write-Section 'Scenario 5/7: spelled-out capitalised claim fails' $spelled = Join-Path $workRoot 'spelled-out' New-RepoCopy -Dest $spelled Add-Content -LiteralPath (Join-Path $spelled 'docs\usage.md') ` @@ -199,18 +213,33 @@ try { # 'workflow commands'), so an undecorated 'N commands' matched nothing at all. # That is how 'Five commands invoke no subagent' sat in docs/architecture.md unseen. - Write-Section 'Scenario 6/6: bare-noun claim fails' + Write-Section 'Scenario 6/7: bare-noun claim fails' $bareNoun = Join-Path $workRoot 'bare-noun' New-RepoCopy -Dest $bareNoun Add-Content -LiteralPath (Join-Path $bareNoun 'docs\usage.md') ` -Value "`nThe engine ships 99 commands." Invoke-Case -Name 'bare-noun' -ExpectPass $false -Needle 'undeclared inventory claim' -Dir $bareNoun + # ---- Scenario 7: a wrong version claim fails (SW-28) -------------------- + + Write-Section 'Scenario 7/7: wrong ROADMAP version fails' + # NOTE: this directory var must not be named $wrongVersion/$WrongVersion or any + # case-variant thereof - PowerShell variables are case-INSENSITIVE, so that would + # silently clobber the $WrongVersion corruption value set above. + $wrongVersionDir = Join-Path $workRoot 'wrong-version' + New-RepoCopy -Dest $wrongVersionDir + $planted = Assert-Corruption -Name 'wrong-version' -Path (Join-Path $wrongVersionDir 'ROADMAP.md') ` + -From "Current released version: \*\*$TrueVersion\*\*" -To "Current released version: **$WrongVersion**" + if ($planted) { + Invoke-Case -Name 'wrong-version' -ExpectPass $false ` + -Needle "says $WrongVersion, CHANGELOG has $TrueVersion" -Dir $wrongVersionDir + } + # ---- summary ----------------------------------------------------------- Write-Section 'Summary' if ($script:Failures -eq 0) { - Write-Ok 'Check 7 bites on all 6 scenarios.' + Write-Ok 'Check 7 bites on all 7 scenarios.' exit 0 } else { Write-FailMsg "$($script:Failures) scenario(s) behaved wrong - Check 7 is not trustworthy." diff --git a/scripts/selftest-docs.sh b/scripts/selftest-docs.sh index 89e27aa..523beb5 100644 --- a/scripts/selftest-docs.sh +++ b/scripts/selftest-docs.sh @@ -13,13 +13,17 @@ # 4. An undeclared new claim -> fails as undeclared. # 5. A spelled-out CAPITALISED -> fails as undeclared. (SW-24) # 6. A bare-noun claim -> fails as undeclared. (SW-24) +# 7. A wrong published version -> fails, naming the version and CHANGELOG's truth. (SW-28) # # Scenarios 3 and 4 are what stop the check rotting: without them someone could # reword or add docs and quietly leave Check 7 guarding nothing. Scenarios 5 and 6 # cover the two escapes SW-24 found, both of which had let a real wrong claim sit in # a tracked doc through many green runs. They are deliberately separate: a fix that # only adds a lowercase word alternation passes 4 and fails 5, and a fix that only -# handles decorated nouns passes 5 and fails 6. +# handles decorated nouns passes 5 and fails 6. Scenario 7 covers the SW-28 blind +# spot: Check 7's docClaims/claimPhrases vocabulary is entirely count-based, so a +# stale version string (e.g. ROADMAP.md) escaped detection until versionClaims was +# added. # # Exit 0 = the check behaves correctly; 1 = the check is broken. @@ -70,6 +74,13 @@ make_copy() { TRUE_COMMANDS="$(find "$repo_root/commands" -maxdepth 1 -type f -name '*.md' | wc -l | tr -d ' ')" WRONG_COMMANDS=$((TRUE_COMMANDS + 1)) +# Same anti-hardcoding rationale as TRUE_COMMANDS above, applied to the version claim +# added by SW-28: derived from THIS repo's own CHANGELOG.md at run time, never +# hardcoded, since a literal here would itself rot on the next release cut. +TRUE_VERSION="$(grep -m1 -E '^##[[:space:]]+\[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]+-[[:space:]]+[^[:space:]]' "$repo_root/CHANGELOG.md" \ + | sed -E 's/^##[[:space:]]+\[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/')" +WRONG_VERSION="9.9.9" + # Replace first match of a regex in a file, portably (macOS sed -i differs from GNU). # Returns non-zero when the file did not change, so a corruption that silently failed to # apply is reported as a fixture-setup error rather than sailing on as a passing validator. @@ -135,14 +146,14 @@ echo " Sandbox: $work_root" # ---- Scenario 1: clean copy passes ----------------------------------------- -section "Scenario 1/6: clean copy passes" +section "Scenario 1/7: clean copy passes" clean="$work_root/clean" make_copy "$clean" run_case "clean" 1 "" "$clean" # ---- Scenario 2: a wrong published number fails ---------------------------- -section "Scenario 2/6: wrong README number fails" +section "Scenario 2/7: wrong README number fails" wrong="$work_root/wrong-number" make_copy "$wrong" if corrupt_or_fail "wrong-number" "$wrong/README.md" \ @@ -152,7 +163,7 @@ fi # ---- Scenario 3: a reworded claim fails as vacuous -------------------------- -section "Scenario 3/6: reworded claim fails as vacuous" +section "Scenario 3/7: reworded claim fails as vacuous" reworded="$work_root/reworded" make_copy "$reworded" if corrupt_or_fail "reworded" "$reworded/README.md" \ @@ -162,7 +173,7 @@ fi # ---- Scenario 4: an undeclared claim fails --------------------------------- -section "Scenario 4/6: undeclared claim in a new doc fails" +section "Scenario 4/7: undeclared claim in a new doc fails" undeclared="$work_root/undeclared" make_copy "$undeclared" printf '\nThe engine ships 99 reusable skills.\n' >> "$undeclared/docs/usage.md" @@ -175,7 +186,7 @@ run_case "undeclared" 0 "undeclared inventory claim" "$undeclared" # a spelled-out count in prose is usually sentence-initial, which is exactly the form a # lowercase-only word alternation misses. A lowercase-only fix passes scenario 4 and fails # here, which is the whole reason this scenario is separate. -section "Scenario 5/6: spelled-out capitalised claim fails" +section "Scenario 5/7: spelled-out capitalised claim fails" spelled="$work_root/spelled-out" make_copy "$spelled" printf '\nSeven reusable skills ship with the engine.\n' >> "$spelled/docs/usage.md" @@ -186,17 +197,28 @@ run_case "spelled-out" 0 "undeclared inventory claim" "$spelled" # Before SW-24 the vocabulary only listed decorated forms ('slash commands', 'workflow # commands'), so an undecorated 'N commands' matched nothing at all. That is how # 'Five commands invoke no subagent' sat in docs/architecture.md unseen. -section "Scenario 6/6: bare-noun claim fails" +section "Scenario 6/7: bare-noun claim fails" barenoun="$work_root/bare-noun" make_copy "$barenoun" printf '\nThe engine ships 99 commands.\n' >> "$barenoun/docs/usage.md" run_case "bare-noun" 0 "undeclared inventory claim" "$barenoun" +# ---- Scenario 7: a wrong version claim fails (SW-28) ------------------------ + +section "Scenario 7/7: wrong ROADMAP version fails" +wrongversion="$work_root/wrong-version" +make_copy "$wrongversion" +if corrupt_or_fail "wrong-version" "$wrongversion/ROADMAP.md" \ + "Current released version: \*\*${TRUE_VERSION}\*\*" \ + "Current released version: **${WRONG_VERSION}**"; then + run_case "wrong-version" 0 "says ${WRONG_VERSION}, CHANGELOG has ${TRUE_VERSION}" "$wrongversion" +fi + # ---- summary --------------------------------------------------------------- section "Summary" if [[ $failures -eq 0 ]]; then - ok "Check 7 bites on all 6 scenarios." + ok "Check 7 bites on all 7 scenarios." exit 0 else fail "$failures scenario(s) behaved wrong - Check 7 is not trustworthy." diff --git a/scripts/smoke-hooks.ps1 b/scripts/smoke-hooks.ps1 index 8f87259..1be31d4 100644 --- a/scripts/smoke-hooks.ps1 +++ b/scripts/smoke-hooks.ps1 @@ -174,6 +174,62 @@ Invoke-Hook (Join-Path $repoRoot 'hooks\powershell\spec-gate.ps1') '{not valid j Assert-Exit0 'spec-gate (d) malformed JSON' $script:Code Assert-Empty 'spec-gate (d) malformed JSON' $script:Stdout +# ---- spec-gate: (e)/(f) archived parent + registered child -> complexity split (SW-31) ---- +# Same pending index.md content in both cases - only paths.protected differs. +# (e) proves the split is recorded when the edit is actually ALLOWED through. +# (f) proves it is NOT recorded when the SAME edit is denied (index.md +# protected, the default) - nothing reached disk, so no split happened. + +$splitFixture = Join-Path $env:TEMP "sd-smoke-hooks-split-$PID" +if (Test-Path -LiteralPath $splitFixture) { Remove-Item -Recurse -Force $splitFixture } +New-Item -ItemType Directory -Force -Path (Join-Path $splitFixture '.specs') | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $splitFixture '.claude') | Out-Null +$splitIndexContent = @' +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-big | feature | in-progress | 2026-08-01 | Big oversized thing | +| FEAT-big-partA | feature | draft | 2026-08-09 | Part A | +'@ +Set-Content -LiteralPath (Join-Path $splitFixture '.specs\index.md') -Value $splitIndexContent -Encoding UTF8 -NoNewline +$splitNewContent = @' +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-big | feature | archived | 2026-08-01 | Big oversized thing | +| FEAT-big-partA | feature | draft | 2026-08-09 | Part A | +'@ +$splitPayload = [pscustomobject]@{ + tool_name = 'Write' + cwd = $splitFixture + tool_input = [pscustomobject]@{ file_path = '.specs/index.md'; content = $splitNewContent } +} | ConvertTo-Json -Compress -Depth 5 +$splitConfigPath = Join-Path $splitFixture '.claude\project-config.json' +$splitEventsPath = Join-Path $splitFixture '.specs\_metrics\events.jsonl' + +Write-Section 'spec-gate (PowerShell): (e) allowed edit with archived parent + registered child -> complexity split metric' +Set-Content -LiteralPath $splitConfigPath -Value '{"spec":{"dir":".specs","indexFile":".specs/index.md"},"paths":{"protected":[]}}' -Encoding UTF8 -NoNewline +Invoke-Hook (Join-Path $repoRoot 'hooks\powershell\spec-gate.ps1') $splitPayload +Assert-Exit0 'spec-gate (e) complexity split, allowed' $script:Code +$splitEvents = Get-Content -LiteralPath $splitEventsPath -Raw -ErrorAction SilentlyContinue +if ($null -eq $splitEvents) { $splitEvents = '' } +Assert-Contains 'spec-gate (e) complexity split, allowed' $splitEvents '"gate":"complexity","decision":"split"' +Assert-Contains 'spec-gate (e) complexity split, allowed' $splitEvents '"spec_id":"FEAT-big"' + +Write-Section 'spec-gate (PowerShell): (f) blocked edit (default protected index.md) -> no complexity split metric' +Remove-Item -LiteralPath $splitEventsPath -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $splitConfigPath -ErrorAction SilentlyContinue +Invoke-Hook (Join-Path $repoRoot 'hooks\powershell\spec-gate.ps1') $splitPayload +Assert-Exit0 'spec-gate (f) complexity split, blocked' $script:Code +Assert-Contains 'spec-gate (f) complexity split, blocked' $script:Stdout '"decision":"block"' +$splitEventsBlocked = Get-Content -LiteralPath $splitEventsPath -Raw -ErrorAction SilentlyContinue +if ($null -eq $splitEventsBlocked) { $splitEventsBlocked = '' } +if ($splitEventsBlocked.Contains('"gate":"complexity"')) { + Add-Bad 'spec-gate (f) complexity split, blocked : split metric wrongly recorded for a denied edit' +} else { + Add-Ok 'spec-gate (f) complexity split, blocked : no split metric recorded' +} + +Remove-Item -Recurse -Force $splitFixture -ErrorAction SilentlyContinue + # ---- subagent-retro: missing retro names the spec, then debounces ---------- Write-Section 'subagent-retro (PowerShell): missing 05-retro.md names the real spec ID' diff --git a/scripts/smoke-hooks.sh b/scripts/smoke-hooks.sh index f1054a0..d6db649 100644 --- a/scripts/smoke-hooks.sh +++ b/scripts/smoke-hooks.sh @@ -157,6 +157,55 @@ run_hook "$repo_root/hooks/bash/spec-gate.sh" '{not valid json' assert_exit0 "spec-gate (d) malformed JSON" "$CODE" assert_empty "spec-gate (d) malformed JSON" "$STDOUT" +# ---- spec-gate: (e)/(f) archived parent + registered child -> complexity split (SW-31) ---- +# Same pending index.md content in both cases - only paths.protected differs. +# (e) proves the split is recorded when the edit is actually ALLOWED through. +# (f) proves it is NOT recorded when the SAME edit is denied (index.md +# protected, the default) - nothing reached disk, so no split happened. + +split_fixture="$(mktemp -d)" +mkdir -p "$split_fixture/.specs" "$split_fixture/.claude" +cat > "$split_fixture/.specs/index.md" <<'MD' +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-big | feature | in-progress | 2026-08-01 | Big oversized thing | +| FEAT-big-partA | feature | draft | 2026-08-09 | Part A | +MD +new_content='| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-big | feature | archived | 2026-08-01 | Big oversized thing | +| FEAT-big-partA | feature | draft | 2026-08-09 | Part A |' +payload="$(jq -n --arg cwd "$split_fixture" --arg fp ".specs/index.md" --arg newc "$new_content" \ + '{tool_name:"Write",cwd:$cwd,tool_input:{file_path:$fp,content:$newc}}' 2>/dev/null)" + +if [[ -n "$payload" ]]; then + section "spec-gate (bash): (e) allowed edit with archived parent + registered child -> complexity split metric" + cat > "$split_fixture/.claude/project-config.json" <<'JSON' +{"spec":{"dir":".specs","indexFile":".specs/index.md"},"paths":{"protected":[]}} +JSON + run_hook "$repo_root/hooks/bash/spec-gate.sh" "$payload" + assert_exit0 "spec-gate (e) complexity split, allowed" "$CODE" + events="$(cat "$split_fixture/.specs/_metrics/events.jsonl" 2>/dev/null)" + assert_contains "spec-gate (e) complexity split, allowed" "$events" '"gate":"complexity","decision":"split"' + assert_contains "spec-gate (e) complexity split, allowed" "$events" '"spec_id":"FEAT-big"' + + section "spec-gate (bash): (f) blocked edit (default protected index.md) -> no complexity split metric" + rm -f "$split_fixture/.specs/_metrics/events.jsonl" + rm -f "$split_fixture/.claude/project-config.json" + run_hook "$repo_root/hooks/bash/spec-gate.sh" "$payload" + assert_exit0 "spec-gate (f) complexity split, blocked" "$CODE" + assert_contains "spec-gate (f) complexity split, blocked" "$STDOUT" '"decision":"block"' + events="$(cat "$split_fixture/.specs/_metrics/events.jsonl" 2>/dev/null)" + if [[ "$events" == *'"gate":"complexity"'* ]]; then + bad "spec-gate (f) complexity split, blocked : split metric wrongly recorded for a denied edit" + else + ok "spec-gate (f) complexity split, blocked : no split metric recorded" + fi +else + echo " [SKIP] jq not available - cannot build (e)/(f) payload" +fi +rm -rf "$split_fixture" + # ---- subagent-retro: missing retro names the spec, then debounces ---------- section "subagent-retro (bash): missing 05-retro.md names the real spec ID" diff --git a/scripts/validate.ps1 b/scripts/validate.ps1 index 1290091..d6e3c05 100644 --- a/scripts/validate.ps1 +++ b/scripts/validate.ps1 @@ -15,6 +15,9 @@ 6. CHANGELOG gate: the [Unreleased] section is non-empty. 7. Docs consistency: published numbers in the docs match disk, per specwright.manifest.json. + 8. Cross-file contract lint: the relationships between commands, agents + and skills, per specwright.manifest.json's contractLint subtree. + Delegated to scripts/contract-lint.ps1 as a child process. Exit code 0 = all checks passed; 1 = at least one check failed. @@ -44,6 +47,12 @@ $ExpectedSkills = (Get-ChildItem (Join-Path $repoRoot 'skills') -Filter 'SKIL $ExpectedHooks = (Get-ChildItem (Join-Path $repoRoot 'hooks\powershell') -Filter *.ps1 -File).Count $ExpectedTemplates = (Get-ChildItem (Join-Path $repoRoot 'templates') -File -Recurse).Count +# The version stamp has no source-tree counterpart - it is generated at install time from +# CHANGELOG.md, never copied from a source dir - so unlike the counts above it cannot be +# derived from a glob. This is the one hand-written constant Check 5 uses: how many stamp +# files land per installed area. +$StampFilesPerArea = 1 + foreach ($pair in @( @{ Name = 'commands'; Count = $ExpectedCommands }, @{ Name = 'agents'; Count = $ExpectedAgents }, @@ -116,12 +125,30 @@ function Find-WorkingBash { return $null } +# Counts files under Dir and compares against Expected, reporting through the same +# Write-Ok / Write-FailMsg / Add-Failure vocabulary as every other check. Reused by +# Check 5 for both the fresh install and the idempotent re-run, so both runs are +# asserted through one code path rather than two hand-copied loops. +function Test-AreaCount { + param([string]$Name, [string]$Dir, [int]$Expected) + $cnt = 0 + if (Test-Path -LiteralPath $Dir) { + $cnt = (Get-ChildItem -LiteralPath $Dir -Recurse -File).Count + } + if ($cnt -eq $Expected) { + Write-Ok "$Name/sd : $cnt file(s)" + } else { + Write-FailMsg "$Name/sd : expected $Expected, found $cnt" + Add-Failure "install: $Name/sd expected $Expected found $cnt" + } +} + Write-Section 'specwright validate' Write-Host " Repo root: $repoRoot" # ---- Check 1: pure-ASCII scan ---------------------------------------------- -Write-Section 'Check 1/7: Pure-ASCII scan (*.ps1)' +Write-Section 'Check 1/8: Pure-ASCII scan (*.ps1)' $ps1Files = Get-ChildItem -Path $repoRoot -Recurse -Filter *.ps1 -File | Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } $asciiBad = 0 @@ -138,7 +165,7 @@ if ($asciiBad -eq 0) { Write-Ok "$($ps1Files.Count) .ps1 file(s) are pure ASCII" # ---- Check 2: bash -n syntax ----------------------------------------------- -Write-Section 'Check 2/7: bash -n syntax (*.sh)' +Write-Section 'Check 2/8: bash -n syntax (*.sh)' $shFiles = @() foreach ($sub in @('hooks\bash', 'install', 'scripts')) { $dir = Join-Path $repoRoot $sub @@ -166,7 +193,7 @@ if ($null -eq $bashExe) { # ---- Check 3: hook-pair parity --------------------------------------------- -Write-Section 'Check 3/7: Hook-pair parity' +Write-Section 'Check 3/8: Hook-pair parity' $psHooks = Get-ChildItem (Join-Path $repoRoot 'hooks\powershell') -Filter *.ps1 -File | ForEach-Object { $_.BaseName } $shHooks = Get-ChildItem (Join-Path $repoRoot 'hooks\bash') -Filter *.sh -File | @@ -190,7 +217,7 @@ if ($parityBad -eq 0) { Write-Ok "$($psHooks.Count) hook pair(s) present on both # ---- Check 4: agent model aliases ------------------------------------------ -Write-Section 'Check 4/7: Agent model aliases' +Write-Section 'Check 4/8: Agent model aliases' $agentFiles = Get-ChildItem (Join-Path $repoRoot 'agents') -Filter *.md -File $modelBad = 0 foreach ($f in $agentFiles) { @@ -213,12 +240,16 @@ if ($modelBad -eq 0) { Write-Ok "$($agentFiles.Count) agent(s) use a model alias # ---- Check 5: install-target counts ---------------------------------------- -Write-Section 'Check 5/7: Install-target counts' +Write-Section 'Check 5/8: Install-target counts' $installPs1 = Join-Path $repoRoot 'install\install.ps1' $tmp = Join-Path $env:TEMP "sd-validate-$PID" +$tmpNc = Join-Path $env:TEMP "sd-validate-nc-$PID" +$tmpNcBase = Join-Path $env:TEMP "sd-validate-nc-base-$PID" $psExe = (Get-Process -Id $PID).Path try { if (Test-Path -LiteralPath $tmp) { Remove-Item -Recurse -Force $tmp } + if (Test-Path -LiteralPath $tmpNc) { Remove-Item -Recurse -Force $tmpNc } + if (Test-Path -LiteralPath $tmpNcBase) { Remove-Item -Recurse -Force $tmpNcBase } if ($env:OS -eq 'Windows_NT') { $childArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installPs1, '-BasePath', $tmp, '-Force') } else { @@ -230,34 +261,147 @@ try { Add-Failure "install: installer exit $LASTEXITCODE" } else { $targets = @( - [pscustomobject]@{ Name = 'commands'; Path = 'commands\sd'; Expected = $ExpectedCommands }, - [pscustomobject]@{ Name = 'agents'; Path = 'agents\sd'; Expected = $ExpectedAgents }, - [pscustomobject]@{ Name = 'skills'; Path = 'skills\sd'; Expected = $ExpectedSkills }, - [pscustomobject]@{ Name = 'hooks'; Path = 'hooks\sd'; Expected = $ExpectedHooks }, - [pscustomobject]@{ Name = 'templates'; Path = 'templates\sd'; Expected = $ExpectedTemplates } + [pscustomobject]@{ Name = 'commands'; Path = 'commands\sd'; Expected = $ExpectedCommands + $StampFilesPerArea }, + [pscustomobject]@{ Name = 'agents'; Path = 'agents\sd'; Expected = $ExpectedAgents + $StampFilesPerArea }, + [pscustomobject]@{ Name = 'skills'; Path = 'skills\sd'; Expected = $ExpectedSkills + $StampFilesPerArea }, + [pscustomobject]@{ Name = 'hooks'; Path = 'hooks\sd'; Expected = $ExpectedHooks + $StampFilesPerArea }, + [pscustomobject]@{ Name = 'templates'; Path = 'templates\sd'; Expected = $ExpectedTemplates + $StampFilesPerArea } ) foreach ($t in $targets) { - $full = Join-Path $tmp $t.Path - if (Test-Path -LiteralPath $full) { - $cnt = (Get-ChildItem -LiteralPath $full -Recurse -File).Count - } else { - $cnt = 0 + Test-AreaCount -Name $t.Name -Dir (Join-Path $tmp $t.Path) -Expected $t.Expected + } + + # ---- stamp content: every area's stamp equals the CHANGELOG-derived version ---- + $stampChangelog = Join-Path $repoRoot 'CHANGELOG.md' + $stampVersion = $null + foreach ($line in (Get-Content -LiteralPath $stampChangelog)) { + $m = [regex]::Match($line, '^##\s+\[([0-9]+\.[0-9]+\.[0-9]+)\]\s+-\s+\S') + if ($m.Success) { $stampVersion = $m.Groups[1].Value; break } + } + if ([string]::IsNullOrEmpty($stampVersion)) { + Write-FailMsg 'CHANGELOG.md : no dated release heading found (## [x.y.z] - )' + Add-Failure 'install: stamp version source unreadable' + } else { + $stampBad = 0 + foreach ($t in $targets) { + $stampPath = Join-Path (Join-Path $tmp $t.Path) 'specwright-version.txt' + if (-not (Test-Path -LiteralPath $stampPath -PathType Leaf)) { + Write-FailMsg "$($t.Name)/sd/specwright-version.txt : not found" + Add-Failure "install: $($t.Name)/sd stamp missing" + $stampBad++ + continue + } + # Tolerate a stray trailing CR on read - the byte contract itself is + # asserted below on one stamp; this comparison only cares about content. + $stampContent = (Get-Content -LiteralPath $stampPath -Raw) -replace '[\r\n]+$', '' + if ($stampContent -ne $stampVersion) { + Write-FailMsg "$($t.Name)/sd/specwright-version.txt : content '$stampContent' != CHANGELOG version '$stampVersion'" + Add-Failure "install: $($t.Name)/sd stamp content mismatch" + $stampBad++ + } + } + if ($stampBad -eq 0) { Write-Ok "5 stamp(s) match CHANGELOG version $stampVersion" } + + # Byte-level contract on one stamp (commands): no BOM, no CR, exactly one trailing LF. + $byteStampPath = Join-Path (Join-Path $tmp 'commands\sd') 'specwright-version.txt' + if (Test-Path -LiteralPath $byteStampPath -PathType Leaf) { + $stampBytes = [System.IO.File]::ReadAllBytes($byteStampPath) + $hasBom = ($stampBytes.Length -ge 3) -and ($stampBytes[0] -eq 0xEF) -and ($stampBytes[1] -eq 0xBB) -and ($stampBytes[2] -eq 0xBF) + $hasCr = $stampBytes -contains 0x0D + $endsWithLf = ($stampBytes.Length -gt 0) -and ($stampBytes[$stampBytes.Length - 1] -eq 0x0A) + $doubleLf = ($stampBytes.Length -gt 1) -and ($stampBytes[$stampBytes.Length - 2] -eq 0x0A) + if ($hasBom -or $hasCr -or (-not $endsWithLf) -or $doubleLf) { + Write-FailMsg "commands/sd/specwright-version.txt : byte contract violated (BOM=$hasBom CR=$hasCr trailingLF=$endsWithLf doubleLF=$doubleLf)" + Add-Failure 'install: commands/sd stamp byte contract' + } else { + Write-Ok 'commands/sd/specwright-version.txt : LF, no BOM, no CR, single trailing newline' + } } - if ($cnt -eq $t.Expected) { - Write-Ok "$($t.Name)/sd : $cnt file(s)" + } + + # ---- idempotent re-run: second -Force pass must be a no-op for the stamp ---- + & $psExe @childArgs *> $null + if ($LASTEXITCODE -ne 0) { + Write-FailMsg "second installer run exited with code $LASTEXITCODE" + Add-Failure "install: second installer run exit $LASTEXITCODE" + } else { + $bakFiles = @(Get-ChildItem -LiteralPath $tmp -Recurse -File -Filter '*.bak.*' -ErrorAction SilentlyContinue) + if ($bakFiles.Count -gt 0) { + Write-FailMsg "second install run created $($bakFiles.Count) *.bak.* file(s) - stamp is not idempotent" + Add-Failure 'install: second run produced .bak files' } else { - Write-FailMsg "$($t.Name)/sd : expected $($t.Expected), found $cnt" - Add-Failure "install: $($t.Name)/sd expected $($t.Expected) found $cnt" + Write-Ok 'second -Force run created zero *.bak.* file(s)' + } + foreach ($t in $targets) { + Test-AreaCount -Name $t.Name -Dir (Join-Path $tmp $t.Path) -Expected $t.Expected } } } + + # ---- negative case: missing version source fails loudly, copies nothing ---- + # The required-source-directory list is mirrored FROM install.ps1's own + # $requiredDirs block, not hand-duplicated here, so a future added requirement + # fails this scenario visibly instead of silently changing what it proves. + $reqDirsFromInstaller = New-Object System.Collections.Generic.List[string] + $inReqBlock = $false + foreach ($line in (Get-Content -LiteralPath $installPs1)) { + if ($line -match '^\$requiredDirs\s*=\s*@\(') { $inReqBlock = $true; continue } + if ($inReqBlock) { + if ($line -match '^\)') { break } + $m = [regex]::Match($line, "Path\s*=\s*'([^']+)'") + if ($m.Success) { $reqDirsFromInstaller.Add($m.Groups[1].Value) } + } + } + if ($reqDirsFromInstaller.Count -eq 0) { + Write-FailMsg 'install.ps1 : could not parse $requiredDirs - missing-CHANGELOG scenario skipped' + Add-Failure 'install: could not parse install.ps1 required dirs' + } else { + New-Item -ItemType Directory -Path (Join-Path $tmpNc 'install') -Force | Out-Null + Copy-Item -LiteralPath $installPs1 -Destination (Join-Path $tmpNc 'install\install.ps1') -Force + foreach ($d in $reqDirsFromInstaller) { + New-Item -ItemType Directory -Path (Join-Path $tmpNc $d) -Force | Out-Null + } + $ncInstallPs1 = Join-Path $tmpNc 'install\install.ps1' + if ($env:OS -eq 'Windows_NT') { + $ncArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ncInstallPs1, '-BasePath', $tmpNcBase, '-Force') + } else { + $ncArgs = @('-NoProfile', '-File', $ncInstallPs1, '-BasePath', $tmpNcBase, '-Force') + } + $ncOut = & $psExe @ncArgs 2>&1 | Out-String + $ncExit = $LASTEXITCODE + $ncBad = 0 + if ($ncExit -eq 0) { + Write-FailMsg 'missing-CHANGELOG scenario: installer exited 0, expected non-zero' + Add-Failure 'install: missing-changelog scenario did not fail' + $ncBad++ + } + if ($ncOut -notmatch 'CHANGELOG\.md') { + Write-FailMsg 'missing-CHANGELOG scenario: installer output did not mention CHANGELOG.md' + Add-Failure 'install: missing-changelog scenario message missing CHANGELOG.md' + $ncBad++ + } + $ncFileCount = 0 + if (Test-Path -LiteralPath $tmpNcBase) { + $ncFileCount = (Get-ChildItem -LiteralPath $tmpNcBase -Recurse -File -Force -ErrorAction SilentlyContinue).Count + } + if ($ncFileCount -ne 0) { + Write-FailMsg "missing-CHANGELOG scenario: base contains $ncFileCount file(s), expected 0" + Add-Failure 'install: missing-changelog scenario copied files' + $ncBad++ + } + if ($ncBad -eq 0) { + Write-Ok 'missing-CHANGELOG.md scenario: installer failed loudly and copied nothing' + } + } } finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $tmpNc) { Remove-Item -Recurse -Force $tmpNc -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $tmpNcBase) { Remove-Item -Recurse -Force $tmpNcBase -ErrorAction SilentlyContinue } } # ---- Check 6: CHANGELOG [Unreleased] non-empty ----------------------------- -Write-Section 'Check 6/7: CHANGELOG [Unreleased] gate' +Write-Section 'Check 6/8: CHANGELOG [Unreleased] gate' $changelog = Join-Path $repoRoot 'CHANGELOG.md' $lines = Get-Content -LiteralPath $changelog $start = -1 @@ -290,7 +434,7 @@ if ($start -lt 0) { # ---- Check 7: docs consistency --------------------------------------------- -Write-Section 'Check 7/7: Docs consistency (published numbers vs disk)' +Write-Section 'Check 7/8: Docs consistency (published numbers vs disk)' $manifestPath = Join-Path $repoRoot 'specwright.manifest.json' if (-not (Test-Path -LiteralPath $manifestPath)) { Write-FailMsg 'specwright.manifest.json not found at repo root' @@ -329,6 +473,22 @@ if (-not (Test-Path -LiteralPath $manifestPath)) { $quantities[$areaName] = $areaCount } + # Stamp count is a THIRD kind of quantity here: not read from a source-tree glob (there + # is no stamp source dir) but derived from areas.*.installTo - one stamp lands in each + # distinct '/sd/' root the installer writes to. Seeded here, before the derived + # loop, so derived.installTotalWithStamps can reference it without depending on JSON key + # order, and it self-corrects if an area is added or removed. + $installRoots = @() + foreach ($areaProp in $manifest.areas.PSObject.Properties) { + $installTo = $areaProp.Value.installTo + if ([string]::IsNullOrEmpty($installTo)) { continue } + $segments = @($installTo -split '/' | Where-Object { $_ -ne '' }) + if ($segments.Count -lt 2) { continue } + $root = "$($segments[0])/$($segments[1])" + if ($installRoots -notcontains $root) { $installRoots += $root } + } + $quantities['installStamps'] = $installRoots.Count + foreach ($derProp in $manifest.derived.PSObject.Properties) { $derTotal = 0 foreach ($part in $derProp.Value) { @@ -337,6 +497,20 @@ if (-not (Test-Path -LiteralPath $manifestPath)) { $quantities[$derProp.Name] = $derTotal } + # Gate quantities are DECLARED, not derived: nothing on disk is a second + # source for "how many hard gates /sd:feature has". Seeding them here gives + # the topology README <- manifest (this check) and manifest <- disk (Check + # 8's CL302), hence transitively README == disk, with zero duplication of the + # gate parser into this file. A null quantity means the gate block is real + # but no doc publishes a number for it. + if ($null -ne $manifest.contractLint -and $null -ne $manifest.contractLint.gates) { + foreach ($gateProp in $manifest.contractLint.gates.PSObject.Properties) { + $qName = $gateProp.Value.quantity + if ([string]::IsNullOrEmpty($qName)) { continue } + $quantities[$qName] = [int]$gateProp.Value.hard + } + } + foreach ($claim in $manifest.docClaims) { if (-not $filePatterns.ContainsKey($claim.file)) { $filePatterns[$claim.file] = New-Object System.Collections.Generic.List[string] @@ -385,6 +559,57 @@ if (-not (Test-Path -LiteralPath $manifestPath)) { } } + # ---- Version claims: published version vs newest dated CHANGELOG heading --- + # Independent of Check 6's $nextHeader - that variable stays $null in the normal + # non-just-released state, since [Unreleased] currently has bullets right after its + # header and the Check 6 loop breaks on the bullet before ever hitting a '## [' line. + # Scan the whole file for the first dated release heading instead. + $releasedVersion = $null + foreach ($line in (Get-Content -LiteralPath $changelog)) { + $m = [regex]::Match($line, '^##\s+\[([0-9]+\.[0-9]+\.[0-9]+)\]\s+-\s+\S') + if ($m.Success) { $releasedVersion = $m.Groups[1].Value; break } + } + if ([string]::IsNullOrEmpty($releasedVersion)) { + Write-FailMsg 'CHANGELOG.md : no dated release heading found (## [x.y.z] - )' + Add-Failure 'docs: no dated release heading in CHANGELOG.md' + $docsBad++ + } + + foreach ($claim in @($manifest.versionClaims)) { + $target = Join-Path $repoRoot ($claim.file -replace '/', '\') + if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { + Write-FailMsg "$($claim.file) : declared version-claim file does not exist" + Add-Failure "docs: missing version-claim file $($claim.file)" + $docsBad++ + continue + } + if ([string]::IsNullOrEmpty($releasedVersion)) { continue } + + $hits = 0 + $lineNo = 0 + foreach ($line in (Get-Content -LiteralPath $target)) { + $lineNo++ + $m = [regex]::Match($line, $claim.pattern) + if ($m.Success) { + $hits++ + $found = $m.Groups[1].Value + if ($found -ne $releasedVersion) { + Write-FailMsg "$($claim.file):$lineNo : says $found, CHANGELOG has $releasedVersion" + Add-Failure "docs: $($claim.file):$lineNo version says $found not $releasedVersion" + $docsBad++ + } + } + } + + # A pattern that matches nothing is a rotted regex, not a pass - same rationale + # as the docClaims vacuous-claim check above. + if ($hits -eq 0) { + Write-FailMsg "$($claim.file) : version pattern matched no lines (reworded?): $($claim.pattern)" + Add-Failure "docs: vacuous version claim in $($claim.file)" + $docsBad++ + } + } + # Undeclared-claim scan: any line that looks like an inventory claim but is not # covered by a docClaims entry. This is what keeps the manifest canonical - a new # doc cannot publish a number that nothing checks. @@ -418,7 +643,58 @@ if (-not (Test-Path -LiteralPath $manifestPath)) { } if ($docsBad -eq 0) { - Write-Ok "$($manifest.docClaims.Count) published claim(s) match disk; no undeclared claims" + $versionClaimCount = @($manifest.versionClaims).Count + Write-Ok "$($manifest.docClaims.Count) published claim(s) + $versionClaimCount version claim(s) match disk/CHANGELOG; no undeclared claims" + } +} + +# ---- Check 8: cross-file contract lint -------------------------------------- + +Write-Section 'Check 8/8: Cross-file contract lint (commands / agents / skills)' +$lintPs1 = Join-Path $scriptDir 'contract-lint.ps1' +if (-not (Test-Path -LiteralPath $lintPs1 -PathType Leaf)) { + Write-FailMsg 'scripts/contract-lint.ps1 not found' + Add-Failure 'contract-lint: script missing' +} else { + # Spawned as a CHILD PROCESS, never with '&': contract-lint.ps1 calls exit, + # and an inline '&' would terminate validate.ps1 outright - leaving a green + # Check 7 line already printed and no summary at all. Same pattern as the + # installer invocation in Check 5. + $lintArgs = @('-NoProfile') + if ($env:OS -eq 'Windows_NT') { $lintArgs += @('-ExecutionPolicy', 'Bypass') } + $lintArgs += @('-File', $lintPs1, '-Root', $repoRoot, '-Quiet') + $lintOut = & $psExe @lintArgs 2>$null + $lintExit = $LASTEXITCODE + + # The linter is a dumb TSV emitter; all human formatting happens here, so + # both twins stay identical and neither learns about colours or [OK] tags. + $clBlocks = 0 + $clWarns = 0 + foreach ($row in @($lintOut)) { + if ([string]::IsNullOrWhiteSpace($row)) { continue } + $parts = $row.Split([char]9) + if ($parts.Count -lt 5) { continue } + $text = "$($parts[2]):$($parts[3]) $($parts[0]) - $($parts[4])" + if ($parts[1] -ceq 'BLOCK') { + Write-FailMsg $text + Add-Failure "contract-lint: $($parts[0]) $($parts[2]):$($parts[3])" + $clBlocks++ + } else { + Write-WarnMsg $text + $clWarns++ + } + } + if ($lintExit -ge 2) { + # Exit 2 means the linter could not run at all. Treating that as a pass + # is the failure mode this whole check exists to prevent. + Write-FailMsg "contract-lint could not run (exit $lintExit)" + Add-Failure "contract-lint: exit $lintExit" + } elseif ($clBlocks -eq 0) { + if ($clWarns -eq 0) { + Write-Ok 'no contract violations' + } else { + Write-Ok "no BLOCK violations ($clWarns warning(s) above)" + } } } diff --git a/scripts/validate.sh b/scripts/validate.sh index f66f184..28a93dc 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -12,6 +12,9 @@ # 6. CHANGELOG gate: the [Unreleased] section is non-empty. # 7. Docs consistency: published numbers in the docs match disk, per # specwright.manifest.json. +# 8. Cross-file contract lint: the relationships between commands, agents and +# skills, per the manifest's contractLint subtree. Delegated to +# scripts/contract-lint.sh as a child process. # # Exit 0 = all checks passed; 1 = at least one failed. @@ -31,6 +34,12 @@ EXPECTED_SKILLS="$(find "$repo_root/skills" -mindepth 2 -maxdepth 2 -type f -nam EXPECTED_HOOKS="$(find "$repo_root/hooks/bash" -maxdepth 1 -type f -name '*.sh' | wc -l | tr -d ' ')" EXPECTED_TEMPLATES="$(find "$repo_root/templates" -type f | wc -l | tr -d ' ')" +# The version stamp has no source-tree counterpart - it is generated at install time from +# CHANGELOG.md, never copied from a source dir - so unlike the counts above it cannot be +# derived from a glob. This is the one hand-written constant Check 5 uses: how many stamp +# files land per installed area. +STAMP_FILES_PER_AREA=1 + for pair in "commands:$EXPECTED_COMMANDS" "agents:$EXPECTED_AGENTS" "skills:$EXPECTED_SKILLS" \ "hooks:$EXPECTED_HOOKS" "templates:$EXPECTED_TEMPLATES"; do if [[ "${pair#*:}" -eq 0 ]]; then @@ -66,7 +75,7 @@ echo " Repo root: $repo_root" # ---- Check 1: pure-ASCII scan ---------------------------------------------- -section "Check 1/7: Pure-ASCII scan (*.ps1)" +section "Check 1/8: Pure-ASCII scan (*.ps1)" ascii_bad=0 ps1_count=0 while IFS= read -r -d '' f; do @@ -83,7 +92,7 @@ if [[ $ascii_bad -eq 0 ]]; then ok "$ps1_count .ps1 file(s) are pure ASCII"; fi # ---- Check 2: bash -n syntax ----------------------------------------------- -section "Check 2/7: bash -n syntax (*.sh)" +section "Check 2/8: bash -n syntax (*.sh)" syn_bad=0 sh_count=0 while IFS= read -r -d '' f; do @@ -100,7 +109,7 @@ if [[ $syn_bad -eq 0 ]]; then ok "$sh_count .sh file(s) pass bash -n"; fi # ---- Check 3: hook-pair parity --------------------------------------------- -section "Check 3/7: Hook-pair parity" +section "Check 3/8: Hook-pair parity" parity_bad=0 ps_count=0 for psf in "$repo_root"/hooks/powershell/*.ps1; do @@ -126,7 +135,7 @@ if [[ $parity_bad -eq 0 ]]; then ok "$ps_count hook pair(s) present on both plat # ---- Check 4: agent model aliases ------------------------------------------ -section "Check 4/7: Agent model aliases" +section "Check 4/8: Agent model aliases" model_bad=0 agent_count=0 for af in "$repo_root"/agents/*.md; do @@ -154,38 +163,180 @@ if [[ $model_bad -eq 0 ]]; then ok "$agent_count agent(s) use a model alias"; fi # ---- Check 5: install-target counts ---------------------------------------- -section "Check 5/7: Install-target counts" +section "Check 5/8: Install-target counts" install_sh="$repo_root/install/install.sh" tmp="${TMPDIR:-/tmp}/sd-validate-$$" -cleanup_tmp() { [[ -n "${tmp:-}" && -d "$tmp" ]] && rm -rf "$tmp" || true; } +tmp_nc_src="${TMPDIR:-/tmp}/sd-validate-nc-src-$$" +tmp_nc_base="${TMPDIR:-/tmp}/sd-validate-nc-base-$$" +cleanup_tmp() { + [[ -n "${tmp:-}" && -d "$tmp" ]] && rm -rf "$tmp" || true + [[ -n "${tmp_nc_src:-}" && -d "$tmp_nc_src" ]] && rm -rf "$tmp_nc_src" || true + [[ -n "${tmp_nc_base:-}" && -d "$tmp_nc_base" ]] && rm -rf "$tmp_nc_base" || true +} trap cleanup_tmp EXIT -rm -rf "$tmp" +rm -rf "$tmp" "$tmp_nc_src" "$tmp_nc_base" +# Counts files under dir and compares against expected, reporting through the same +# fail/ok/add_failure vocabulary as every other check. Reused below for both the +# fresh install and the idempotent re-run, so both runs are asserted through one +# code path rather than two hand-copied loops. +check_count() { + local name="$1" dir="$2" expected="$3" cnt=0 + if [[ -d "$dir" ]]; then cnt="$(find "$dir" -type f | wc -l | tr -d ' ')"; fi + if [[ "$cnt" -eq "$expected" ]]; then + ok "$name/sd : $cnt file(s)" + else + fail "$name/sd : expected $expected, found $cnt" + add_failure "install: $name/sd expected $expected found $cnt" + fi +} if bash "$install_sh" --base-path "$tmp" --force >/dev/null 2>&1; then - check_count() { - local name="$1" dir="$2" expected="$3" cnt=0 - if [[ -d "$dir" ]]; then cnt="$(find "$dir" -type f | wc -l | tr -d ' ')"; fi - if [[ "$cnt" -eq "$expected" ]]; then - ok "$name/sd : $cnt file(s)" + EXPECTED_COMMANDS_WITH_STAMP=$((EXPECTED_COMMANDS + STAMP_FILES_PER_AREA)) + EXPECTED_AGENTS_WITH_STAMP=$((EXPECTED_AGENTS + STAMP_FILES_PER_AREA)) + EXPECTED_SKILLS_WITH_STAMP=$((EXPECTED_SKILLS + STAMP_FILES_PER_AREA)) + EXPECTED_HOOKS_WITH_STAMP=$((EXPECTED_HOOKS + STAMP_FILES_PER_AREA)) + EXPECTED_TEMPLATES_WITH_STAMP=$((EXPECTED_TEMPLATES + STAMP_FILES_PER_AREA)) + check_count "commands" "$tmp/commands/sd" "$EXPECTED_COMMANDS_WITH_STAMP" + check_count "agents" "$tmp/agents/sd" "$EXPECTED_AGENTS_WITH_STAMP" + check_count "skills" "$tmp/skills/sd" "$EXPECTED_SKILLS_WITH_STAMP" + check_count "hooks" "$tmp/hooks/sd" "$EXPECTED_HOOKS_WITH_STAMP" + check_count "templates" "$tmp/templates/sd" "$EXPECTED_TEMPLATES_WITH_STAMP" + + # ---- stamp content: every area's stamp equals the CHANGELOG-derived version ---- + stamp_changelog="$repo_root/CHANGELOG.md" + stamp_version="" + stamp_release_line="$(grep -m1 -E '^##[[:space:]]+\[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]+-[[:space:]]+[^[:space:]]' "$stamp_changelog" || true)" + if [[ "$stamp_release_line" =~ \[([0-9]+\.[0-9]+\.[0-9]+)\] ]]; then + stamp_version="${BASH_REMATCH[1]}" + fi + if [[ -z "$stamp_version" ]]; then + fail "CHANGELOG.md : no dated release heading found (## [x.y.z] - )" + add_failure "install: stamp version source unreadable" + else + stamp_bad=0 + for area_dir in commands agents skills hooks templates; do + stamp_file="$tmp/$area_dir/sd/specwright-version.txt" + if [[ ! -f "$stamp_file" ]]; then + fail "$area_dir/sd/specwright-version.txt : not found" + add_failure "install: $area_dir/sd stamp missing" + stamp_bad=$((stamp_bad + 1)) + continue + fi + # Tolerate a stray trailing CR on read - the byte contract itself is + # asserted below on one stamp; this comparison only cares about content. + stamp_content="$(cat "$stamp_file")" + stamp_content="${stamp_content%$'\r'}" + if [[ "$stamp_content" != "$stamp_version" ]]; then + fail "$area_dir/sd/specwright-version.txt : content '$stamp_content' != CHANGELOG version '$stamp_version'" + add_failure "install: $area_dir/sd stamp content mismatch" + stamp_bad=$((stamp_bad + 1)) + fi + done + if [[ $stamp_bad -eq 0 ]]; then ok "5 stamp(s) match CHANGELOG version $stamp_version"; fi + + # Byte-level contract on one stamp (commands): no BOM, no CR, exactly one trailing LF. + byte_stamp="$tmp/commands/sd/specwright-version.txt" + if [[ -f "$byte_stamp" ]]; then + read -r -a stamp_bytes <<< "$(od -A n -v -t x1 "$byte_stamp")" + byte_count=${#stamp_bytes[@]} + has_bom=0 + if [[ $byte_count -ge 3 && "${stamp_bytes[0]}" == "ef" && "${stamp_bytes[1]}" == "bb" && "${stamp_bytes[2]}" == "bf" ]]; then + has_bom=1 + fi + has_cr=0 + for b in "${stamp_bytes[@]}"; do + if [[ "$b" == "0d" ]]; then has_cr=1; break; fi + done + ends_with_lf=0 + double_lf=0 + if [[ $byte_count -gt 0 && "${stamp_bytes[$((byte_count - 1))]}" == "0a" ]]; then + ends_with_lf=1 + if [[ $byte_count -gt 1 && "${stamp_bytes[$((byte_count - 2))]}" == "0a" ]]; then + double_lf=1 + fi + fi + if [[ $has_bom -eq 1 || $has_cr -eq 1 || $ends_with_lf -eq 0 || $double_lf -eq 1 ]]; then + fail "commands/sd/specwright-version.txt : byte contract violated (BOM=$has_bom CR=$has_cr trailingLF=$ends_with_lf doubleLF=$double_lf)" + add_failure "install: commands/sd stamp byte contract" + else + ok "commands/sd/specwright-version.txt : LF, no BOM, no CR, single trailing newline" + fi + fi + fi + + # ---- idempotent re-run: second --force pass must be a no-op for the stamp ---- + if bash "$install_sh" --base-path "$tmp" --force >/dev/null 2>&1; then + bak_count="$(find "$tmp" -type f -name '*.bak.*' | wc -l | tr -d ' ')" + if [[ "$bak_count" -gt 0 ]]; then + fail "second install run created $bak_count *.bak.* file(s) - stamp is not idempotent" + add_failure "install: second run produced .bak files" else - fail "$name/sd : expected $expected, found $cnt" - add_failure "install: $name/sd expected $expected found $cnt" + ok "second --force run created zero *.bak.* file(s)" fi - } - check_count "commands" "$tmp/commands/sd" "$EXPECTED_COMMANDS" - check_count "agents" "$tmp/agents/sd" "$EXPECTED_AGENTS" - check_count "skills" "$tmp/skills/sd" "$EXPECTED_SKILLS" - check_count "hooks" "$tmp/hooks/sd" "$EXPECTED_HOOKS" - check_count "templates" "$tmp/templates/sd" "$EXPECTED_TEMPLATES" + check_count "commands" "$tmp/commands/sd" "$EXPECTED_COMMANDS_WITH_STAMP" + check_count "agents" "$tmp/agents/sd" "$EXPECTED_AGENTS_WITH_STAMP" + check_count "skills" "$tmp/skills/sd" "$EXPECTED_SKILLS_WITH_STAMP" + check_count "hooks" "$tmp/hooks/sd" "$EXPECTED_HOOKS_WITH_STAMP" + check_count "templates" "$tmp/templates/sd" "$EXPECTED_TEMPLATES_WITH_STAMP" + else + fail "second installer run failed: bash install.sh --base-path --force" + add_failure "install: second installer run returned non-zero" + fi else fail "installer failed: bash install.sh --base-path --force" add_failure "install: installer returned non-zero" fi + +# ---- negative case: missing version source fails loudly, copies nothing ---- +# The required-source-directory list is mirrored FROM install.sh's own +# REQUIRED_DIRS array, not hand-duplicated here, so a future added requirement +# fails this scenario visibly instead of silently changing what it proves. +required_dirs=() +while IFS= read -r rd; do + required_dirs+=("$rd") +done < <(grep -m1 '^REQUIRED_DIRS=' "$install_sh" | grep -oE '"[^"]+"' | tr -d '"') +if [[ ${#required_dirs[@]} -eq 0 ]]; then + fail "install.sh : could not parse REQUIRED_DIRS - missing-CHANGELOG scenario skipped" + add_failure "install: could not parse install.sh required dirs" +else + mkdir -p "$tmp_nc_src/install" + cp "$install_sh" "$tmp_nc_src/install/install.sh" + for d in "${required_dirs[@]}"; do + mkdir -p "$tmp_nc_src/$d" + done + nc_out="" + nc_exit=0 + nc_out="$(bash "$tmp_nc_src/install/install.sh" --base-path "$tmp_nc_base" --force 2>&1)" || nc_exit=$? + nc_bad=0 + if [[ $nc_exit -eq 0 ]]; then + fail "missing-CHANGELOG scenario: installer exited 0, expected non-zero" + add_failure "install: missing-changelog scenario did not fail" + nc_bad=$((nc_bad + 1)) + fi + if [[ "$nc_out" != *"CHANGELOG.md"* ]]; then + fail "missing-CHANGELOG scenario: installer output did not mention CHANGELOG.md" + add_failure "install: missing-changelog scenario message missing CHANGELOG.md" + nc_bad=$((nc_bad + 1)) + fi + nc_file_count=0 + if [[ -d "$tmp_nc_base" ]]; then + nc_file_count="$(find "$tmp_nc_base" -type f | wc -l | tr -d ' ')" + fi + if [[ "$nc_file_count" -ne 0 ]]; then + fail "missing-CHANGELOG scenario: base contains $nc_file_count file(s), expected 0" + add_failure "install: missing-changelog scenario copied files" + nc_bad=$((nc_bad + 1)) + fi + if [[ $nc_bad -eq 0 ]]; then + ok "missing-CHANGELOG.md scenario: installer failed loudly and copied nothing" + fi +fi + cleanup_tmp trap - EXIT # ---- Check 6: CHANGELOG [Unreleased] non-empty ----------------------------- -section "Check 6/7: CHANGELOG [Unreleased] gate" +section "Check 6/8: CHANGELOG [Unreleased] gate" changelog="$repo_root/CHANGELOG.md" block="$(awk ' /^##[[:space:]]+\[Unreleased\]/ { f=1; next } @@ -210,7 +361,7 @@ fi # ---- Check 7: docs consistency --------------------------------------------- -section "Check 7/7: Docs consistency (published numbers vs disk)" +section "Check 7/8: Docs consistency (published numbers vs disk)" manifest="$repo_root/specwright.manifest.json" if [[ ! -f "$manifest" ]]; then fail "specwright.manifest.json not found at repo root" @@ -304,6 +455,31 @@ else done < <(mjq '.areas | to_entries[] | "\(.key)\t\(if .value.glob then "glob" else "files" end)\t\(.value.glob // (.value.files | join(" ")))"') shopt -u nullglob + # Stamp count is a THIRD kind of quantity here: not read from a source-tree glob (there + # is no stamp source dir) but derived from areas.*.installTo - one stamp lands in each + # distinct '/sd/' root the installer writes to. Seeded here, before the derived + # loop, so derived.installTotalWithStamps can reference it without depending on JSON key + # order, and it self-corrects if an area is added or removed. Plain string + word-split, + # not an array: an empty array under `set -u` is not safe on bash 3.2 (macOS). + install_roots="" + while IFS= read -r install_to; do + [[ -z "$install_to" ]] && continue + seg1="${install_to%%/*}" + rest="${install_to#*/}" + seg2="${rest%%/*}" + [[ -z "$seg2" ]] && continue + root="$seg1/$seg2" + case " $install_roots " in + *" $root "*) ;; + *) install_roots="$install_roots $root" ;; + esac + done < <(mjq '.areas | to_entries[] | .value.installTo // empty') + install_stamps=0 + for r in $install_roots; do + install_stamps=$((install_stamps + 1)) + done + q_set "installStamps" "$install_stamps" + while IFS=$'\t' read -r der_name der_parts; do der_total=0 for part in $der_parts; do @@ -313,6 +489,17 @@ else q_set "$der_name" "$der_total" done < <(mjq '.derived | to_entries[] | "\(.key)\t\(.value | join(" "))"') + # Gate quantities are DECLARED, not derived: nothing on disk is a second + # source for "how many hard gates /sd:feature has". Seeding them here gives + # the topology README <- manifest (this check) and manifest <- disk (Check + # 8's CL302), hence transitively README == disk, with zero duplication of the + # gate parser into this file. A null quantity means the gate block is real + # but no doc publishes a number for it. + while IFS=$'\t' read -r gate_q gate_hard; do + [[ -z "$gate_q" || "$gate_q" == "null" ]] && continue + q_set "$gate_q" "$gate_hard" + done < <(mjq '.contractLint.gates // {} | to_entries[] | "\(.value.quantity)\t\(.value.hard)"') + while IFS=$'\t' read -r c_file c_pattern c_equals; do fp_append "$c_file" "$c_pattern" @@ -355,6 +542,56 @@ else fi done < <(mjq '.docClaims[] | "\(.file)\t\(.pattern)\t\(.equals)"') + # ---- Version claims: published version vs newest dated CHANGELOG heading --- + # Independent of Check 6's next_header - that variable resolves to whatever line + # sits directly below [Unreleased], which is a bullet (not a header) in the normal + # non-just-released state, so it cannot be relied on here. Scan the whole file for + # the first dated release heading instead. + released_version="" + release_line="$(grep -m1 -E '^##[[:space:]]+\[[0-9]+\.[0-9]+\.[0-9]+\][[:space:]]+-[[:space:]]+[^[:space:]]' "$changelog" || true)" + if [[ "$release_line" =~ \[([0-9]+\.[0-9]+\.[0-9]+)\] ]]; then + released_version="${BASH_REMATCH[1]}" + fi + if [[ -z "$released_version" ]]; then + fail "CHANGELOG.md : no dated release heading found (## [x.y.z] - )" + add_failure "docs: no dated release heading in CHANGELOG.md" + docs_bad=$((docs_bad + 1)) + fi + + while IFS=$'\t' read -r v_file v_pattern; do + target="$repo_root/$v_file" + if [[ ! -f "$target" ]]; then + fail "$v_file : declared version-claim file does not exist" + add_failure "docs: missing version-claim file $v_file" + docs_bad=$((docs_bad + 1)) + continue + fi + [[ -z "$released_version" ]] && continue + + hits=0 + lineno=0 + while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + if [[ "$line" =~ $v_pattern ]]; then + hits=$((hits + 1)) + found="${BASH_REMATCH[1]}" + if [[ "$found" != "$released_version" ]]; then + fail "$v_file:$lineno : says $found, CHANGELOG has $released_version" + add_failure "docs: $v_file:$lineno version says $found not $released_version" + docs_bad=$((docs_bad + 1)) + fi + fi + done < "$target" + + # A pattern that matches nothing is a rotted regex, not a pass - same rationale + # as the docClaims vacuous-claim check above. + if [[ $hits -eq 0 ]]; then + fail "$v_file : version pattern matched no lines (reworded?): $v_pattern" + add_failure "docs: vacuous version claim in $v_file" + docs_bad=$((docs_bad + 1)) + fi + done < <(mjq '.versionClaims[] | "\(.file)\t\(.pattern)"') + # Undeclared-claim scan: any line that looks like an inventory claim but is not # covered by a docClaims entry. This is what keeps the manifest canonical - a new # doc cannot publish a number that nothing checks. @@ -395,7 +632,54 @@ else if [[ $docs_bad -eq 0 ]]; then claim_total="$(mjq '.docClaims | length')" - ok "$claim_total published claim(s) match disk; no undeclared claims" + version_total="$(mjq '.versionClaims | length')" + ok "$claim_total published claim(s) + $version_total version claim(s) match disk/CHANGELOG; no undeclared claims" + fi +fi + +# ---- Check 8: cross-file contract lint ------------------------------------- + +section "Check 8/8: Cross-file contract lint (commands / agents / skills)" +lint_sh="$script_dir/contract-lint.sh" +if [[ ! -f "$lint_sh" ]]; then + fail "scripts/contract-lint.sh not found" + add_failure "contract-lint: script missing" +else + # Spawned as a CHILD PROCESS so its `exit` cannot terminate this validator, + # and so its stdout stays a clean machine-readable stream. All human + # formatting happens here; the linter stays a dumb TSV emitter and the two + # linter twins never learn about colours or [OK] tags. + lint_out="" + lint_exit=0 + lint_out="$(bash "$lint_sh" --root "$repo_root" --quiet 2>/dev/null)" || lint_exit=$? + + cl_blocks=0 + cl_warns=0 + if [[ -n "$lint_out" ]]; then + while IFS=$'\t' read -r cl_rule cl_sev cl_file cl_line cl_msg; do + [[ -z "$cl_rule" ]] && continue + if [[ "$cl_sev" == "BLOCK" ]]; then + fail "$cl_file:$cl_line $cl_rule - $cl_msg" + add_failure "contract-lint: $cl_rule $cl_file:$cl_line" + cl_blocks=$((cl_blocks + 1)) + else + warn "$cl_file:$cl_line $cl_rule - $cl_msg" + cl_warns=$((cl_warns + 1)) + fi + done <<< "$lint_out" + fi + + if [[ $lint_exit -ge 2 ]]; then + # Exit 2 means the linter could not run at all. Treating that as a pass + # is the failure mode this whole check exists to prevent. + fail "contract-lint could not run (exit $lint_exit)" + add_failure "contract-lint: exit $lint_exit" + elif [[ $cl_blocks -eq 0 ]]; then + if [[ $cl_warns -eq 0 ]]; then + ok "no contract violations" + else + ok "no BLOCK violations ($cl_warns warning(s) above)" + fi fi fi diff --git a/skills/sd-port-fidelity/SKILL.md b/skills/sd-port-fidelity/SKILL.md new file mode 100644 index 0000000..50d8882 --- /dev/null +++ b/skills/sd-port-fidelity/SKILL.md @@ -0,0 +1,243 @@ +# sd-port-fidelity + +Cross-project port fidelity policy for specwright. A port reproduces a donor implementation inside +a host repo; the donor's form is the specification, and every departure from it is a citable +decision rather than a preference. Authored into port specs by `sd-spec-architect` and enforced +against the changeset by `sd-reviewer`. + +Read the section matching your role. The core rule and the deviation allowlist apply to everyone. + +--- + +## The core rule + +**Mirror the donor structurally. Every departure is a row in the deviation table, or it is a +defect.** + +Structural mirror means the host reproduces, as far as the target permits: the donor's file +layout, member set, member order, symbol names, local variable names, step order within each +member, attribute order, and log message text. Judgement is not the mechanism here. A departure +carrying a deviation row is legal; a departure carrying none is a defect; and nobody is asked to +decide which of the two a given hunk is. + +--- + +## Deviation allowlist + +Exactly four groups force a departure. Each requires its own citation - a row citing nothing is +invalid, and the hunk it claims to cover is unjustified. + +| Group | Forced by | Citation required | +|---|---|---| +| 1 | Compiler, namespace, or assembly conflict | the conflicting identifier | +| 2 | Host constitution | the constitution section, `§N.M` | +| 3 | Host precedent | `file:line` in the host | +| 4 | Behavior parity fix on an agreed donor defect | the parity criterion it satisfies | + +Anything outside these four reverts to the donor form. "Cleaner", "more idiomatic to me", and "the +host does it differently somewhere" are not groups. + +--- + +## Anti-simplification rules + +These bind the implementer through the task blocks the architect writes, not by the implementer +loading this file: `sd-spec-architect` puts each applicable rule into the port task's `Acceptance` +and cites the donor `file:line` in `Pattern refs` (see **sd-atomic-task-format**). + +- **No unrequested simplification.** Shorter is not better. Collapsing branches, merging members, + or replacing a loop with a library call is a departure and needs a row. +- **No gratuitous rename.** Donor identifier names carry over even where host style would differ. + Group 1, group 3, or nothing. +- **No reordering.** Statement order, parameter order, and member order follow the donor, even + where another order reads better. +- **No opportunistic fix.** A donor defect is reproduced as-is and recorded for a follow-up spec. + Fixing it inline is group 4 only when the fix was agreed at the gate before implementation began. +- **Host constitution beats donor precedent.** "It is only a copy" exempts nothing. A donor form + that violates the host constitution is a cited group 2 deviation, never a verbatim import. + +--- + +## Gate tables (sd-spec-architect) + +A port spec carries three tables. Each has a completeness condition the gate evaluates +mechanically - it counts and matches, it never assesses whether a reason is good. A table failing +its condition is incomplete and the gate refuses it. + +The spec must also carry a **fidelity acceptance criterion**: the criterion these tables are +evidence for. It is what review findings anchor to, and without it a fidelity finding has no legal +anchor (see **sd-severity-taxonomy**). A port spec missing it fails the gate. + +### Path mapping table + +| Column | Contents | +|---|---| +| `Donor path` | repo-relative path in the donor | +| `Host path` | repo-relative destination in the host | +| `Kind` | `mirror` / `merge` / `omit` | +| `Reason` | required when `Kind` is not `mirror`; `-` when it is | + +**Complete when**: one row per file in the declared donor scope and no others; every `Host path` +unique; no empty cell; every non-`mirror` row carries a non-empty `Reason`. + +### Member manifest + +| Column | Contents | +|---|---| +| `Donor path` | the donor file the member comes from | +| `Member` | donor symbol name, verbatim | +| `Ordinal` | 1-based position of the member within that donor file | +| `Host path` | destination, copied from the path mapping table | +| `Status` | `ported` / `deviated` / `omitted` | +| `Deviation ID` | `D` when `Status` is not `ported`; `-` when it is | + +**Complete when**: one row per member of every non-`omit` donor file; the `Ordinal` values within +each `Donor path` form `1..N` with no gap and no duplicate; each `Host path` equals the mapping row +for its `Donor path`; every non-`ported` row names a `Deviation ID` present in the deviation table. + +### Deviation table + +| Column | Contents | +|---|---| +| `ID` | `D`, unique, ascending | +| `Donor form` | the donor name, order, or text departed from | +| `Host form` | what the host uses instead | +| `Group` | `1`, `2`, `3`, or `4` from the allowlist | +| `Citation` | the citation that group requires, verbatim | + +**Complete when**: every `ID` unique; every `Group` is one of `1` to `4`; every `Citation` is +non-empty and in the shape its group requires; every `Deviation ID` named in the member manifest +appears here exactly once; no row here is unreferenced by the manifest. + +--- + +## Snapshot artifacts (sd-spec-architect) + +Under a port spec's `04-artifacts/source/`: the bridged contract always, and under +`snapshot: contract+source`, the donor files too. `MANIFEST.md` sits at that directory's root. + +### Snapshot manifest format + +- **Donor**: +- **Commit**: +- **Captured**: +- **Mode**: contract | contract+source +- **Hash algorithm**: sha256, lowercase hex, over the bytes as captured + +| Snapshot path | Donor path | Commit | Bytes | SHA-256 | Member ranges | +|---|---|---|---|---|---| +| `orders/order-intake` | `src/orders/order-intake` | `` | 4821 | `3f9a...` | `1: CreateOrder 12-58; 2: ValidateTotals 60-93` | + +One row per captured file. `Member ranges` is `: -`, +semicolon-separated, ordinals contiguous from 1 - the same ordinals the Member manifest table uses. + +### Two snapshot rules + +1. **Cite the snapshot, not the donor.** Path mapping and Member manifest rows cite `Snapshot + path`, never a donor-repo path - donor line numbers rot the moment the donor changes; the + snapshot is frozen. Donor paths, the commit, and line ranges live only in `MANIFEST.md`, + addressed by `(Snapshot path, Ordinal)` - the Member manifest table carries no line-range + column of its own. +2. **Quarantine.** The snapshot directory is evidence, never a copy source handed to an + implementer without a Member manifest row and an allowed-deviation list scoping it. + +### Immutability + +At freeze, every file under `04-artifacts/source/` plus `MANIFEST.md` is appended as an +**individual literal string** to `paths.protected` in `.claude/project-config.json`. Both +`spec-gate` hooks match `paths.protected` by exact string equality, not glob - the enumeration +*is* the mechanism, not a workaround for one. The append must be idempotent. `/sd:spec` cannot +perform this step itself (it never touches `.claude/`); `/sd:port` Phase 2 performs the freeze. +Outside that pipeline it stays a documented manual step. `spec-gate` guards `Edit`/`Write` only - +it does not stop a shell delete. + +Per-file hashes are recorded now so a later drift check (re-hashing the donor at a newer commit) +remains possible; consuming them is out of scope for this story. + +--- + +## Parity artifacts (main thread) + +The diff `sd-reviewer` adjudicates is produced by the main thread, never by an agent. The reviewer +has no `Bash` and no write tool - that allowlist is what structurally stops the adjudicator from +fixing what it judges - so the diff must already be a file on disk when the reviewer is invoked. + +Output root is `04-artifacts/parity/`, a sibling of the frozen `04-artifacts/source/`: same spec, +same artifacts folder, evidence produced at a later phase by a different actor. Nothing under +`parity/` is frozen or appended to `paths.protected` - it is regenerated on every parity run and +overwrites the previous one. + +- One diff per non-`omit` path mapping row, snapshot side first, host side second, unified format, + at least 3 lines of context. File name is the `Host path` with `/` replaced by `__`, suffix + `.diff`. +- A mapping row whose `Host path` does not exist still gets its diff - an all-deletion one. That is + what makes a `missing` member visible without the reviewer listing the host tree itself. +- Every changeset file with no mapping row gets an all-addition diff, named the same way. Path + conformance is then a property of the diff set rather than a second traversal. +- `INDEX.md` at the parity root, one row per diff file: `Diff file`, `Snapshot path`, `Host path`, + `Mapping row` (the path mapping row number, or `-` when there is none). + +`INDEX.md` is the reviewer's `DIFF_REF`. A run that cannot produce a diff for a row says so in that +row's `Mapping row` cell instead of dropping the row - a silently absent row reads as a clean file. + +--- + +## Hunk classification (sd-reviewer) + +Classify every hunk of the port changeset into exactly one class. The vocabulary is closed - there +is no sixth class and no "probably fine". + +| Class | Condition | Verdict | +|---|---|---| +| `justified` | maps to a deviation row whose citation satisfies its group | accept | +| `unjustified` | departs from the donor form with no deviation row covering it | BLOCK | +| `missing` | a manifest member has no host counterpart and no `omitted` status | BLOCK | +| `extra` | host content with no donor counterpart and no deviation row | BLOCK | +| `overreached` | a deviation row covers the hunk, but the hunk changes more than that row's `Host form` states | BLOCK | + +`overreached` is where a rubber stamp hides: one legitimately cited rename carrying an unrelated +reordering in the same hunk. Judge the hunk against the row's `Host form` text, never against the +mere existence of a row. + +A `justified` hunk is a PASS and is not written up as a finding - report the count in the summary +line. Listing them individually buries the BLOCKs they outnumber. + +Anchor every fidelity BLOCK to the port spec's fidelity acceptance criterion - a spec acceptance +criterion is a legal anchor for a code target under **sd-severity-taxonomy**. Cite the deviation +row, or its absence, as the supporting `file:line` per **sd-evidence-citation**. A deviation row is +evidence; it is never the anchor by itself. + +### Whole-artifact checks + +Two checks no hunk class can express, because each is a property of the changeset as a whole. Both +run once per parity review, both are BLOCK, and both are reported even when they pass - the count +is the evidence the check ran. + +| Check | Question | Reported as | +|---|---|---| +| Member completeness | does every manifest row with `Status` `ported` or `deviated` have a host counterpart? | `/ members present`, plus one BLOCK naming the `Donor path`, `Member` and `Ordinal` of each absent row | +| Path conformance | does every changeset file appear as a `Host path` in the path mapping table? | one BLOCK per unmapped file, citing the file and the table it is missing from | + +Both belong to the reviewer rather than to `/sd:verify`, which decides everything from the spec's +own artifacts and reads no host source - the reasoning is recorded in `docs/architecture.md`. + +### The parity gate + +A HARD gate. Any BLOCK from the classification or from either whole-artifact check refuses +close-out. Two resolutions exist: revert the host toward the snapshot, or add a deviation row whose +group and citation hold up, then regenerate the parity artifacts and re-run. There is no third, and +no override - a gate that can be waived measures nothing. Deriving the missing rows from the +unexplained hunks is not a resolution either; it makes the diff justify itself. + +--- + +## Anti-patterns + +- **Improving the donor while porting.** The port is not the moment. Reproduce it, record it, and + raise the follow-up. +- **A deviation row citing nothing.** "Host style" is not a citation; group 3 needs `file:line`. +- **Renaming to local taste.** Taste is not one of the four groups. +- **Reordering for readability.** Donor order is the order, attribute order included. +- **Waving a table through because it looks right.** The completeness conditions are counted, not + judged. An incomplete table is refused without a discussion of intent. +- **Treating a donor defect as an invitation.** Record it; fix it in its own spec. diff --git a/skills/sd-retro-lessons/SKILL.md b/skills/sd-retro-lessons/SKILL.md index c963d36..45010ce 100644 --- a/skills/sd-retro-lessons/SKILL.md +++ b/skills/sd-retro-lessons/SKILL.md @@ -65,9 +65,11 @@ One line. Exactly this grammar: - `tag` - from the enum above, lowercase kebab. - `severity` - `high` | `medium` | `low`. High means it would have shipped a defect or lost a day. Low means it cost minutes. -- `scope` - `feature` | `bug` | `refactor` | `perf` | `rca` | `all`. This is the selector a - future run filters on, so `all` must be earned: use it only when the rule genuinely does - not depend on the workflow. +- `scope` - `feature` | `bug` | `refactor` | `perf` | `rca` | `port` | `all`. This is the selector + a future run filters on, so `all` must be earned: use it only when the rule genuinely does not + depend on the workflow. A `port`-scoped lesson is recorded but not yet selected by + `subagent-retro` - the hook's in-progress-spec detection does not recognize the `PORT-` prefix + (see `docs/troubleshooting.md`). - `Rule sentence` - one sentence, imperative, **120 characters maximum**. After aggregation a repeat count may be appended - ` (3)`. Authors never write it. @@ -94,6 +96,7 @@ Forbidden in a rule sentence: - snake_case identifiers. - Repository, product, team, customer, or person names. + Technology proper nouns that happen to be PascalCase (PowerShell, TypeScript, PostgreSQL, and similar) are allowed via a small allowlist in the validator. Extending that allowlist takes a PR - and usually means the lesson is less portable than you think. diff --git a/skills/sd-spec-templates/SKILL.md b/skills/sd-spec-templates/SKILL.md index 0b17421..fc716e8 100644 --- a/skills/sd-spec-templates/SKILL.md +++ b/skills/sd-spec-templates/SKILL.md @@ -27,6 +27,7 @@ Each template has a dedicated section below. Read only the section matching the | Refactor | `smell` | | Perf | `target_metric` | | RCA | `severity`, `incident_started`, `incident_resolved` | +| Port | `jira` (or `none`), `scope` (`endpoint`\|`module`\|`feature`\|`pattern`), `source_repo`, `source_commit`, `source_license`, `snapshot` (`contract`\|`contract+source`) | `linked_specs` is always `[]` at authoring time. Cross-references are written later by `/sd:spec link`, which maintains the inverse entry on the other spec — never hand-write the list, @@ -45,6 +46,10 @@ Fill: - **`complexity` frontmatter** — the whole-spec size estimate, `S` | `M` | `L`, with a one-line rationale in the trailing comment. See "Complexity estimate" below. +**Leave empty**: `## Spawned specs` — header + separator only, no rows and no `<<...>>` token. It +mirrors `rca.template.md`'s four-column table and is filled at close-out by the owning workflow, +not at create time. A reserved ID is a placeholder, never an `.specs/index.md` row. + - Scenario headings use stable IDs: `### SC-: `. IDs are sequential from SC-1 and are never renumbered or reused after a scenario is deleted - downstream `Covers` fields and `/sd:verify` reports reference them. @@ -118,7 +123,10 @@ Fill: - **Affected** — components, scope, first introduced (version / PR if known). - **Severity** — P0 (production down) / P1 (major feature broken) / P2 (partial degradation) / P3 (minor). -**Leave empty**: Root cause section, Fix approach section — these are filled in Phase 3 (debugger) and Phase 5 (main thread + implementer). Pre-filling breaks the workflow gate discipline. +**Leave empty**: Root cause section, Fix approach section, `## Spawned specs` (header + separator +only, no `<<...>>` token) — the first two are filled in Phase 3 (debugger) and Phase 5 (main thread ++ implementer); `## Spawned specs` is filled at close-out by the owning workflow. Pre-filling +breaks the workflow gate discipline. --- @@ -134,6 +142,8 @@ Fill: Leave TBD: - **Impact surface** — Phase 2 explorer fills. - **Test coverage prerequisite measurements** — Phase 3 fills. +- **`## Spawned specs`** — header + separator only, no `<<...>>` token; filled at close-out by the + owning workflow, not at create time. Public API: preserved by default. Any public API change requires explicit mention in the spec. @@ -152,6 +162,7 @@ Leave empty: - **Results log** — filled iteratively in Phase 4. - **Hypothesis tree** — filled by debugger in Phase 3. - **Trade-offs accepted** — filled at close-out. +- **`## Spawned specs`** — header + separator only, no `<<...>>` token; filled at close-out. --- @@ -172,6 +183,31 @@ Spec ID format: `RCA--` using UTC date. --- +## port.template.md + +`source_repo` / `source_commit` are required **values**, not just present fields, when `scope` is +not `pattern` (write `none` for `pattern`). The three fidelity tables - Path mapping table, Member +manifest, Deviation table - and the mandatory fidelity acceptance criterion (`AC-1`, pre-filled, +never reworded or renumbered) are owned by **sd-port-fidelity**: read that skill for the column +schemas and completeness conditions rather than re-deriving them here. + +Fill: +- **Why**, **Donor provenance**, **Behavioral contract** (fixed 7-row table), **Success criteria** + `AC-2` onward, **Out of scope**, **Open questions**. +- **`## Behavioral invariants (non-obvious)`** is mandatory even when short and must never be + deleted - it is the single highest-value section in the spec. +- **`## Spawned specs`** mirrors `rca.template.md`'s 4-column table: one row per donor defect + reproduced deliberately, not per sanctioned deviation. + +**No `<>` tokens exist for this template** (same as `feature.template.md`) - the +fidelity tables and `Frozen:` line are progressively filled by `sd-spec-architect` `refine` calls +and main-thread edits across `/sd:port`'s phases, not gated by phase-deferred tokens. Do not invent +one. + +Spec ID format: `PORT--` using UTC date. + +--- + ## Anti-patterns - Filling cross-phase fields prematurely (bug root cause, perf results log, rca root cause). @@ -179,3 +215,5 @@ Spec ID format: `RCA--` using UTC date. - Hardcoding stack assumptions — always read `CLAUDE.md` first; never default to a stack from prior invocations. - Producing the spec text in the prose response — write to the file; return only the summary + path. - Glossing over a constitution violation — if §1.1 is at risk, that is an Open question. +- Inventing a line-range column in the port member manifest — line ranges live only in + `04-artifacts/source/MANIFEST.md`, keyed by `(Snapshot path, Ordinal)`. diff --git a/specwright.manifest.json b/specwright.manifest.json index 35b4d29..85be04f 100644 --- a/specwright.manifest.json +++ b/specwright.manifest.json @@ -60,10 +60,11 @@ "commands/bug.md", "commands/rca.md", "commands/refactor.md", - "commands/perf.md" + "commands/perf.md", + "commands/port.md" ], "installTo": "commands/sd/", - "description": "Spec-producing pipelines - a SUBSET of commands (the rest are utilities: spec, explore, review, setup, release, adr, verify, status). Docs cite this subset count separately from the total, so it needs its own quantity. Declared as an explicit list because no glob expresses 'these 5 of the 13'; the validators also assert each file exists." + "description": "Spec-producing pipelines - a SUBSET of commands (the rest are utilities: spec, explore, review, setup, release, adr, verify, status). Docs cite this subset count separately from the total, so it needs its own quantity. Declared as an explicit list because no glob expresses 'these 6 of the 14'; the validators also assert each file exists." } }, @@ -76,6 +77,15 @@ "hooksPowerShell", "templatesSetup", "templatesSpec" + ], + "installTotalWithStamps": [ + "commands", + "agents", + "skills", + "hooksPowerShell", + "templatesSetup", + "templatesSpec", + "installStamps" ] }, @@ -111,7 +121,9 @@ "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (setup|spec) templates", "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) templates", "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (reusable )?skills", - "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (reusable )?rule packs" + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) (reusable )?rule packs", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) hard gates?", + "([Oo]ne|[Tt]wo|[Tt]hree|[Ff]our|[Ff]ive|[Ss]ix|[Ss]even|[Ee]ight|[Nn]ine|[Tt]en|[Ee]leven|[Tt]welve|[Tt]hirteen|[Ff]ourteen|[Ff]ifteen|[Ss]ixteen|[Ss]eventeen|[Ee]ighteen|[Nn]ineteen|[Tt]wenty|[0-9]+) version stamps?" ], "$claimPhrasesComment": [ @@ -175,6 +187,8 @@ { "file": "README.md", "pattern": "templates\\*\\* \\| ([0-9]+) setup templates", "equals": "templatesSetup" }, { "file": "README.md", "pattern": "setup templates \\+ ([0-9]+) spec templates", "equals": "templatesSpec" }, { "file": "README.md", "pattern": "^\\| \\*\\*([0-9]+) reusable skills\\*\\* \\|", "equals": "skills" }, + { "file": "README.md", "pattern": "they orchestrate ([0-9]+) subagents", "equals": "agents" }, + { "file": "README.md", "pattern": "Cross-cutting rules live in \\*\\*([0-9]+) skills\\*\\*", "equals": "skills" }, { "file": "CLAUDE.md", "pattern": "commands/sd/` \\| ([0-9]+) slash commands", "equals": "commands" }, { "file": "CLAUDE.md", "pattern": "agents/sd/` \\| ([0-9]+) subagents", "equals": "agents" }, @@ -204,7 +218,8 @@ { "file": "install/README.md", "pattern": "`templates/sd/` \\| ([0-9]+) \\+ [0-9]+ \\|", "equals": "templatesSetup" }, { "file": "install/README.md", "pattern": "`templates/sd/` \\| [0-9]+ \\+ ([0-9]+) \\|", "equals": "templatesSpec" }, { "file": "install/README.md", "pattern": "^\\| `skills/` \\| `skills/sd/` \\| ([0-9]+) \\|", "equals": "skills" }, - { "file": "install/README.md", "pattern": "\\*\\*Total\\*\\*: ([0-9]+) files per OS", "equals": "installTotal" }, + { "file": "install/README.md", "pattern": "^\\| _\\(generated at install\\)_ \\| `/sd/specwright-version\\.txt` \\| ([0-9]+) \\|", "equals": "installStamps" }, + { "file": "install/README.md", "pattern": "\\*\\*Total\\*\\*: ([0-9]+) files per OS", "equals": "installTotalWithStamps" }, { "file": "docs/architecture.md", "pattern": "commands/sd/ +([0-9]+) workflow definitions", "equals": "commands" }, { "file": "docs/architecture.md", "pattern": "agents/sd/ +([0-9]+) subagent prompt files", "equals": "agents" }, @@ -219,6 +234,279 @@ { "file": "commands/setup.md", "pattern": "commands/sd/ +\\(([0-9]+) workflow commands\\)", "equals": "commands" }, { "file": "commands/setup.md", "pattern": "agents/sd/ +\\(([0-9]+) specialist agents\\)", "equals": "agents" }, { "file": "commands/setup.md", "pattern": "hooks/sd/ +\\(([0-9]+) hooks\\)", "equals": "hooksPowerShell" }, - { "file": "commands/setup.md", "pattern": "skills/sd/ +\\(([0-9]+) skills:", "equals": "skills" } - ] + { "file": "commands/setup.md", "pattern": "skills/sd/ +\\(([0-9]+) skills:", "equals": "skills" }, + + { "file": "README.md", "pattern": "^\\| `/sd:feature [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesFeature" }, + { "file": "README.md", "pattern": "^\\| `/sd:bug [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesBug" }, + { "file": "README.md", "pattern": "^\\| `/sd:rca [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesRca" }, + { "file": "README.md", "pattern": "^\\| `/sd:refactor [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesRefactor" }, + { "file": "README.md", "pattern": "^\\| `/sd:perf [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesPerf" }, + { "file": "README.md", "pattern": "^\\| `/sd:port [^|]*\\| Workflow \\| ([0-9]+) \\|", "equals": "gatesPort" }, + { "file": "README.md", "pattern": "^\\| `/sd:setup` \\| Utility \\| ([0-9]+) \\|", "equals": "gatesSetup" }, + { "file": "README.md", "pattern": "^\\| `/sd:release [^|]*\\| Utility \\| ([0-9]+) \\|", "equals": "gatesRelease" }, + { "file": "README.md", "pattern": "^\\| `/sd:adr .*\\| Utility \\| ([0-9]+) \\|", "equals": "gatesAdr" }, + + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:feature` \\| ([0-9]+) \\|", "equals": "gatesFeature" }, + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:bug` \\| ([0-9]+) \\|", "equals": "gatesBug" }, + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:rca` \\| ([0-9]+) \\|", "equals": "gatesRca" }, + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:refactor` \\| ([0-9]+) \\|", "equals": "gatesRefactor" }, + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:perf` \\| ([0-9]+) \\|", "equals": "gatesPerf" }, + { "file": "docs/architecture.md", "pattern": "^\\| `/sd:port` \\| ([0-9]+) \\|", "equals": "gatesPort" }, + + { "file": "commands/feature.md", "pattern": "([0-9]+) hard gates", "equals": "gatesFeature" }, + { "file": "commands/bug.md", "pattern": "([0-9]+) hard gates", "equals": "gatesBug" }, + { "file": "commands/rca.md", "pattern": "([0-9]+) hard gates", "equals": "gatesRca" }, + { "file": "commands/refactor.md", "pattern": "([0-9]+) hard gates", "equals": "gatesRefactor" }, + { "file": "commands/perf.md", "pattern": "([0-9]+) hard gates", "equals": "gatesPerf" }, + { "file": "commands/port.md", "pattern": "([0-9]+) hard gates", "equals": "gatesPort" }, + { "file": "commands/adr.md", "pattern": "([0-9]+) hard gate", "equals": "gatesAdr" }, + + { "file": "docs/usage.md", "pattern": "([0-9]+) hard gates", "equals": "gatesFeature" }, + { "file": "docs/adr/0003-adaptive-replan-loop.md", "pattern": "([0-9]+) hard gates", "equals": "gatesFeature" } + ], + + "versionClaims": [ + { "file": "ROADMAP.md", "pattern": "^Current released version: \\*\\*([0-9]+\\.[0-9]+\\.[0-9]+)\\*\\*" } + ], + + "$versionClaimsComment": [ + "A version claim is a different shape from docClaims: docClaims compares a captured integer", + "against a manifest 'quantities' value derived from an areas/derived count, and needs 'equals'", + "to say which one. A version claim compares a captured x.y.z string against the newest dated", + "'## [x.y.z] - ' heading in CHANGELOG.md instead - there is no quantities entry for that,", + "so 'equals' does not apply here and is intentionally absent from these entries.", + "", + "v1 is an EXPLICIT LIST ONLY - unlike docClaims there is no undeclared-claim scan for version", + "strings. A semver-shaped phrase pattern would collide with legitimate non-claim version", + "strings already in the repo (keepachangelog.com/en/1.1.0/, semver.org/spec/v2.0.0.html,", + "illustrative versions in commands/release.md, docs/usage.md, template/fixture content), and", + "policing that needs a second exclusions/vocabulary surface disproportionate to the one real", + "offender this closes (SW-28). If a second tracked doc starts publishing a released-version", + "claim, add it here explicitly rather than building the scan; only build the scan if this list", + "grows enough that omissions become plausible." + ], + + "$contractLintComment": [ + "Contract contract for specwright's PRODUCT (commands/agents/skills), read by", + "scripts/contract-lint.ps1 and scripts/contract-lint.sh (Check 8: cross-file contract lint).", + "Where 'areas' above governs INVENTORY (how many files exist), this subtree governs the", + "RELATIONSHIPS between those files - which agent a command invokes, which skill an agent", + "loads, how many hard gates a workflow declares.", + "", + "THE COUNTS QUESTION. The manifest's charter at the top says it stores no counts, and", + "'gates..hard' is a literal number. That is deliberate, and the two are not in", + "conflict, because the charter governs inventory. The test is:", + "", + " Can a script count it from disk with no judgement calls?", + " YES -> it is inventory. It belongs in 'areas' and must be derived.", + " NO -> it is a declared design contract. It belongs here, written down.", + "", + "'How many command files exist' passes that test, so hardcoding it is pure liability.", + "'How many hard gates /sd:feature declares' does NOT: nothing on disk is a second source", + "for it. If it were derived, CL302 would compare disk against itself and pass vacuously", + "forever - exactly the rot Check 7's vacuous-claim rule exists to catch. The number's job", + "is to make deleting a '### Gate 4' heading a deliberate two-file edit that shows up in", + "review. Feeding these into Check 7 as quantities gives README <- manifest <- disk, hence", + "transitively README == disk, with no gate parser duplicated into validate.", + "", + "FOUR FALSE POSITIVES the gate parser handles WITHOUT an exclusion list. Do not re-add one:", + " 1. 'commands/status.md' - '## Gate activity' is a report section, not a gate. Rejected", + " because 'Gate' followed by a lowercase word is never a gate heading.", + " 2. 'commands/feature.md' - '**Face B - Gate Complexity (HARD)**' is bold text, not a", + " heading. Rejected because a gate must match '^#{2,3} '.", + " 3. 'commands/bug.md' - 'Gate 3a' is authored BEFORE 'Gate 3'. CL303 compares SETS, never", + " file order.", + " 4. The ~20 literal STOPs in Phase 0 bootstrap error paths. CL300 only looks inside a gate", + " block, which ends at the next heading of any level.", + "", + "SCAN SCOPE IS LOAD-BEARING. It is commands/ agents/ skills/ and nothing else on purpose.", + "CLAUDE.md and CONTRIBUTING.md use 'sd-test' as a sandbox path and docs/architecture.md", + "carries a 'name: sd-debugger' frontmatter example - adding docs/** here produces ~18 CL001", + "false positives on day one.", + "", + "SEVERITY LIVES HERE, NEVER IN A RULE. Both linters look severity up from rules[] below, so", + "a BLOCK/WARN divergence between the PowerShell and bash twins is structurally impossible.", + "A registry parity guard inside each linter asserts that the set of ids below equals the set", + "of rules it dispatches, and exits 2 if not - so adding a wave-2 rule cannot be half-done." + ], + + "contractLint": { + "scanScope": ["commands/*.md", "agents/*.md", "skills/*/SKILL.md"], + + "installNamespaceSegment": "sd", + + "rules": [ + { "id": "CL001", "severity": "BLOCK", "wave": 1, "summary": "sd- reference resolving to no agent and no skill" }, + { "id": "CL002", "severity": "BLOCK", "wave": 1, "summary": "skills: frontmatter entry with no matching SKILL.md" }, + { "id": "CL003", "severity": "BLOCK", "wave": 1, "summary": "unresolved sd- reference on a skill-decorated line" }, + { "id": "CL004", "severity": "WARN", "wave": 1, "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" }, + { "id": "CL005", "severity": "BLOCK", "wave": 1, "summary": "templates/ path that does not exist on disk" }, + { "id": "CL006", "severity": "BLOCK", "wave": 1, "summary": "/sd: reference with no matching command file" }, + { "id": "CL007", "severity": "WARN", "wave": 1, "summary": "agent mentioned by no command body" }, + { "id": "CL008", "severity": "BLOCK", "wave": 1, "summary": "numbered .specs artifact filename absent from specArtifacts" }, + + { "id": "CL100", "severity": "BLOCK", "wave": 2, "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" }, + { "id": "CL101", "severity": "WARN", "wave": 2, "summary": "agent declares a mode no command ever invokes" }, + { "id": "CL102", "severity": "BLOCK", "wave": 2, "summary": "invocation omits an input the declared mode marks required" }, + { "id": "CL103", "severity": "WARN", "wave": 2, "summary": "invocation passes an input token the declared mode declares nowhere" }, + { "id": "CL104", "severity": "BLOCK", "wave": 2, "summary": "two agent files share a frontmatter name:" }, + + { "id": "CL200", "severity": "BLOCK", "wave": 3, "summary": "agent with no write tool is instructed to write, append or create" }, + { "id": "CL201", "severity": "BLOCK", "wave": 3, "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" }, + { "id": "CL202", "severity": "WARN", "wave": 3, "summary": "mcp__* name absent from contractLint.knownMcpTools" }, + { "id": "CL203", "severity": "WARN", "wave": 3, "summary": "agent frontmatter declares a tool never mentioned in its own body" }, + + { "id": "CL300", "severity": "BLOCK", "wave": 1, "summary": "gate block containing no literal STOP" }, + { "id": "CL301", "severity": "BLOCK", "wave": 1, "summary": "gate block offering no option set" }, + { "id": "CL302", "severity": "BLOCK", "wave": 1, "summary": "hard gate count on disk disagrees with gates..hard" }, + { "id": "CL303", "severity": "WARN", "wave": 1, "summary": "hard gate numbering is not exactly 1..N without duplicates" }, + { "id": "CL304", "severity": "BLOCK", "wave": 1, "summary": "conditional gate on disk undeclared, or declared and absent" }, + { "id": "CL305", "severity": "BLOCK", "wave": 1, "summary": "HARD gate listing an override token as a selectable option" }, + { "id": "CL306", "severity": "BLOCK", "wave": 3, "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" }, + + { "id": "CL400", "severity": "BLOCK", "wave": 3, "summary": "hardcoded stack command token outside a <>, fenced example or suppression comment" }, + { "id": "CL401", "severity": "WARN", "wave": 3, "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" }, + { "id": "CL402", "severity": "BLOCK", "wave": 3, "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" }, + + { "id": "CL500", "severity": "WARN", "wave": 4, "summary": "file exceeds contractLint.budgets.Bytes (stays WARN permanently - a ratchet, not a build gate)" }, + + { "id": "CL900", "severity": "BLOCK", "wave": 1, "summary": "suppression comment carrying no usable reason" }, + { "id": "CL901", "severity": "BLOCK", "wave": 1, "summary": "suppression naming a rule id absent from this registry" }, + { "id": "CL902", "severity": "WARN", "wave": 1, "summary": "suppression that suppressed no finding" } + ], + + "gates": { + "commands/feature.md": { "quantity": "gatesFeature", "hard": 3, "conditional": ["Re-plan"] }, + "commands/bug.md": { "quantity": "gatesBug", "hard": 5, "conditional": ["3a"] }, + "commands/rca.md": { "quantity": "gatesRca", "hard": 3, "conditional": [] }, + "commands/refactor.md": { "quantity": "gatesRefactor", "hard": 6, "conditional": ["Re-plan"] }, + "commands/perf.md": { "quantity": "gatesPerf", "hard": 8, "conditional": [] }, + "commands/port.md": { "quantity": "gatesPort", "hard": 6, "conditional": [] }, + "commands/setup.md": { "quantity": "gatesSetup", "hard": 2, "conditional": [] }, + "commands/release.md": { "quantity": "gatesRelease", "hard": 1, "conditional": [] }, + "commands/adr.md": { "quantity": "gatesAdr", "hard": 1, "conditional": [] }, + + "skills/sd-replan-loop/SKILL.md": { "quantity": null, "hard": 0, "conditional": ["Re-plan"] } + }, + + "$gatesComment": [ + "quantity is the Check 7 name this gate count is published under. null means the gate", + "block is real but nothing in the docs cites a number for it - the Re-plan protocol is", + "defined once in the skill and reached from commands/feature.md and commands/refactor.md,", + "so its count belongs to those workflows, not to the skill. Check 7's seeding loop skips", + "null quantities; CL302 and CL304 do not care either way." + ], + + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md", + "03-decisions.md", + "05-retro.md", + "06-verify.md" + ], + + "skillConsumers": { + "sd-retro-lessons": "scripts/validate-lessons.{ps1,sh}, scripts/aggregate-lessons.{ps1,sh}" + }, + + "overrideOptionTokens": ["skip", "override", "proceed anyway", "bypass", "force", "ignore"], + + "gateProseEscapeTokens": ["insists on", "may override", "proceed anyway", "bypass this gate"], + + "$gateProseEscapeTokensComment": [ + "CL306's vocabulary, scoped to HARD gate blocks only. Deliberately narrow to control the", + "false-positive band this rule occupies (see docs/contract-lint.md) - it does not try to", + "catch every conceivable escape hatch, only the phrasings already proven to appear in", + "commands/bug.md and commands/release.md. A HARD gate whose prose matches one of these", + "phrases needs a '' comment, the same", + "suppression convention CL305 already uses on commands/perf.md - there is no separate", + "per-gate declared-exception surface, on purpose." + ], + + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + }, + + "$stackTokensComment": [ + "Hand-maintained, same category as overrideOptionTokens and knownMcpTools - nothing on", + "disk is a second source for 'which tokens count as a hardcoded stack assumption'.", + "commands.* is CL400's vocabulary (BLOCK, promoted from WARN 2026-07-31 after a clean", + "run across the engine tree under both implementations);", + "languages.* is CL401's (permanent WARN - a language name in prose is often legitimate,", + "e.g. an 'or the project's equivalent' example list). Both rules skip text inside", + "<<...>> placeholders, fenced code blocks, and lines covered by a", + "'' comment." + ], + + "readOnlyAgents": ["sd-code-explorer", "sd-reviewer", "sd-debugger"], + + "$readOnlyAgentsComment": [ + "A DECLARED promise, not a derived fact: these three agents are architecturally", + "committed to staying read-only, and CL201 is the only rule that reads this list -", + "it fires the moment one of them grows Write/Edit/MultiEdit in its own tools: line.", + "CL200 does NOT consult this list. CL200 asks 'does THIS agent's frontmatter carry a", + "write tool right now', straight off disk, for every agent - so a new read-only agent", + "is protected on day one even if nobody remembers to add it here. This list exists so", + "the THREE AGENTS ABOVE SPECIFICALLY can never quietly gain a write tool, the same", + "declared-vs-disk shape CL304 already uses for conditional gates." + ], + + "knownMcpTools": [ + "mcp__sequential-thinking__sequentialthinking", + "mcp__gitnexus__context", + "mcp__gitnexus__impact", + "mcp__gitnexus__query", + "mcp__gitnexus__list_repos", + "mcp__tavily__tavily_search", + "mcp__context7__query-docs", + "mcp__context7__resolve-library-id", + "mcp__atlassian__getJiraIssue", + "mcp__atlassian__searchJiraIssuesUsingJql", + "mcp__atlassian__getJiraIssueRemoteIssueLinks", + "mcp__atlassian__getConfluencePage" + ], + + "$knownMcpToolsComment": [ + "Hand-maintained on purpose, not derived: nothing on disk is a second source for", + "which mcp__* tool names are real, since that comes from runtime MCP server", + "configuration this repo cannot see. That fails the manifest's own derivability", + "test (see $contractLintComment above), so it belongs here, written down, same", + "category as overrideOptionTokens and skillConsumers.", + "", + "THE DELIBERATE FAILURE MODE: this list goes stale the moment a new MCP tool is", + "wired into an agent and this file is not updated in the same PR. CL202 stays WARN", + "forever (see contractLint.rules above) specifically because of that risk - a stale", + "allowlist must never be able to block CI. Treat a CL202 hit as 'update this list or", + "explain why not', never as 'suppress and move on'." + ], + + "budgets": { + "commandsBytes": 32677, + "agentsBytes": 15232, + "skillsBytes": 12412 + }, + + "$budgetsComment": [ + "CL500's vocabulary. A RATCHET, not a design target: each ceiling is set at today's", + "largest file in that area, so the repo is green by construction on day one and every", + "later WARN is real growth, never a paragraph someone happened to write. CL500 stays", + "WARN forever (see contractLint.rules above) - promoting it to BLOCK would turn a", + "judgement call into a build failure, and the judgement is the valuable part. Raising", + "a number here is a real decision that belongs in a PR description, never a reflex to", + "a red CI run.", + "", + "The byte count is NORMALIZED, never a raw disk read: the sum of each line's UTF-8", + "byte length plus one separator per line boundary, computed off the same per-line", + "cache every other rule already reads. A raw ReadAllBytes().Length / `wc -c` would", + "make this rule disagree with itself between platforms for byte-identical content -", + "contractLint.scanScope is 'text=auto' (see .gitattributes), so *.md checks out LF on", + "a Linux CI runner and CRLF on Windows. Confirmed on this repo: commands/spec.md is", + "25979 bytes as a git blob (LF) but 26521 bytes on a native Windows checkout - a 542", + "byte, ~2% difference for identical content. Line count was rejected for the same", + "reason CLAUDE.md rejects it elsewhere: it is not a measure both implementations can", + "agree on without arguing about line endings." + ] + } } diff --git a/templates/project-config.template.json b/templates/project-config.template.json index 945f2ea..6ad7b7e 100644 --- a/templates/project-config.template.json +++ b/templates/project-config.template.json @@ -1,5 +1,4 @@ { - "$schema": "https://raw.githubusercontent.com/Developzone/specwright/main/schema/project-config.schema.json", "version": "1.0.0", "project": { @@ -30,7 +29,8 @@ "bug": "BUG", "refactor": "REF", "perf": "PERF", - "rca": "RCA" + "rca": "RCA", + "port": "PORT" }, "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], "archiveAfterDays": 90 @@ -63,7 +63,8 @@ "feature": ["feature", "add", "implement", "new", "support"], "refactor": ["refactor", "restructure", "clean up", "extract", "rename"], "perf": ["perf", "performance", "slow", "optimize", "latency", "throughput"], - "rca": ["incident", "outage", "rca", "root cause", "post-mortem", "postmortem"] + "rca": ["incident", "outage", "rca", "root cause", "post-mortem", "postmortem"], + "port": ["backport", "port from", "port the", "donor repo", "mirror from", "replicate from"] }, "gates": { "_comment": "Descriptive only - no hook or command reads this block today; it documents each workflow's hard-gate sequence for humans/tooling that may consume it later.", @@ -71,7 +72,8 @@ "bug": ["symptom-captured", "reproduction-confirmed", "root-cause-confirmed", "failing-test-written", "regression-pass"], "rca": ["evidence-gathered", "hypotheses-enumerated", "root-cause-confirmed"], "refactor": ["spec-approved", "coverage-threshold-met", "post-test-added", "plan-approved", "batch-tests-green", "holistic-review-pass"], - "perf": ["target-defined", "baseline-measured", "hotspot-identified", "hypothesis-selected", "correctness-verified", "keep-or-revert-decided", "regression-pass", "final-review-pass"] + "perf": ["target-defined", "baseline-measured", "hotspot-identified", "hypothesis-selected", "correctness-verified", "keep-or-revert-decided", "regression-pass", "final-review-pass"], + "port": ["donor-set-frozen", "tables-complete", "behavior-pinned", "plan-approved", "batch-tests-green", "parity-clean"] } }, @@ -125,17 +127,19 @@ "_modes": "block = refuse Edit/Write on code without in-progress spec; warn = log to stderr only; off = skip", "_use": "Guard rail blocking code edits when no spec is in-progress", "verifyGate": true, - "_verifyGate_use": "Scoped to FEAT- (feature-spec) rows only - other types (bug, refactor, perf, rca) have no /sd:verify integration yet. true: a FEAT- row may transition to done only when //06-verify.md records 'result: pass' (written by /sd:verify); non-FEAT rows and any false setting keep index.md fully protected as before SW-6." + "_verifyGate_use": "Scoped to FEAT- (feature-spec) rows only - other types (bug, refactor, perf, rca, port) have no /sd:verify integration yet. true: a FEAT- row may transition to done only when //06-verify.md records 'result: pass' (written by /sd:verify); non-FEAT rows and any false setting keep index.md fully protected as before SW-6." }, "subagentRetro": { "enabled": true, "retroStaleMinutes": 30, "debounceMinutes": 10, "_use": "Reminder to update stale retros after subagent runs", + "_retroStaleMinutes_use": "Default 30 - a guess, not tuned against a measured corpus (see docs/adr/0004-threshold-calibration.md). Re-calibrate per CONTRIBUTING's re-calibration ritual once enough closed specs accumulate.", + "_debounceMinutes_use": "Default 10 - a guess, not tuned against a measured corpus (see docs/adr/0004-threshold-calibration.md). Re-calibrate per CONTRIBUTING's re-calibration ritual once enough closed specs accumulate.", "injectLessons": true, "maxLessons": 3, "_injectLessons_use": "Surface lessons from /_lessons/lessons.md when a subagent finishes work on an in-progress spec. Selection is by workflow type - a FEAT- spec pulls feature-scoped lessons - so there is no ranking. Emitted regardless of retro staleness and of debounceMinutes, which gate only the stale-retro reminder.", - "_maxLessons_use": "Cap on NEW lessons surfaced per subagent stop. Already-surfaced lessons are recorded per session, so a session converges to silence rather than repeating itself. Set to 0 to surface none without disabling the reminder." + "_maxLessons_use": "Cap on NEW lessons surfaced per subagent stop. Already-surfaced lessons are recorded per session, so a session converges to silence rather than repeating itself. Set to 0 to surface none without disabling the reminder. Default 3 - a guess, not tuned against a measured corpus (see docs/adr/0004-threshold-calibration.md)." }, "metrics": { "enabled": true, diff --git a/templates/specs/bug.template.md b/templates/specs/bug.template.md index c4a733f..05c0182 100644 --- a/templates/specs/bug.template.md +++ b/templates/specs/bug.template.md @@ -87,6 +87,21 @@ linked_specs: [] - [ ] Adjacent edge cases also covered if cheap - [ ] CI passes locally +## Spawned specs + + + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| + ## Constitution check - **Applicable sections**: <<§N.M ...>> diff --git a/templates/specs/feature.template.md b/templates/specs/feature.template.md index 6cc7fdc..bd3da07 100644 --- a/templates/specs/feature.template.md +++ b/templates/specs/feature.template.md @@ -79,6 +79,21 @@ linked_specs: [] - <> - <> +## Spawned specs + + + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| + ## Constitution check diff --git a/templates/specs/perf.template.md b/templates/specs/perf.template.md index 50f24f2..5ba92c0 100644 --- a/templates/specs/perf.template.md +++ b/templates/specs/perf.template.md @@ -80,6 +80,21 @@ linked_specs: [] - <> +## Spawned specs + + + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| + ## Constitution check - **§1 Layer rules**: <> diff --git a/templates/specs/port.template.md b/templates/specs/port.template.md new file mode 100644 index 0000000..63f9190 --- /dev/null +++ b/templates/specs/port.template.md @@ -0,0 +1,164 @@ +--- +id: <> +type: port +status: draft +jira: <> +created: <> +scope: <> +source_repo: <> +source_commit: <> +source_license: <> +snapshot: <> +linked_specs: [] +--- + + + + +# <> + +> **The donor is the specification. Every departure is a row in the deviation table, or it is a +> defect.** The three fidelity tables below (Path mapping, Member manifest, Deviation table) are +> defined and enforced by the **sd-port-fidelity** skill - read it before filling them. + +## Why + + + +<> + +## Donor provenance + +- **Donor**: <> @ <> +- **License**: <> - <> +- **Snapshot mode**: <> +- **Snapshot root**: `04-artifacts/source/` +- **Manifest**: `04-artifacts/source/MANIFEST.md` +- **Frozen**: <> - <> path(s) appended to `paths.protected` on <> + + + +## Behavioral contract + + + +| Facet | Donor behavior (verbatim) | +|---|---| +| Route / entry point | <<...>> | +| Input shape | <<...>> | +| Output shape | <<...>> | +| Status / result codes | <<...>> | +| Auth requirement | <<...>> | +| Side effects | <<...>> | +| Error paths | <<...>> | + +## Behavioral invariants (non-obvious) + + + +- INV-1: <> + +## Path mapping table + + + +| Donor path | Host path | Kind | Reason | +|---|---|---|---| +| <> | <> | <> | <<->> | + +## Member manifest + + + +| Donor path | Member | Ordinal | Host path | Status | Deviation ID | +|---|---|---|---|---|---| +| <> | <> | <<1>> | <> | <> | <<->> | + +## Deviation table + + + +| ID | Donor form | Host form | Group | Citation | +|---|---|---|---|---| +| <> | <> | <> | <<1-4>> | <> | + +## Spawned specs + + + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| +| <> | bug | <> | <<owner>> | + +## Success criteria + +<!-- AC-1 is fixed by sd-port-fidelity and is the anchor every fidelity finding cites - never + renumber it; renumbering breaks review anchors. Reword it only to track a change in + sd-port-fidelity's own vocabulary, never to soften it. AC-2+ are author-fill. --> + +- [ ] AC-1: Every host hunk in this port is either a structural mirror of its member-manifest row, + or is covered by a deviation-table row whose citation satisfies its group and whose `Host form` + accounts for the whole hunk. No `unjustified`, `missing`, `extra`, or `overreached` hunk remains + (see sd-port-fidelity). +- [ ] AC-2: <<criterion citing an INV-<n> or a license/attribution obligation>> + +## Out of scope + +<<thing 1>> + +## Open questions + +<<question 1>> + +## Constitution check + +<!-- Which constitution sections apply. Filled by sd-spec-architect. --> + +- **§1.1 Layer rules**: <<how this port respects layer boundaries>> +- **Risk of violation**: <<none | low | medium - explain>> + +<!-- Cross-references live in the `linked_specs` frontmatter field, not in a body section. They + are written by `/sd:spec link <ID-A> <relation> <ID-B>`. Do not hand-edit `linked_specs`. --> diff --git a/templates/specs/refactor.template.md b/templates/specs/refactor.template.md index 8dff0e9..0809391 100644 --- a/templates/specs/refactor.template.md +++ b/templates/specs/refactor.template.md @@ -82,6 +82,21 @@ linked_specs: [] - <<e.g. Updating consumer projects - they will continue to work via unchanged public API>> - <<e.g. Performance optimization - any perf gain is incidental, not a goal>> +## Spawned specs + +<!-- Follow-up work discovered while doing this spec. IDs are RESERVED, not created: this section + records the intent; the child spec is created later by running its own workflow. Same + four-column shape as rca.template.md, so there is one convention and not two. + Filled at close-out - i.e. after `approved` - so it deliberately carries NO `<<...>>` token: + an author-fill token here would still be unfilled at `approved` and fail /sd:spec validate + (SL010). Leave the table at header + separator when nothing was deferred. + Example row: | BUG-1310 | bug | Guard the empty-prefix match | alice | + A reserved ID is NOT a registry entry. Never add it to `.specs/index.md` until the real spec + directory exists - see /sd:spec, "Index <-> folder symmetry". --> + +| Reserved ID | Type | Title | Owner | +|---|---|---|---| + ## Constitution check - **§1.1 Layer rules**: <<does the refactor preserve / improve layer boundaries?>> diff --git a/tests/contract-lint/.gitattributes b/tests/contract-lint/.gitattributes new file mode 100644 index 0000000..ecf44ff --- /dev/null +++ b/tests/contract-lint/.gitattributes @@ -0,0 +1,9 @@ +# Fixtures are pinned to LF so a case's golden means the same thing on every +# checkout. Without this, Git's default core.autocrlf=true rewrites the .md +# fixtures to CRLF on Windows and a line-ending bug in one implementation would +# show up as a mysterious fixture failure instead of a parity failure. +# +# This does NOT reduce coverage of the CRLF path: the repo's own *.md files are +# deliberately left unpinned, so CI's windows-latest job runs both linters over +# a fully CRLF tree every time. +* text eol=lf diff --git a/tests/contract-lint/README.md b/tests/contract-lint/README.md new file mode 100644 index 0000000..e3956f3 --- /dev/null +++ b/tests/contract-lint/README.md @@ -0,0 +1,123 @@ +# contract-lint fixtures + +Fixture suite for `scripts/contract-lint.ps1` and `scripts/contract-lint.sh`. + +```powershell +.\tests\contract-lint\run-selftest.ps1 # the suite +.\tests\contract-lint\run-selftest.ps1 -SelfTest # plus: does the harness notice a dead linter? +.\tests\contract-lint\run-selftest.ps1 -Case cl30 # one family, for debugging +``` + +One pwsh runner drives **both** implementations in a single process, so parity is asserted rather +than inferred from two green runs in separate CI jobs. There is deliberately no `.sh` twin of the +runner: validate's Check 2 (`bash -n`) is depth-1 over `hooks/bash`, `install` and `scripts`, and +shipping a harness outside those directories would leave it unchecked. + +## How a case works + +`fixtures/_base/` is a complete, valid mini-engine with its own `specwright.manifest.json`. It is +the only tree that exists in full. Each case directory holds an `overlay/` copied over a fresh copy +of `_base`, plus an `expected.json`. That keeps every case to roughly one file instead of a near +duplicate tree that drifts out of sync with the others. + +`expected.json` pins an **anchor**, never a line number: + +```json +{ "rule": "CL300", "severity": "BLOCK", "file": "commands/alpha.md", + "anchor": "seed", "seed": "gate-without-stop" } +``` + +| Anchor | Means | +|---|---| +| `seed` | the finding must land within three lines after the `<!-- SEEDED: <name> - <why> -->` marker | +| `file` | the finding is a whole-file verdict, so its line must be 1 | + +A literal line number in a golden rots the moment a line above it shifts, and the case then passes +while checking nothing. The seed marker travels with the violation instead. A window rather than +"the next non-comment line" because the `CL9xx` cases report **on** a comment: the suppression is +the violation. + +The message text is never pinned, but it **is** compared between the two implementations, so +wording can improve in one commit while a divergence still fails. + +## Cases that must FIRE + +| Case | Rule | +|---|---| +| `cl001-unresolved-agent-reference` | CL001 | +| `cl002-skills-entry-without-skill-md` | CL002 | +| `cl003-unresolved-skill-reference` | CL003 | +| `cl004-skill-referenced-by-nobody` | CL004 | +| `cl005-missing-templates-path` | CL005 | +| `cl006-unknown-command-reference` | CL006 | +| `cl007-agent-invoked-by-no-command` | CL007 | +| `cl008-unknown-spec-artifact` | CL008 | +| `cl100-invocation-sets-undeclared-mode` | CL100 | +| `cl101-mode-invoked-by-nobody` | CL101 | +| `cl102-invocation-omits-required-input` | CL102 | +| `cl103-invocation-passes-undeclared-input` | CL103 | +| `cl104-duplicate-agent-name` | CL104 | +| `cl200-agent-instructed-to-write` | CL200 | +| `cl201-readonly-agent-declares-write-tool` | CL201 | +| `cl202-unknown-mcp-tool-name` | CL202 | +| `cl203-declared-tool-never-mentioned` | CL203 | +| `cl300-gate-without-stop` | CL300 | +| `cl301-gate-without-options` | CL301 | +| `cl302-gate-count-disagrees` | CL302 | +| `cl303-gate-numbering-gap` | CL303 | +| `cl304-conditional-gate-mismatch` | CL304, both directions | +| `cl305-hard-gate-offers-override` | CL305 | +| `cl306-hard-gate-prose-escape-unsuppressed` | CL306 | +| `cl400-hardcoded-npm-test` | CL400 | +| `cl401-hardcoded-typescript` | CL401 | +| `cl402-hardcoded-absolute-path` | CL402 | +| `cl500-file-over-budget` | CL500 | +| `cl900-suppression-without-reason` | CL900 | +| `cl901-suppression-unknown-rule` | CL901 | +| `cl902-suppression-suppresses-nothing` | CL902 | + +## Cases that must STAY SILENT + +These are not decoration. They are the only thing stopping a future tightening of `CL301` or +`CL305` from quietly breaking the real engine, where all four shapes occur. + +| Case | Shape it protects | Lives in the engine at | +|---|---|---| +| `clean` | the unmodified base tree | - | +| `fp-gate-activity-heading` | `## Gate activity` is a report section | `commands/status.md` | +| `fp-bold-pseudo-gate` | bold `Gate Complexity (HARD)` text is not a heading | `commands/feature.md` | +| `fp-substep-before-parent` | a conditional sub-gate authored before its parent | `commands/bug.md` | +| `fp-hard-gate-prose-escape` | a HARD gate whose PROSE mentions an override, annotated for both CL305 and CL306 | `commands/bug.md`, `commands/release.md` | +| `fp-negated-write-verb` | a negated ("Do not write") or third-person ("The caller will Create") use of a CL200 verb | `agents/code-explorer.md` | +| `fp-cl400-placeholder-and-fenced-example` | a stack command token inside a `<<placeholder>>` and inside a fenced example | `commands/perf.md`, `commands/verify.md` | +| `fp-cl401-placeholder-and-fenced-example` | a language name inside a `<<placeholder>>` and inside a fenced example | `commands/setup.md`, `agents/spec-architect.md` | +| `fp-cl402-slash-command-reference` | `/sd:<name>` references and `~/.claude/...` install-target paths, not filesystem paths | throughout `commands/`, `agents/` | +| `fp-cl500-file-at-budget-ceiling` | a file just under `contractLint.budgets.commandsBytes` must not trip CL500 | - | + +## The case that must still BITE + +| Case | Why it exists | +|---|---| +| `fp-phase0-stop` | The engine's Phase 0 bootstrap paths are full of literal `STOP`s. This case has them **and** a gate with none, and asserts `CL300` still fires. It guards the opposite direction from the silent cases: a widened `CL300` window would let a real gate pass because some unrelated line elsewhere said `STOP`. | + +## Adding a rule + +Adding one to `specwright.manifest.json` means adding all four of these, and skipping any one of +them fails this harness or CI: + +1. a `contractLint.rules` entry (the registry, which carries the severity); +2. a rule function in **both** `scripts/contract-lint.ps1` and `scripts/contract-lint.sh` -- each + linter's registry parity guard exits 2 if the dispatched set and the registry disagree; +3. a case here with an `expected.json` that names the rule, plus a row in the table above; +4. a row in `docs/contract-lint.md`. + +## House rules for fixture prose + +Fixture `.md` files are walked by validate's Check 7, which flags any line that looks like a +published inventory claim but has no `docClaims` entry behind it. Write fixture prose with none of +that vocabulary: no `N hard gates`, no `N commands`, no `N agents`, no `N skills`. Say what the +case does instead of counting anything. The fallback -- adding `tests/` to `historicalExclusions` -- +would silently drop this whole directory out of the scan, and is worth avoiding. + +`run-selftest.ps1` is scanned by Check 1 and must stay pure ASCII. The fixture `.md` files are not, +and deliberately carry the real non-ASCII gate marker so the marker-stripping path is exercised. diff --git a/tests/contract-lint/fixtures/_base/agents/keeper.md b/tests/contract-lint/fixtures/_base/agents/keeper.md new file mode 100644 index 0000000..3f9907d --- /dev/null +++ b/tests/contract-lint/fixtures/_base/agents/keeper.md @@ -0,0 +1,19 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. diff --git a/tests/contract-lint/fixtures/_base/commands/alpha.md b/tests/contract-lint/fixtures/_base/commands/alpha.md new file mode 100644 index 0000000..29328ef --- /dev/null +++ b/tests/contract-lint/fixtures/_base/commands/alpha.md @@ -0,0 +1,34 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) diff --git a/tests/contract-lint/fixtures/_base/commands/beta.md b/tests/contract-lint/fixtures/_base/commands/beta.md new file mode 100644 index 0000000..d2ea2a7 --- /dev/null +++ b/tests/contract-lint/fixtures/_base/commands/beta.md @@ -0,0 +1,14 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) diff --git a/tests/contract-lint/fixtures/_base/skills/sd-demo-rule/SKILL.md b/tests/contract-lint/fixtures/_base/skills/sd-demo-rule/SKILL.md new file mode 100644 index 0000000..f3a8e84 --- /dev/null +++ b/tests/contract-lint/fixtures/_base/skills/sd-demo-rule/SKILL.md @@ -0,0 +1,3 @@ +# sd-demo-rule + +Demo rule pack used by the contract-lint fixtures. Loaded by `sd-keeper`. diff --git a/tests/contract-lint/fixtures/_base/specwright.manifest.json b/tests/contract-lint/fixtures/_base/specwright.manifest.json new file mode 100644 index 0000000..16b8b07 --- /dev/null +++ b/tests/contract-lint/fixtures/_base/specwright.manifest.json @@ -0,0 +1,275 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": [ + "dotnet", + "npm", + "npx", + "yarn", + "pnpm", + "pytest", + "mvn", + "gradle", + "cargo", + "go test", + "rake", + "bundle exec", + "composer" + ], + "languages": [ + "C#", + ".NET", + "TypeScript", + "JavaScript", + "Python", + "Java", + "Go", + "Rust", + "Ruby", + "PHP", + "MSSQL", + "SQL Server", + "PostgreSQL", + "MySQL", + "MongoDB" + ] + }, + "readOnlyAgents": [], + "knownMcpTools": [] + } +} diff --git a/tests/contract-lint/fixtures/_base/templates/demo.template.md b/tests/contract-lint/fixtures/_base/templates/demo.template.md new file mode 100644 index 0000000..95b5f69 --- /dev/null +++ b/tests/contract-lint/fixtures/_base/templates/demo.template.md @@ -0,0 +1,3 @@ +# <<title>> + +Demo template used by the contract-lint fixtures. diff --git a/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/expected.json b/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/expected.json new file mode 100644 index 0000000..55765c7 --- /dev/null +++ b/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a sd- token that resolves to nothing, on a line that never says \"skill\"", + "findings": [ + { + "rule": "CL001", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unresolved-agent" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/overlay/commands/alpha.md new file mode 100644 index 0000000..b7a6969 --- /dev/null +++ b/tests/contract-lint/fixtures/cl001-unresolved-agent-reference/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unresolved-agent - the handoff below names an agent that is not on disk --> +Then hand off to `sd-nobody` for the follow-up. diff --git a/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/expected.json b/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/expected.json new file mode 100644 index 0000000..826c371 --- /dev/null +++ b/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a skills: frontmatter entry with no folder behind it", + "findings": [ + { + "rule": "CL002", + "severity": "BLOCK", + "file": "agents/keeper.md", + "anchor": "seed", + "seed": "missing-skill-md" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/overlay/agents/keeper.md new file mode 100644 index 0000000..f0fd4c5 --- /dev/null +++ b/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/overlay/agents/keeper.md @@ -0,0 +1,21 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +<!-- SEEDED: missing-skill-md - the second entry below names a folder that is not on disk --> +skills: + - sd-demo-rule + - sd-absent-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. diff --git a/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/expected.json b/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/expected.json new file mode 100644 index 0000000..08d64bc --- /dev/null +++ b/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/expected.json @@ -0,0 +1,12 @@ +{ + "note": "the same unresolved shape as CL001, but decorated as a skill reference", + "findings": [ + { + "rule": "CL003", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unresolved-skill" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/overlay/commands/alpha.md new file mode 100644 index 0000000..749014d --- /dev/null +++ b/tests/contract-lint/fixtures/cl003-unresolved-skill-reference/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unresolved-skill - the line says "skill", so this is CL003 and not CL001 --> +Apply the **sd-ghost-rule** skill before drafting. diff --git a/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/expected.json b/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/expected.json new file mode 100644 index 0000000..984c5e6 --- /dev/null +++ b/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/expected.json @@ -0,0 +1,11 @@ +{ + "note": "an orphan skill; reported at line 1 of its own SKILL.md, not at a seed", + "findings": [ + { + "rule": "CL004", + "severity": "WARN", + "file": "skills/sd-orphan-rule/SKILL.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/overlay/skills/sd-orphan-rule/SKILL.md b/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/overlay/skills/sd-orphan-rule/SKILL.md new file mode 100644 index 0000000..da0f351 --- /dev/null +++ b/tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/overlay/skills/sd-orphan-rule/SKILL.md @@ -0,0 +1,4 @@ +<!-- SEEDED: orphan-skill - nothing in scan scope ever loads this rule pack --> +# sd-orphan-rule + +A rule pack nobody loads. diff --git a/tests/contract-lint/fixtures/cl005-missing-templates-path/expected.json b/tests/contract-lint/fixtures/cl005-missing-templates-path/expected.json new file mode 100644 index 0000000..866d197 --- /dev/null +++ b/tests/contract-lint/fixtures/cl005-missing-templates-path/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a templates/ path that does not resolve once the install namespace is folded away", + "findings": [ + { + "rule": "CL005", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "missing-template" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl005-missing-templates-path/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl005-missing-templates-path/overlay/commands/alpha.md new file mode 100644 index 0000000..9775050 --- /dev/null +++ b/tests/contract-lint/fixtures/cl005-missing-templates-path/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: missing-template - the template read below is not on disk --> +Also read `templates/sd/absent.template.md` before writing. diff --git a/tests/contract-lint/fixtures/cl006-unknown-command-reference/expected.json b/tests/contract-lint/fixtures/cl006-unknown-command-reference/expected.json new file mode 100644 index 0000000..8ee2758 --- /dev/null +++ b/tests/contract-lint/fixtures/cl006-unknown-command-reference/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a /sd: reference with no command file behind it", + "findings": [ + { + "rule": "CL006", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unknown-command" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl006-unknown-command-reference/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl006-unknown-command-reference/overlay/commands/alpha.md new file mode 100644 index 0000000..073809b --- /dev/null +++ b/tests/contract-lint/fixtures/cl006-unknown-command-reference/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unknown-command - the command routed to below does not exist in this tree --> +When the draft is rejected, route the user to `/sd:gamma`. diff --git a/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/expected.json b/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/expected.json new file mode 100644 index 0000000..d217a48 --- /dev/null +++ b/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/expected.json @@ -0,0 +1,11 @@ +{ + "note": "an agent nothing invokes; reported at line 1 of its own file", + "findings": [ + { + "rule": "CL007", + "severity": "WARN", + "file": "agents/hermit.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/overlay/agents/hermit.md b/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/overlay/agents/hermit.md new file mode 100644 index 0000000..bc4c6fc --- /dev/null +++ b/tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/overlay/agents/hermit.md @@ -0,0 +1,12 @@ +<!-- SEEDED: lonely-agent - no command body ever calls the agent declared below --> +--- +name: sd-hermit +color: green +description: Demo agent no command ever invokes. +model: haiku +tools: Read +skills: + - sd-demo-rule +--- + +You are never called. You would `Read` `templates/sd/demo.template.md` if anyone did. diff --git a/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/expected.json b/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/expected.json new file mode 100644 index 0000000..2299634 --- /dev/null +++ b/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a numbered spec-artifact filename absent from specArtifacts", + "findings": [ + { + "rule": "CL008", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unknown-artifact" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/overlay/commands/alpha.md new file mode 100644 index 0000000..03ea711 --- /dev/null +++ b/tests/contract-lint/fixtures/cl008-unknown-spec-artifact/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unknown-artifact - the roll-up file appended below is not a declared artifact --> +Finally append the roll-up to `09-summary.md`. diff --git a/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/expected.json b/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/expected.json new file mode 100644 index 0000000..9ad4890 --- /dev/null +++ b/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/expected.json @@ -0,0 +1,6 @@ +{ + "note": "sd-keeper's only declared mode is `TASK = draft`; TASK = archive matches nothing", + "findings": [ + { "rule": "CL100", "severity": "BLOCK", "file": "commands/alpha.md", "anchor": "seed", "seed": "undeclared-mode" } + ] +} diff --git a/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/overlay/commands/alpha.md new file mode 100644 index 0000000..1388f86 --- /dev/null +++ b/tests/contract-lint/fixtures/cl100-invocation-sets-undeclared-mode/overlay/commands/alpha.md @@ -0,0 +1,39 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +## Phase 3 - Archive + +<!-- SEEDED: undeclared-mode - sd-keeper's only real mode is `TASK = draft` --> +Invoke `sd-keeper` with `TASK = archive`. diff --git a/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/expected.json b/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/expected.json new file mode 100644 index 0000000..116254e --- /dev/null +++ b/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/expected.json @@ -0,0 +1,6 @@ +{ + "note": "sd-keeper declares TASK = archive but no command in the fixture tree ever sets it", + "findings": [ + { "rule": "CL101", "severity": "WARN", "file": "agents/keeper.md", "anchor": "seed", "seed": "unused-mode" } + ] +} diff --git a/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/overlay/agents/keeper.md new file mode 100644 index 0000000..1862216 --- /dev/null +++ b/tests/contract-lint/fixtures/cl101-mode-invoked-by-nobody/overlay/agents/keeper.md @@ -0,0 +1,27 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +<!-- SEEDED: unused-mode - no command in this fixture tree ever sets TASK = archive --> +## `TASK = archive` + +Inputs (required): none +Inputs (optional): none + +Archive the current draft. Nothing in this fixture tree invokes this mode. diff --git a/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/expected.json b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/expected.json new file mode 100644 index 0000000..677cbbb --- /dev/null +++ b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/expected.json @@ -0,0 +1,6 @@ +{ + "note": "TASK = archive marks REASON required; the invocation never passes it", + "findings": [ + { "rule": "CL102", "severity": "BLOCK", "file": "commands/alpha.md", "anchor": "seed", "seed": "missing-required" } + ] +} diff --git a/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/agents/keeper.md new file mode 100644 index 0000000..5ece9cf --- /dev/null +++ b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/agents/keeper.md @@ -0,0 +1,26 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +## `TASK = archive` + +Inputs (required): REASON +Inputs (optional): none + +Archive the current draft. `REASON` explains why the draft is being archived. diff --git a/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/commands/alpha.md new file mode 100644 index 0000000..6f13242 --- /dev/null +++ b/tests/contract-lint/fixtures/cl102-invocation-omits-required-input/overlay/commands/alpha.md @@ -0,0 +1,39 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +## Phase 3 - Archive + +<!-- SEEDED: missing-required - TASK = archive marks REASON as required and it is never passed --> +Invoke `sd-keeper` with `TASK = archive`. diff --git a/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/expected.json b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/expected.json new file mode 100644 index 0000000..51cbc2e --- /dev/null +++ b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/expected.json @@ -0,0 +1,6 @@ +{ + "note": "TASK = archive declares no REASON input at all, required or optional", + "findings": [ + { "rule": "CL103", "severity": "WARN", "file": "commands/alpha.md", "anchor": "seed", "seed": "undeclared-token" } + ] +} diff --git a/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/agents/keeper.md new file mode 100644 index 0000000..2969419 --- /dev/null +++ b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/agents/keeper.md @@ -0,0 +1,26 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +## `TASK = archive` + +Inputs (required): none +Inputs (optional): none + +Archive the current draft. Takes no inputs beyond the mode selector itself. diff --git a/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/commands/alpha.md new file mode 100644 index 0000000..31237e5 --- /dev/null +++ b/tests/contract-lint/fixtures/cl103-invocation-passes-undeclared-input/overlay/commands/alpha.md @@ -0,0 +1,39 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +## Phase 3 - Archive + +<!-- SEEDED: undeclared-token - TASK = archive declares no REASON input, required or optional --> +Invoke `sd-keeper` with `TASK = archive`, `REASON = superseded`. diff --git a/tests/contract-lint/fixtures/cl104-duplicate-agent-name/expected.json b/tests/contract-lint/fixtures/cl104-duplicate-agent-name/expected.json new file mode 100644 index 0000000..9810f60 --- /dev/null +++ b/tests/contract-lint/fixtures/cl104-duplicate-agent-name/expected.json @@ -0,0 +1,6 @@ +{ + "note": "agents/keeper.md and agents/twin.md both declare `name: sd-keeper`; keeper.md sorts first and stays canonical", + "findings": [ + { "rule": "CL104", "severity": "BLOCK", "file": "agents/twin.md", "anchor": "seed", "seed": "reused-name" } + ] +} diff --git a/tests/contract-lint/fixtures/cl104-duplicate-agent-name/overlay/agents/twin.md b/tests/contract-lint/fixtures/cl104-duplicate-agent-name/overlay/agents/twin.md new file mode 100644 index 0000000..c09621b --- /dev/null +++ b/tests/contract-lint/fixtures/cl104-duplicate-agent-name/overlay/agents/twin.md @@ -0,0 +1,10 @@ +--- +<!-- SEEDED: reused-name - this agent reuses sd-keeper's name from _base/agents/keeper.md --> +name: sd-keeper +color: gray +description: Demo agent that collides with keeper's frontmatter name. +model: haiku +tools: Read +--- + +You are a duplicate. This file exists only to trip CL104. diff --git a/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/expected.json b/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/expected.json new file mode 100644 index 0000000..0f35462 --- /dev/null +++ b/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/expected.json @@ -0,0 +1,6 @@ +{ + "note": "sd-keeper has no write tool; the seeded line still instructs it to Append output", + "findings": [ + { "rule": "CL200", "severity": "BLOCK", "file": "agents/keeper.md", "anchor": "seed", "seed": "no-write-tool-instructed" } + ] +} diff --git a/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/overlay/agents/keeper.md new file mode 100644 index 0000000..6cb9deb --- /dev/null +++ b/tests/contract-lint/fixtures/cl200-agent-instructed-to-write/overlay/agents/keeper.md @@ -0,0 +1,22 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +<!-- SEEDED: no-write-tool-instructed - sd-keeper carries no Write/Edit/MultiEdit tool but the line below still tells it to save output directly --> +Append the finished draft directly to `00-spec.md` before returning control. diff --git a/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/expected.json b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/expected.json new file mode 100644 index 0000000..4e185b7 --- /dev/null +++ b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/expected.json @@ -0,0 +1,6 @@ +{ + "note": "the manifest declares sd-keeper read-only; its own tools: line adds Edit anyway", + "findings": [ + { "rule": "CL201", "severity": "BLOCK", "file": "agents/keeper.md", "anchor": "seed", "seed": "readonly-agent-gained-write-tool" } + ] +} diff --git a/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/agents/keeper.md new file mode 100644 index 0000000..0654781 --- /dev/null +++ b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/agents/keeper.md @@ -0,0 +1,21 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +<!-- SEEDED: readonly-agent-gained-write-tool - sd-keeper is declared read-only below but this line adds Edit --> +tools: Read, Grep, Edit +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. +`Edit` appears only to exercise this fixture and is never actually invoked. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. diff --git a/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/specwright.manifest.json new file mode 100644 index 0000000..572b6c1 --- /dev/null +++ b/tests/contract-lint/fixtures/cl201-readonly-agent-declares-write-tool/overlay/specwright.manifest.json @@ -0,0 +1,245 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + }, + "readOnlyAgents": ["sd-keeper"], + "knownMcpTools": [] + } +} diff --git a/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/expected.json b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/expected.json new file mode 100644 index 0000000..64d819e --- /dev/null +++ b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/expected.json @@ -0,0 +1,6 @@ +{ + "note": "mcp__demo__known-tool is declared in knownMcpTools and stays silent; mcp__demo__unknown-tool is not, and fires", + "findings": [ + { "rule": "CL202", "severity": "WARN", "file": "agents/keeper.md", "anchor": "seed", "seed": "unknown-mcp-tool" } + ] +} diff --git a/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/agents/keeper.md new file mode 100644 index 0000000..9793253 --- /dev/null +++ b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/agents/keeper.md @@ -0,0 +1,24 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +Consult `mcp__demo__known-tool` for prior art. + +<!-- SEEDED: unknown-mcp-tool - the next line names an mcp tool this manifest does not recognize --> +Do not consult `mcp__demo__unknown-tool`; this manifest does not recognize it. diff --git a/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/specwright.manifest.json new file mode 100644 index 0000000..a367383 --- /dev/null +++ b/tests/contract-lint/fixtures/cl202-unknown-mcp-tool-name/overlay/specwright.manifest.json @@ -0,0 +1,245 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + }, + "readOnlyAgents": [], + "knownMcpTools": ["mcp__demo__known-tool"] + } +} diff --git a/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/expected.json b/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/expected.json new file mode 100644 index 0000000..b79b567 --- /dev/null +++ b/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/expected.json @@ -0,0 +1,6 @@ +{ + "note": "sd-keeper declares Bash in tools: but its body never mentions it", + "findings": [ + { "rule": "CL203", "severity": "WARN", "file": "agents/keeper.md", "anchor": "seed", "seed": "unused-tool-declared" } + ] +} diff --git a/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/overlay/agents/keeper.md b/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/overlay/agents/keeper.md new file mode 100644 index 0000000..efb2a7b --- /dev/null +++ b/tests/contract-lint/fixtures/cl203-declared-tool-never-mentioned/overlay/agents/keeper.md @@ -0,0 +1,20 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +<!-- SEEDED: unused-tool-declared - sd-keeper's body below never says Bash anywhere --> +tools: Read, Grep, Bash +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. diff --git a/tests/contract-lint/fixtures/cl300-gate-without-stop/expected.json b/tests/contract-lint/fixtures/cl300-gate-without-stop/expected.json new file mode 100644 index 0000000..8189141 --- /dev/null +++ b/tests/contract-lint/fixtures/cl300-gate-without-stop/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a gate whose block contains no literal STOP; the Phase 0 STOPs must not rescue it", + "findings": [ + { + "rule": "CL300", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "gate-without-stop" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/commands/alpha.md new file mode 100644 index 0000000..5e9cef7 --- /dev/null +++ b/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/commands/alpha.md @@ -0,0 +1,41 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: gate-without-stop - this gate block never halts --> +### ⛔ Gate 3 - Publish approved + +Ask the user: + +> Publish it? (yes / revise / abort) diff --git a/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/specwright.manifest.json new file mode 100644 index 0000000..4f246d9 --- /dev/null +++ b/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/specwright.manifest.json @@ -0,0 +1,243 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 3, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + } + } +} diff --git a/tests/contract-lint/fixtures/cl301-gate-without-options/expected.json b/tests/contract-lint/fixtures/cl301-gate-without-options/expected.json new file mode 100644 index 0000000..848555c --- /dev/null +++ b/tests/contract-lint/fixtures/cl301-gate-without-options/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a gate that halts but offers the user nothing to choose between", + "findings": [ + { + "rule": "CL301", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "gate-without-options" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/commands/alpha.md new file mode 100644 index 0000000..d461b0b --- /dev/null +++ b/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/commands/alpha.md @@ -0,0 +1,39 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: gate-without-options - no parenthetical and fewer than two top-level bullets --> +### ⛔ Gate 3 - Publish approved + +STOP and wait for the user to say so. diff --git a/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/specwright.manifest.json new file mode 100644 index 0000000..4f246d9 --- /dev/null +++ b/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/specwright.manifest.json @@ -0,0 +1,243 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 3, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + } + } +} diff --git a/tests/contract-lint/fixtures/cl302-gate-count-disagrees/expected.json b/tests/contract-lint/fixtures/cl302-gate-count-disagrees/expected.json new file mode 100644 index 0000000..70617c8 --- /dev/null +++ b/tests/contract-lint/fixtures/cl302-gate-count-disagrees/expected.json @@ -0,0 +1,11 @@ +{ + "note": "a gate added to disk without the deliberate second edit to the manifest", + "findings": [ + { + "rule": "CL302", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl302-gate-count-disagrees/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl302-gate-count-disagrees/overlay/commands/alpha.md new file mode 100644 index 0000000..3c3d41b --- /dev/null +++ b/tests/contract-lint/fixtures/cl302-gate-count-disagrees/overlay/commands/alpha.md @@ -0,0 +1,41 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: extra-gate - a third gate on disk while the manifest still declares two --> +### ⛔ Gate 3 - Publish approved + +STOP. Ask: + +> Publish it? (yes / revise / abort) diff --git a/tests/contract-lint/fixtures/cl303-gate-numbering-gap/expected.json b/tests/contract-lint/fixtures/cl303-gate-numbering-gap/expected.json new file mode 100644 index 0000000..2a0dc74 --- /dev/null +++ b/tests/contract-lint/fixtures/cl303-gate-numbering-gap/expected.json @@ -0,0 +1,11 @@ +{ + "note": "labels {1,4} for a count of two; the count still matches, only the numbering rotted", + "findings": [ + { + "rule": "CL303", + "severity": "WARN", + "file": "commands/alpha.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl303-gate-numbering-gap/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl303-gate-numbering-gap/overlay/commands/alpha.md new file mode 100644 index 0000000..8389680 --- /dev/null +++ b/tests/contract-lint/fixtures/cl303-gate-numbering-gap/overlay/commands/alpha.md @@ -0,0 +1,34 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 4 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) diff --git a/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/expected.json b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/expected.json new file mode 100644 index 0000000..16cc829 --- /dev/null +++ b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/expected.json @@ -0,0 +1,18 @@ +{ + "note": "both directions at once: 2a undeclared on disk, 2b declared and absent - the anti-rot half", + "findings": [ + { + "rule": "CL304", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "undeclared-conditional" + }, + { + "rule": "CL304", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/commands/alpha.md new file mode 100644 index 0000000..4566f7d --- /dev/null +++ b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/commands/alpha.md @@ -0,0 +1,41 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: undeclared-conditional - Gate 2a is on disk but the manifest declares 2b --> +### ⛔ Gate 2a - Retry the draft + +STOP. Ask: + +> Retry? (yes / abort) diff --git a/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/specwright.manifest.json new file mode 100644 index 0000000..4b34a0d --- /dev/null +++ b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/specwright.manifest.json @@ -0,0 +1,245 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [ + "2b" + ] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + } + } +} diff --git a/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/expected.json b/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/expected.json new file mode 100644 index 0000000..dd8d0f5 --- /dev/null +++ b/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/expected.json @@ -0,0 +1,12 @@ +{ + "note": "an override offered as a LISTED CHOICE at a HARD gate, which is what CL305 scopes to", + "findings": [ + { + "rule": "CL305", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "override-option" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/overlay/commands/alpha.md new file mode 100644 index 0000000..9438163 --- /dev/null +++ b/tests/contract-lint/fixtures/cl305-hard-gate-offers-override/overlay/commands/alpha.md @@ -0,0 +1,35 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +<!-- SEEDED: override-option - skip is a selectable option at a HARD gate --> +> Close it now? (yes / skip / abort) diff --git a/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/expected.json b/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/expected.json new file mode 100644 index 0000000..1497759 --- /dev/null +++ b/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a HARD gate's prose describes an escape hatch with no contract-lint: allow CL306 comment nearby", + "findings": [ + { + "rule": "CL306", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unsuppressed-escape-hatch" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/overlay/commands/alpha.md new file mode 100644 index 0000000..f5a2c81 --- /dev/null +++ b/tests/contract-lint/fixtures/cl306-hard-gate-prose-escape-unsuppressed/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unsuppressed-escape-hatch - prose describes an override with no contract-lint: allow CL306 comment --> +If the user insists on closing anyway, proceed without further confirmation. diff --git a/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/expected.json b/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/expected.json new file mode 100644 index 0000000..8179e20 --- /dev/null +++ b/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a hardcoded stack command token outside any <<placeholder>> or fenced example", + "findings": [ + { + "rule": "CL400", + "severity": "BLOCK", + "file": "commands/beta.md", + "anchor": "seed", + "seed": "stack-command-hardcoded" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/overlay/commands/beta.md b/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/overlay/commands/beta.md new file mode 100644 index 0000000..c54117f --- /dev/null +++ b/tests/contract-lint/fixtures/cl400-hardcoded-npm-test/overlay/commands/beta.md @@ -0,0 +1,17 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +<!-- SEEDED: stack-command-hardcoded - a stack command token outside any placeholder or fenced example --> +Run `npm test` before merging any change. diff --git a/tests/contract-lint/fixtures/cl401-hardcoded-typescript/expected.json b/tests/contract-lint/fixtures/cl401-hardcoded-typescript/expected.json new file mode 100644 index 0000000..320362e --- /dev/null +++ b/tests/contract-lint/fixtures/cl401-hardcoded-typescript/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a hardcoded language name outside any <<placeholder>> or fenced example", + "findings": [ + { + "rule": "CL401", + "severity": "WARN", + "file": "commands/beta.md", + "anchor": "seed", + "seed": "typescript-hardcoded" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl401-hardcoded-typescript/overlay/commands/beta.md b/tests/contract-lint/fixtures/cl401-hardcoded-typescript/overlay/commands/beta.md new file mode 100644 index 0000000..03d1e5b --- /dev/null +++ b/tests/contract-lint/fixtures/cl401-hardcoded-typescript/overlay/commands/beta.md @@ -0,0 +1,17 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +<!-- SEEDED: typescript-hardcoded - a language name outside any placeholder or fenced example --> +This project is written in TypeScript, so a type error always blocks the build. diff --git a/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/expected.json b/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/expected.json new file mode 100644 index 0000000..a20f4ea --- /dev/null +++ b/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a hardcoded absolute filesystem path in scan scope", + "findings": [ + { + "rule": "CL402", + "severity": "BLOCK", + "file": "commands/beta.md", + "anchor": "seed", + "seed": "hardcoded-abspath" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/overlay/commands/beta.md b/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/overlay/commands/beta.md new file mode 100644 index 0000000..b54f842 --- /dev/null +++ b/tests/contract-lint/fixtures/cl402-hardcoded-absolute-path/overlay/commands/beta.md @@ -0,0 +1,17 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +<!-- SEEDED: hardcoded-abspath - an absolute filesystem path in scan scope --> +See `/home/dev/project/config.json` for the reference layout. diff --git a/tests/contract-lint/fixtures/cl500-file-over-budget/expected.json b/tests/contract-lint/fixtures/cl500-file-over-budget/expected.json new file mode 100644 index 0000000..36bd719 --- /dev/null +++ b/tests/contract-lint/fixtures/cl500-file-over-budget/expected.json @@ -0,0 +1,11 @@ +{ + "note": "a file whose normalized byte count exceeds contractLint.budgets.commandsBytes", + "findings": [ + { + "rule": "CL500", + "severity": "WARN", + "file": "commands/gamma.md", + "anchor": "file" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/commands/gamma.md b/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/commands/gamma.md new file mode 100644 index 0000000..1dbeefd --- /dev/null +++ b/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/commands/gamma.md @@ -0,0 +1,23 @@ +--- +description: Filler command used only to exercise the CL500 byte-budget rule. +argument-hint: <slug> +--- + +# Gamma filler command + +This file exists purely so its own byte count clears a small fixture-local +budget. It carries no gate headings, no references to other prompt files, +and no stack-specific vocabulary, so the byte-budget check is the only rule +it is meant to trip. + +Everything past this point is harmless padding prose whose sole purpose is +to push the total size comfortably past the configured ceiling, so the +fixture behaves identically on every checkout and both linter +implementations without anyone needing to hand-count bytes to keep it +passing. + +The budget for this fixture case is set above the base tree's own +`commands/alpha.md` and `commands/beta.md`, so only this file is meant to +trip CL500 here - the other two stay comfortably under the ceiling, and +this extra paragraph exists only to keep this file clearly the largest of +the three by a wide, legible margin. diff --git a/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/specwright.manifest.json new file mode 100644 index 0000000..6a05781 --- /dev/null +++ b/tests/contract-lint/fixtures/cl500-file-over-budget/overlay/specwright.manifest.json @@ -0,0 +1,278 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": [ + "dotnet", + "npm", + "npx", + "yarn", + "pnpm", + "pytest", + "mvn", + "gradle", + "cargo", + "go test", + "rake", + "bundle exec", + "composer" + ], + "languages": [ + "C#", + ".NET", + "TypeScript", + "JavaScript", + "Python", + "Java", + "Go", + "Rust", + "Ruby", + "PHP", + "MSSQL", + "SQL Server", + "PostgreSQL", + "MySQL", + "MongoDB" + ] + }, + "readOnlyAgents": [], + "knownMcpTools": [], + "budgets": { + "commandsBytes": 750 + } + } +} diff --git a/tests/contract-lint/fixtures/cl900-suppression-without-reason/expected.json b/tests/contract-lint/fixtures/cl900-suppression-without-reason/expected.json new file mode 100644 index 0000000..7408a78 --- /dev/null +++ b/tests/contract-lint/fixtures/cl900-suppression-without-reason/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a suppression with no reason worth reading; the CL001 it covers is correctly silenced", + "findings": [ + { + "rule": "CL900", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "reasonless-suppression" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl900-suppression-without-reason/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl900-suppression-without-reason/overlay/commands/alpha.md new file mode 100644 index 0000000..8d5312f --- /dev/null +++ b/tests/contract-lint/fixtures/cl900-suppression-without-reason/overlay/commands/alpha.md @@ -0,0 +1,38 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: reasonless-suppression - the reason is under ten non-separator characters --> +<!-- contract-lint: allow CL001 - hmm --> +Hand off to `sd-nobody` now. diff --git a/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/expected.json b/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/expected.json new file mode 100644 index 0000000..b735581 --- /dev/null +++ b/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/expected.json @@ -0,0 +1,12 @@ +{ + "note": "a suppression naming a rule id the registry has never heard of; exempt from CL902", + "findings": [ + { + "rule": "CL901", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "unknown-rule-suppression" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/overlay/commands/alpha.md new file mode 100644 index 0000000..d1bc177 --- /dev/null +++ b/tests/contract-lint/fixtures/cl901-suppression-unknown-rule/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: unknown-rule-suppression - CL404 is not in the registry --> +<!-- contract-lint: allow CL404 - guards a rule that does not exist --> diff --git a/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/expected.json b/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/expected.json new file mode 100644 index 0000000..c7d8257 --- /dev/null +++ b/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/expected.json @@ -0,0 +1,12 @@ +{ + "note": "the anti-rot rule: a suppression that outlived the finding it was written for", + "findings": [ + { + "rule": "CL902", + "severity": "WARN", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "vacuous-suppression" + } + ] +} diff --git a/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/overlay/commands/alpha.md b/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/overlay/commands/alpha.md new file mode 100644 index 0000000..952d767 --- /dev/null +++ b/tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/overlay/commands/alpha.md @@ -0,0 +1,37 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: vacuous-suppression - well-formed, correctly spelled, and covering no finding --> +<!-- contract-lint: allow CL001 - kept from an era when this line had a bad reference --> diff --git a/tests/contract-lint/fixtures/clean/expected.json b/tests/contract-lint/fixtures/clean/expected.json new file mode 100644 index 0000000..fe31429 --- /dev/null +++ b/tests/contract-lint/fixtures/clean/expected.json @@ -0,0 +1,4 @@ +{ + "note": "the unmodified base tree must produce no findings at all", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-bold-pseudo-gate/expected.json b/tests/contract-lint/fixtures/fp-bold-pseudo-gate/expected.json new file mode 100644 index 0000000..7a087b5 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-bold-pseudo-gate/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: a gate must match a heading, and bold text never does", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-bold-pseudo-gate/overlay/commands/alpha.md b/tests/contract-lint/fixtures/fp-bold-pseudo-gate/overlay/commands/alpha.md new file mode 100644 index 0000000..e5935d1 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-bold-pseudo-gate/overlay/commands/alpha.md @@ -0,0 +1,36 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +**Face B - Gate Complexity (HARD)** is bold text inside a phase, not a heading. It has no STOP and offers nothing. diff --git a/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/expected.json b/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/expected.json new file mode 100644 index 0000000..6ae16bb --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: a stack command token inside a <<placeholder>> and inside a fenced code block, never in bare prose", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/overlay/commands/beta.md b/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/overlay/commands/beta.md new file mode 100644 index 0000000..96af8db --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl400-placeholder-and-fenced-example/overlay/commands/beta.md @@ -0,0 +1,20 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +Run the project's test command: `<<e.g. npm test>>`. + +```text +npm test +``` diff --git a/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/expected.json b/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/expected.json new file mode 100644 index 0000000..1bcddad --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: a language name inside a <<placeholder>> and inside a fenced code block, never in bare prose", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/overlay/commands/beta.md b/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/overlay/commands/beta.md new file mode 100644 index 0000000..8c11648 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl401-placeholder-and-fenced-example/overlay/commands/beta.md @@ -0,0 +1,20 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +Detected language: `<<e.g. Python>>`. + +```text +Written in TypeScript. +``` diff --git a/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/expected.json b/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/expected.json new file mode 100644 index 0000000..15ded45 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: /sd:<name> slash-command references and ~/.claude/... install-target paths are not hardcoded absolute filesystem paths", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/overlay/commands/beta.md b/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/overlay/commands/beta.md new file mode 100644 index 0000000..6d8d587 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl402-slash-command-reference/overlay/commands/beta.md @@ -0,0 +1,17 @@ +--- +description: Second demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:beta + +Runs after `/sd:alpha`. Invokes `sd-keeper` for the write step. + +## Gate - confirm before writing + +STOP for explicit approval: + +> Reply to accept as-is, or send corrections. (go / <corrections>) + +Run `/sd:alpha` before `/sd:beta` to prep the workspace, and see `~/.claude/skills/sd/` +for the shared rule packs. diff --git a/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/expected.json b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/expected.json new file mode 100644 index 0000000..bf4e293 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/expected.json @@ -0,0 +1,4 @@ +{ + "note": "a file safely under contractLint.budgets.commandsBytes must not trip CL500", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/commands/gamma.md b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/commands/gamma.md new file mode 100644 index 0000000..1bcf977 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/commands/gamma.md @@ -0,0 +1,9 @@ +--- +description: Filler command sized to sit under the CL500 budget ceiling. +argument-hint: <slug> +--- + +# Gamma filler command + +This file is small enough to stay under budget, the boundary case CL500 +must never trip. diff --git a/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/specwright.manifest.json new file mode 100644 index 0000000..6a05781 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-cl500-file-at-budget-ceiling/overlay/specwright.manifest.json @@ -0,0 +1,278 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": [ + "dotnet", + "npm", + "npx", + "yarn", + "pnpm", + "pytest", + "mvn", + "gradle", + "cargo", + "go test", + "rake", + "bundle exec", + "composer" + ], + "languages": [ + "C#", + ".NET", + "TypeScript", + "JavaScript", + "Python", + "Java", + "Go", + "Rust", + "Ruby", + "PHP", + "MSSQL", + "SQL Server", + "PostgreSQL", + "MySQL", + "MongoDB" + ] + }, + "readOnlyAgents": [], + "knownMcpTools": [], + "budgets": { + "commandsBytes": 750 + } + } +} diff --git a/tests/contract-lint/fixtures/fp-gate-activity-heading/expected.json b/tests/contract-lint/fixtures/fp-gate-activity-heading/expected.json new file mode 100644 index 0000000..8204964 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-gate-activity-heading/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: \"Gate\" followed by a lowercase word is never a gate heading", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-gate-activity-heading/overlay/commands/alpha.md b/tests/contract-lint/fixtures/fp-gate-activity-heading/overlay/commands/alpha.md new file mode 100644 index 0000000..63a9fbf --- /dev/null +++ b/tests/contract-lint/fixtures/fp-gate-activity-heading/overlay/commands/alpha.md @@ -0,0 +1,38 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +## Gate activity + +A report section, not a gate. No STOP, no options, and none is wanted. diff --git a/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/expected.json b/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/expected.json new file mode 100644 index 0000000..682d8f4 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: an override DESCRIBED in prose is not an override OFFERED as a choice (CL305), and once annotated with contract-lint: allow CL306 it stays silent for the prose rule too - the same shape as commands/bug.md and commands/release.md", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/overlay/commands/alpha.md b/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/overlay/commands/alpha.md new file mode 100644 index 0000000..bc958f3 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/overlay/commands/alpha.md @@ -0,0 +1,41 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- contract-lint: allow CL306 - logged insist-and-proceed keeps an audit trail via the constitution exception --> +- If the user insists on closing without a draft, log a constitution exception and + proceed at their explicit risk acknowledgement. +<!-- contract-lint: allow CL306 - described option, not a listed override token; CL305 already covers listed-choice overrides --> +- The user may override the outcome at this gate. +- On rejection: write nothing. diff --git a/tests/contract-lint/fixtures/fp-negated-write-verb/expected.json b/tests/contract-lint/fixtures/fp-negated-write-verb/expected.json new file mode 100644 index 0000000..f8a8439 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-negated-write-verb/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: a negated instruction ('Do not write, append, or create') and a third-person subject ('The caller will Create') are not line-initial imperatives", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-negated-write-verb/overlay/agents/keeper.md b/tests/contract-lint/fixtures/fp-negated-write-verb/overlay/agents/keeper.md new file mode 100644 index 0000000..616fada --- /dev/null +++ b/tests/contract-lint/fixtures/fp-negated-write-verb/overlay/agents/keeper.md @@ -0,0 +1,22 @@ +--- +name: sd-keeper +color: blue +description: Demo agent used by the contract-lint fixtures. +model: haiku +tools: Read, Grep +skills: + - sd-demo-rule +--- + +You are the demo agent. Follow the **sd-demo-rule** skill on every task. + +## `TASK = draft` + +Inputs (required): none +Inputs (optional): none + +`Grep` for any existing draft first. Read `templates/sd/demo.template.md` and return +the drafted body. The main thread writes `00-spec.md`; you have no write tool. + +Do not write, append, or create files yourself - describe the change and let the +main thread apply it. The caller will Create `00-spec.md` from what you return. diff --git a/tests/contract-lint/fixtures/fp-phase0-stop/expected.json b/tests/contract-lint/fixtures/fp-phase0-stop/expected.json new file mode 100644 index 0000000..970674c --- /dev/null +++ b/tests/contract-lint/fixtures/fp-phase0-stop/expected.json @@ -0,0 +1,12 @@ +{ + "note": "MUST STILL BITE: literal STOPs elsewhere in the file never satisfy a gate that has none", + "findings": [ + { + "rule": "CL300", + "severity": "BLOCK", + "file": "commands/alpha.md", + "anchor": "seed", + "seed": "phase0-stop-does-not-rescue" + } + ] +} diff --git a/tests/contract-lint/fixtures/fp-phase0-stop/overlay/commands/alpha.md b/tests/contract-lint/fixtures/fp-phase0-stop/overlay/commands/alpha.md new file mode 100644 index 0000000..b6db075 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-phase0-stop/overlay/commands/alpha.md @@ -0,0 +1,41 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) + +<!-- SEEDED: phase0-stop-does-not-rescue - the file is full of STOPs; this gate still has none --> +### ⛔ Gate 3 - Publish approved + +Ask: + +> Publish? (yes / abort) diff --git a/tests/contract-lint/fixtures/fp-phase0-stop/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/fp-phase0-stop/overlay/specwright.manifest.json new file mode 100644 index 0000000..4f246d9 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-phase0-stop/overlay/specwright.manifest.json @@ -0,0 +1,243 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 3, + "conditional": [] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + } + } +} diff --git a/tests/contract-lint/fixtures/fp-substep-before-parent/expected.json b/tests/contract-lint/fixtures/fp-substep-before-parent/expected.json new file mode 100644 index 0000000..3648d3e --- /dev/null +++ b/tests/contract-lint/fixtures/fp-substep-before-parent/expected.json @@ -0,0 +1,4 @@ +{ + "note": "MUST STAY SILENT: Gate 1a is authored BEFORE Gate 2, and CL303 compares sets not order", + "findings": [] +} diff --git a/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/commands/alpha.md b/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/commands/alpha.md new file mode 100644 index 0000000..f4b038d --- /dev/null +++ b/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/commands/alpha.md @@ -0,0 +1,40 @@ +--- +description: Demo workflow used by the contract-lint fixtures. +argument-hint: <slug> +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-<slug>/00-spec.md`, then `01-plan.md`. + +## Phase 0 - Bootstrap + +If `templates/sd/demo.template.md` is missing, STOP and report it. If the registry +is unreadable, STOP. Neither of these sits inside a gate block, and CL300 must not +mistake them for one. + +## Phase 1 - Draft + +Invoke `sd-keeper` with `TASK = draft`. + +### ⛔ Gate 1 - Draft approved + +STOP. Ask: + +> Approve the draft? (yes / revise / abort) + +## Phase 2 - Close + +Append the outcome to `02-tasks.md`. + +### ⛔ Gate 1a - Redraft after rejection + +STOP. Ask: + +> Redraft? (yes / abort) + +### ⛔ Gate 2 - Close approved (HARD) + +STOP. Ask: + +> Close it now? (yes / revise / abort) diff --git a/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/specwright.manifest.json b/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/specwright.manifest.json new file mode 100644 index 0000000..973df39 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/specwright.manifest.json @@ -0,0 +1,245 @@ +{ + "$comment": [ + "Fixture manifest for the contract-lint self-test. Deliberately tiny:", + "the linter takes a root argument so a fixture tree configures itself", + "instead of the tests copying the real engine.", + "", + "rules[] must stay identical to the repo manifest (ids AND severities).", + "run-selftest.ps1 asserts that, so a wave-2 rule added to the repo", + "registry cannot be silently missing here." + ], + "contractLint": { + "scanScope": [ + "commands/*.md", + "agents/*.md", + "skills/*/SKILL.md" + ], + "installNamespaceSegment": "sd", + "rules": [ + { + "id": "CL001", + "severity": "BLOCK", + "wave": 1, + "summary": "sd- reference resolving to no agent and no skill" + }, + { + "id": "CL002", + "severity": "BLOCK", + "wave": 1, + "summary": "skills: frontmatter entry with no matching SKILL.md" + }, + { + "id": "CL003", + "severity": "BLOCK", + "wave": 1, + "summary": "unresolved sd- reference on a skill-decorated line" + }, + { + "id": "CL004", + "severity": "WARN", + "wave": 1, + "summary": "skill referenced by nobody in scan scope and not declared in skillConsumers" + }, + { + "id": "CL005", + "severity": "BLOCK", + "wave": 1, + "summary": "templates/ path that does not exist on disk" + }, + { + "id": "CL006", + "severity": "BLOCK", + "wave": 1, + "summary": "/sd:<name> reference with no matching command file" + }, + { + "id": "CL007", + "severity": "WARN", + "wave": 1, + "summary": "agent mentioned by no command body" + }, + { + "id": "CL008", + "severity": "BLOCK", + "wave": 1, + "summary": "numbered .specs artifact filename absent from specArtifacts" + }, + { + "id": "CL100", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation sets TASK/WORKFLOW_TYPE/TASK_TYPE to a mode the target agent does not declare" + }, + { + "id": "CL101", + "severity": "WARN", + "wave": 2, + "summary": "agent declares a mode no command ever invokes" + }, + { + "id": "CL102", + "severity": "BLOCK", + "wave": 2, + "summary": "invocation omits an input the declared mode marks required" + }, + { + "id": "CL103", + "severity": "WARN", + "wave": 2, + "summary": "invocation passes an input token the declared mode declares nowhere" + }, + { + "id": "CL104", + "severity": "BLOCK", + "wave": 2, + "summary": "two agent files share a frontmatter name:" + }, + { + "id": "CL200", + "severity": "BLOCK", + "wave": 3, + "summary": "agent with no write tool is instructed to write, append or create" + }, + { + "id": "CL201", + "severity": "BLOCK", + "wave": 3, + "summary": "agent listed in contractLint.readOnlyAgents declares a write tool" + }, + { + "id": "CL202", + "severity": "WARN", + "wave": 3, + "summary": "mcp__* name absent from contractLint.knownMcpTools" + }, + { + "id": "CL203", + "severity": "WARN", + "wave": 3, + "summary": "agent frontmatter declares a tool never mentioned in its own body" + }, + { + "id": "CL300", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block containing no literal STOP" + }, + { + "id": "CL301", + "severity": "BLOCK", + "wave": 1, + "summary": "gate block offering no option set" + }, + { + "id": "CL302", + "severity": "BLOCK", + "wave": 1, + "summary": "hard gate count on disk disagrees with gates.<file>.hard" + }, + { + "id": "CL303", + "severity": "WARN", + "wave": 1, + "summary": "hard gate numbering is not exactly 1..N without duplicates" + }, + { + "id": "CL304", + "severity": "BLOCK", + "wave": 1, + "summary": "conditional gate on disk undeclared, or declared and absent" + }, + { + "id": "CL305", + "severity": "BLOCK", + "wave": 1, + "summary": "HARD gate listing an override token as a selectable option" + }, + { + "id": "CL306", + "severity": "BLOCK", + "wave": 3, + "summary": "HARD gate prose describes an escape hatch with no contract-lint: allow suppression" + }, + { + "id": "CL400", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded stack command token outside a <<placeholder>>, fenced example or suppression comment" + }, + { + "id": "CL401", + "severity": "WARN", + "wave": 3, + "summary": "hardcoded language/framework name in the same contexts (stays WARN permanently - often legitimate in prose)" + }, + { + "id": "CL402", + "severity": "BLOCK", + "wave": 3, + "summary": "hardcoded absolute filesystem path (Windows drive letter or POSIX multi-segment) in scan scope" + }, + { + "id": "CL500", + "severity": "WARN", + "wave": 4, + "summary": "file exceeds contractLint.budgets.<area>Bytes (stays WARN permanently - a ratchet, not a build gate)" + }, + { + "id": "CL900", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression comment carrying no usable reason" + }, + { + "id": "CL901", + "severity": "BLOCK", + "wave": 1, + "summary": "suppression naming a rule id absent from this registry" + }, + { + "id": "CL902", + "severity": "WARN", + "wave": 1, + "summary": "suppression that suppressed no finding" + } + ], + "gates": { + "commands/alpha.md": { + "quantity": null, + "hard": 2, + "conditional": [ + "1a" + ] + }, + "commands/beta.md": { + "quantity": null, + "hard": 1, + "conditional": [] + } + }, + "specArtifacts": [ + "00-spec.md", + "01-plan.md", + "02-tasks.md" + ], + "skillConsumers": {}, + "overrideOptionTokens": [ + "skip", + "override", + "proceed anyway", + "bypass", + "force", + "ignore" + ], + "gateProseEscapeTokens": [ + "insists on", + "may override", + "proceed anyway", + "bypass this gate" + ], + "stackTokens": { + "commands": ["dotnet", "npm", "npx", "yarn", "pnpm", "pytest", "mvn", "gradle", "cargo", "go test", "rake", "bundle exec", "composer"], + "languages": ["C#", ".NET", "TypeScript", "JavaScript", "Python", "Java", "Go", "Rust", "Ruby", "PHP", "MSSQL", "SQL Server", "PostgreSQL", "MySQL", "MongoDB"] + } + } +} diff --git a/tests/contract-lint/run-selftest.ps1 b/tests/contract-lint/run-selftest.ps1 new file mode 100644 index 0000000..cb98dd1 --- /dev/null +++ b/tests/contract-lint/run-selftest.ps1 @@ -0,0 +1,464 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fixture suite and negative self-test for scripts/contract-lint.{ps1,sh}. + +.DESCRIPTION + One pwsh runner drives BOTH implementations in a single process, so + cross-implementation parity is ASSERTED rather than inferred from two green + runs in different jobs. This mirrors tests/hooks/run-conformance.ps1. + + For every case under fixtures/ it builds a workspace (a fresh copy of + fixtures/_base with the case's overlay/ copied over it), runs both linters + against it, and checks four things: + + 1. the findings match expected.json (rule, severity, file, and an anchor + that is resolved at run time - see "Anchors" below); + 2. the exit code follows from the expected severities; + 3. the bash and PowerShell outputs are identical LINE FOR LINE, message + text included, even though expected.json never pins a message; + 4. nothing extra fired. + + Harness invariants, each a hard failure rather than a skip: + + A. fixtures/_base itself must produce ZERO findings. A seeded violation + that leaked into the base would make every case's golden wrong in the + same direction, and nothing would notice. + B. fixtures/_base/specwright.manifest.json's rule registry must equal the + repo manifest's registry, ids AND severities. Otherwise a wave-2 rule + lands in the engine and the fixtures keep testing the old contract. + C. every rule id in the repo registry appears in at least one + expected.json. This is what makes "add a rule" mean "add a fixture". + D. every rule id in the repo registry appears in docs/contract-lint.md's + table, and that table names no rule the registry lacks. + E. every case directory is registered in README.md and every case named in + README.md exists. An unregistered case FAILS; it never silently skips. + F. bash and jq must be present. A validator that skips when its tools are + missing is a validator that turns CI green while checking nothing. + +.PARAMETER SelfTest + Negative mode. Replaces the bash linter with a stub that exits 0 and prints + nothing, then asserts the harness DETECTS that divergence. Run in two + stages: the real sweep must pass first, otherwise a broken harness could + "detect" the stub for the wrong reason. + +.PARAMETER Case + Run only case directories whose name contains this substring. Diagnostic + aid; invariants C, D and E are skipped when it is used, because a filtered + run cannot honestly assert full coverage. + +.EXAMPLE + .\tests\contract-lint\run-selftest.ps1 + .\tests\contract-lint\run-selftest.ps1 -SelfTest + +.NOTES + PURE ASCII. validate's Check 1 scans every *.ps1 recursively, so this file + must contain no byte above 0x7F. +#> + +[CmdletBinding()] +param( + [switch]$SelfTest, + [string]$Case = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$testsRoot = $PSScriptRoot +$repoRoot = Split-Path -Parent (Split-Path -Parent $testsRoot) +$fixturesRoot = Join-Path $testsRoot 'fixtures' +$baseRoot = Join-Path $fixturesRoot '_base' +$lintPs1 = Join-Path $repoRoot 'scripts\contract-lint.ps1' +$lintSh = (Join-Path $repoRoot 'scripts\contract-lint.sh') -replace '\\', '/' + +$script:Failures = New-Object System.Collections.Generic.List[string] +function Write-Section { param([string]$t) Write-Host ''; Write-Host "=== $t ===" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [OK] $m" -ForegroundColor Green } +function Write-FailMsg { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } +function Add-Failure { param([string]$m) $script:Failures.Add($m) } + +# ---- preconditions (invariant F) ------------------------------------------- + +function Find-WorkingBash { + # Same search order as scripts/validate.ps1: the bare 'bash' on PATH is + # often the WSL launcher, which fails with no distro installed and + # mistranslates Windows paths. Prefer Git for Windows' bash. + $candidates = New-Object System.Collections.Generic.List[string] + $git = Get-Command git -ErrorAction SilentlyContinue + if ($git) { + $gitRoot = Split-Path -Parent (Split-Path -Parent $git.Source) + $candidates.Add((Join-Path $gitRoot 'bin\bash.exe')) + $candidates.Add((Join-Path $gitRoot 'usr\bin\bash.exe')) + } + foreach ($pf in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, "$env:LOCALAPPDATA\Programs")) { + if ($pf) { + $candidates.Add((Join-Path $pf 'Git\bin\bash.exe')) + $candidates.Add((Join-Path $pf 'Git\usr\bin\bash.exe')) + } + } + foreach ($c in (Get-Command bash -All -ErrorAction SilentlyContinue)) { $candidates.Add($c.Source) } + foreach ($c in $candidates) { + if ($c -and (Test-Path -LiteralPath $c)) { + try { + & $c -c 'exit 0' 2>$null + if ($LASTEXITCODE -eq 0) { return $c } + } catch { } + } + } + return $null +} + +Write-Section 'contract-lint self-test' +Write-Host " Repo root: $repoRoot" + +$bashExe = Find-WorkingBash +if ($null -eq $bashExe) { + Write-FailMsg 'no working bash found - this harness runs BOTH implementations and cannot skip one' + exit 1 +} +& $bashExe -c 'command -v jq >/dev/null 2>&1' 2>$null +if ($LASTEXITCODE -ne 0) { + Write-FailMsg 'jq not found on the bash PATH - contract-lint.sh cannot parse the manifest' + exit 1 +} +foreach ($p in @($lintPs1, ($lintSh -replace '/', '\'), $baseRoot)) { + if (-not (Test-Path -LiteralPath $p)) { + Write-FailMsg "missing required path: $p" + exit 1 + } +} +Write-Ok "bash: $bashExe" + +$psExe = (Get-Process -Id $PID).Path + +# ---- linter invocation ------------------------------------------------------ + +function Invoke-Linters { + param([string]$Root, [string]$BashScript) + $rootFwd = $Root -replace '\\', '/' + $bashOut = @(& $bashExe $BashScript --root $rootFwd --quiet 2>$null) + $bashExit = $LASTEXITCODE + + $psArgs = @('-NoProfile') + if ($env:OS -eq 'Windows_NT') { $psArgs += @('-ExecutionPolicy', 'Bypass') } + $psArgs += @('-File', $lintPs1, '-Root', $Root, '-Quiet') + $psOut = @(& $psExe @psArgs 2>$null) + $psExit = $LASTEXITCODE + + return @{ + BashOut = @($bashOut | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + PsOut = @($psOut | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + BashExit = $bashExit + PsExit = $psExit + } +} + +function ConvertTo-Findings { + param([string[]]$Rows) + $out = New-Object 'System.Collections.Generic.List[object]' + foreach ($r in $Rows) { + $p = $r.Split([char]9) + if ($p.Count -lt 5) { continue } + [void]$out.Add([PSCustomObject]@{ + Rule = $p[0]; Severity = $p[1]; File = $p[2]; Line = [int]$p[3]; Message = $p[4] + }) + } + # .ToArray(), not the list and not a comma-wrapped list: a comma-wrapped + # return arrives at the caller as ONE object that happens to be a list, and + # @() around it then yields a single-element array whose only member has no + # .Rule property. An array emits its elements, so @() collects zero or more. + return $out.ToArray() +} + +# ---- anchors ---------------------------------------------------------------- +# +# expected.json pins an ANCHOR, never a literal line number. A literal rots the +# instant a line above it shifts, and the case then passes vacuously - the exact +# lesson scripts/selftest-docs.sh was rewritten for. +# +# anchor "file" the finding is a whole-file verdict; its line must be 1. +# anchor "seed" the finding must land within SEED_WINDOW lines AFTER the +# '<!-- SEEDED: <name> - <why> -->' marker. A window, not the +# next non-comment line, because the CL9xx cases report ON a +# comment line (the suppression itself). + +$SEED_WINDOW = 3 + +function Get-SeedLine { + param([string]$Workspace, [string]$File, [string]$Seed) + $path = Join-Path $Workspace ($File -replace '/', '\') + if (-not (Test-Path -LiteralPath $path)) { return -1 } + $text = [System.Text.Encoding]::UTF8.GetString([System.IO.File]::ReadAllBytes($path)) + $lines = $text.Split([char]10) + $needle = '<!-- SEEDED: ' + $Seed + ' - ' + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i].Contains($needle)) { return $i + 1 } + } + return -1 +} + +function Test-Anchor { + param([object]$Expected, [object]$Actual, [string]$Workspace) + if ($Expected.anchor -ceq 'file') { + return ($Actual.Line -eq 1) + } + $seedLine = Get-SeedLine -Workspace $Workspace -File $Expected.file -Seed $Expected.seed + if ($seedLine -lt 0) { return $false } + return ($Actual.Line -gt $seedLine -and $Actual.Line -le ($seedLine + $SEED_WINDOW)) +} + +# ---- workspace -------------------------------------------------------------- + +function New-CaseWorkspace { + param([string]$CaseDir) + $ws = Join-Path ([System.IO.Path]::GetTempPath()) ("cl-selftest-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $ws -Force | Out-Null + Copy-Item -Path (Join-Path $baseRoot '*') -Destination $ws -Recurse -Force + $overlay = Join-Path $CaseDir 'overlay' + if (Test-Path -LiteralPath $overlay) { + $items = @(Get-ChildItem -LiteralPath $overlay -Force) + if ($items.Count -gt 0) { + Copy-Item -Path (Join-Path $overlay '*') -Destination $ws -Recurse -Force + } + } + return $ws +} + +# ---- the sweep -------------------------------------------------------------- + +function Invoke-Sweep { + param([string]$BashScript, [switch]$Silent) + $result = @{ Failures = New-Object 'System.Collections.Generic.List[string]'; RulesSeen = (New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal)) } + + $caseDirs = @(Get-ChildItem -LiteralPath $fixturesRoot -Directory | + Where-Object { $_.Name -cne '_base' } | Sort-Object -Property Name) + foreach ($cd in $caseDirs) { + if ($Case.Length -gt 0 -and -not $cd.Name.Contains($Case)) { continue } + $expPath = Join-Path $cd.FullName 'expected.json' + if (-not (Test-Path -LiteralPath $expPath)) { + $result.Failures.Add("$($cd.Name): no expected.json - an unregistered case fails, it does not skip") + continue + } + $exp = (Get-Content -LiteralPath $expPath -Raw | ConvertFrom-Json) + $expected = @() + if ($null -ne $exp.findings) { $expected = @($exp.findings) } + foreach ($e in $expected) { [void]$result.RulesSeen.Add([string]$e.rule) } + + $ws = New-CaseWorkspace -CaseDir $cd.FullName + try { + $run = Invoke-Linters -Root $ws -BashScript $BashScript + + # Parity first: if the twins disagree, every other verdict is noise. + $diff = Compare-Object $run.BashOut $run.PsOut + if ($null -ne $diff) { + $result.Failures.Add("$($cd.Name): bash and PowerShell disagree ($($run.BashOut.Count) vs $($run.PsOut.Count) row(s))") + foreach ($d in $diff) { + $side = if ($d.SideIndicator -eq '<=') { 'bash only' } else { 'pwsh only' } + $result.Failures.Add("$($cd.Name): $side : $($d.InputObject)") + } + } + if ($run.BashExit -ne $run.PsExit) { + $result.Failures.Add("$($cd.Name): exit codes differ (bash $($run.BashExit), pwsh $($run.PsExit))") + } + + $actual = @(ConvertTo-Findings -Rows $run.PsOut) + $wantExit = 0 + foreach ($e in $expected) { if ($e.severity -ceq 'BLOCK') { $wantExit = 1 } } + if ($run.PsExit -ne $wantExit) { + $result.Failures.Add("$($cd.Name): expected exit $wantExit, got $($run.PsExit)") + } + + $matched = New-Object 'System.Collections.Generic.List[int]' + foreach ($e in $expected) { + $hit = -1 + for ($i = 0; $i -lt $actual.Count; $i++) { + if ($matched.Contains($i)) { continue } + $a = $actual[$i] + if ($a.Rule -cne $e.rule) { continue } + if ($a.Severity -cne $e.severity) { continue } + if ($a.File -cne $e.file) { continue } + if (-not (Test-Anchor -Expected $e -Actual $a -Workspace $ws)) { continue } + $hit = $i; break + } + if ($hit -lt 0) { + $where = if ($e.anchor -ceq 'file') { 'line 1' } else { "seed '$($e.seed)'" } + $result.Failures.Add("$($cd.Name): expected $($e.rule) $($e.severity) in $($e.file) at $where - not found") + } else { + [void]$matched.Add($hit) + } + } + for ($i = 0; $i -lt $actual.Count; $i++) { + if ($matched.Contains($i)) { continue } + $a = $actual[$i] + $result.Failures.Add("$($cd.Name): unexpected $($a.Rule) at $($a.File):$($a.Line) - $($a.Message)") + } + + if (-not $Silent) { + $n = $result.Failures.Count + if ($n -eq $script:sweepMark) { + Write-Ok "$($cd.Name) ($($expected.Count) expected finding(s))" + } else { + Write-FailMsg "$($cd.Name)" + } + $script:sweepMark = $n + } + } finally { + Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue + } + } + return $result +} + +# ---- invariant A: the base tree is clean ----------------------------------- + +Write-Section 'Invariant A: fixtures/_base produces no findings' +$baseRun = Invoke-Linters -Root $baseRoot -BashScript $lintSh +if ($baseRun.BashOut.Count -ne 0 -or $baseRun.PsOut.Count -ne 0) { + Write-FailMsg "fixtures/_base is not clean (bash $($baseRun.BashOut.Count), pwsh $($baseRun.PsOut.Count) finding(s))" + foreach ($r in $baseRun.PsOut) { Write-Host " $r" } + Add-Failure 'base tree is not clean' +} elseif ($baseRun.BashExit -ne 0 -or $baseRun.PsExit -ne 0) { + Write-FailMsg "fixtures/_base exit codes are not both 0 (bash $($baseRun.BashExit), pwsh $($baseRun.PsExit))" + Add-Failure 'base tree exit code' +} else { + Write-Ok 'base tree is clean on both implementations' +} + +# ---- invariant B: fixture registry mirrors the repo registry --------------- + +Write-Section 'Invariant B: fixture rule registry mirrors the repo registry' +$repoManifest = Get-Content -LiteralPath (Join-Path $repoRoot 'specwright.manifest.json') -Raw | ConvertFrom-Json +$baseManifest = Get-Content -LiteralPath (Join-Path $baseRoot 'specwright.manifest.json') -Raw | ConvertFrom-Json +$repoReg = @($repoManifest.contractLint.rules | ForEach-Object { "$($_.id)=$($_.severity)" }) +$baseReg = @($baseManifest.contractLint.rules | ForEach-Object { "$($_.id)=$($_.severity)" }) +$regDiff = Compare-Object $repoReg $baseReg +if ($null -ne $regDiff) { + foreach ($d in $regDiff) { + $side = if ($d.SideIndicator -eq '<=') { 'repo only' } else { 'fixture only' } + Write-FailMsg "registry mismatch ($side): $($d.InputObject)" + } + Add-Failure 'fixture registry differs from repo registry' +} else { + Write-Ok "$($repoReg.Count) rule(s), identical id and severity in both manifests" +} + +# ---- the case sweep -------------------------------------------------------- + +Write-Section 'Fixture cases' +$script:sweepMark = 0 +$sweep = Invoke-Sweep -BashScript $lintSh +foreach ($f in $sweep.Failures) { Write-FailMsg $f; Add-Failure $f } +if ($sweep.Failures.Count -eq 0) { Write-Host '' } + +# ---- invariants C, D, E ---------------------------------------------------- + +if ($Case.Length -gt 0) { + Write-Section 'Invariants C, D, E' + Write-Ok 'skipped: -Case filters the sweep, so coverage cannot be asserted honestly' +} else { + Write-Section 'Invariant C: every rule has a fixture' + $uncovered = @() + foreach ($r in $repoManifest.contractLint.rules) { + if (-not $sweep.RulesSeen.Contains([string]$r.id)) { $uncovered += [string]$r.id } + } + if ($uncovered.Count -gt 0) { + Write-FailMsg "no expected.json mentions: $($uncovered -join ', ')" + Add-Failure 'rules without a fixture' + } else { + Write-Ok "all $($repoReg.Count) rule(s) appear in at least one expected.json" + } + + Write-Section 'Invariant D: registry matches docs/contract-lint.md' + $docPath = Join-Path $repoRoot 'docs\contract-lint.md' + if (-not (Test-Path -LiteralPath $docPath)) { + Write-FailMsg 'docs/contract-lint.md not found' + Add-Failure 'contract-lint doc missing' + } else { + $docIds = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($line in (Get-Content -LiteralPath $docPath)) { + $m = [regex]::Match($line, '^\| `(CL[0-9][0-9][0-9])` \|') + if ($m.Success) { [void]$docIds.Add($m.Groups[1].Value) } + } + $regIds = @($repoManifest.contractLint.rules | ForEach-Object { [string]$_.id }) + $docDiff = Compare-Object $regIds @($docIds) + if ($null -ne $docDiff) { + foreach ($d in $docDiff) { + $side = if ($d.SideIndicator -eq '<=') { 'in the registry, missing from the doc' } else { 'in the doc, missing from the registry' } + Write-FailMsg "$($d.InputObject) is $side" + } + Add-Failure 'registry and docs/contract-lint.md disagree' + } else { + Write-Ok "$($docIds.Count) rule(s) documented, both directions" + } + } + + Write-Section 'Invariant E: every case is registered in README.md' + $readmePath = Join-Path $testsRoot 'README.md' + if (-not (Test-Path -LiteralPath $readmePath)) { + Write-FailMsg 'tests/contract-lint/README.md not found' + Add-Failure 'fixture README missing' + } else { + $readme = [System.Text.Encoding]::UTF8.GetString([System.IO.File]::ReadAllBytes($readmePath)) + $onDisk = @(Get-ChildItem -LiteralPath $fixturesRoot -Directory | + Where-Object { $_.Name -cne '_base' } | ForEach-Object { $_.Name } | Sort-Object) + $named = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + foreach ($m in [regex]::Matches($readme, '`(clean|cl[0-9]{3}-[a-z0-9-]+|fp-[a-z0-9-]+)`')) { + [void]$named.Add($m.Groups[1].Value) + } + $caseDiff = Compare-Object $onDisk @($named) + if ($null -ne $caseDiff) { + foreach ($d in $caseDiff) { + $side = if ($d.SideIndicator -eq '<=') { 'on disk but not in README.md' } else { 'in README.md but not on disk' } + Write-FailMsg "$($d.InputObject) is $side" + } + Add-Failure 'case directories and README.md disagree' + } else { + Write-Ok "$($onDisk.Count) case(s), all registered" + } + } +} + +# ---- negative self-test ---------------------------------------------------- + +if ($SelfTest) { + Write-Section 'Negative self-test: does the harness notice a dead linter?' + if ($script:Failures.Count -gt 0) { + # Two-stage, as in tests/hooks/run-conformance.ps1: a harness that is + # already failing could "detect" the stub for entirely the wrong reason. + Write-FailMsg 'the real sweep did not pass, so a stub failure would prove nothing' + Add-Failure 'self-test precondition: real sweep must pass first' + } else { + $stubDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cl-stub-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $stubDir -Force | Out-Null + try { + $stub = Join-Path $stubDir 'contract-lint.sh' + $stubBody = "#!/usr/bin/env bash" + [char]10 + "# stub: reports nothing and claims success" + [char]10 + "exit 0" + [char]10 + [System.IO.File]::WriteAllText($stub, $stubBody, (New-Object System.Text.UTF8Encoding($false))) + $stubFwd = $stub -replace '\\', '/' + $stubSweep = Invoke-Sweep -BashScript $stubFwd -Silent + if ($stubSweep.Failures.Count -eq 0) { + Write-FailMsg 'a linter that reports NOTHING passed the whole suite - the harness is not checking anything' + Add-Failure 'self-test: stub linter went undetected' + } else { + Write-Ok "stub linter detected: $($stubSweep.Failures.Count) failure(s) raised" + } + } finally { + Remove-Item -LiteralPath $stubDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +# ---- summary --------------------------------------------------------------- + +Write-Section 'Summary' +if ($script:Failures.Count -eq 0) { + Write-Host ' [OK] contract-lint self-test passed.' -ForegroundColor Green + # GitHub Actions appends 'exit $LASTEXITCODE' to every pwsh step, so an + # implicit success must still be an explicit 0. + exit 0 +} else { + Write-Host " [FAIL] $($script:Failures.Count) failure(s):" -ForegroundColor Red + foreach ($m in $script:Failures) { Write-Host " - $m" -ForegroundColor Red } + exit 1 +} diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..927ce2b --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,229 @@ +# tests/e2e - headless behavioral eval harness (SW-27) + +Drives real `claude -p` (headless) sessions against a throwaway copy of a fixture project and +asserts on **produced artifacts** - files, frontmatter, status values - never on transcript +wording. This is the only mechanism in the repo that exercises a real model session against the +engine's commands and hooks; everything else (`scripts/validate.*`, `tests/hooks/`, +`tests/contract-lint/`) checks the *assets* (do commands/agents/skills reference each other +correctly) or pipes fixture JSON directly into a hook script in isolation. Neither proves the +engine *behaves* correctly end-to-end through a real session - see `examples/spec-lint-fixture/README.md` +for the gap this closes (`/sd:spec validate` is a prompt, not executable code, so no script can run +it - this harness runs it for real instead of reimplementing the rules). + +## The rule: assert artifacts, never prose + +A model's phrasing varies run to run; the files it must produce, and their frontmatter/status +values, do not. Every `expect.json` in `scenarios/*/` asserts on file existence, file content +patterns, or (only for the one report-only command, scenario 5) presence of specific structured +rule-ID tokens in the final output - never on sentence-level wording. Any assertion that flakes +twice gets deleted or rewritten, not retried. + +## Prerequisites + +- `claude` CLI on `PATH`, **v2.1.196 or later**. +- `ANTHROPIC_API_KEY` set in the environment. +- A real, already-authenticated `~/.claude/.credentials.json` on the machine running this locally + (see "Isolation and auth" below - CI does not have this and authenticates differently, see the + nightly workflow file). +- PowerShell 7+ (`pwsh`) - this is a single cross-platform runner, same posture as + `tests/hooks/run-conformance.ps1` and `tests/contract-lint/run-selftest.ps1`: a test harness, not + a `hooks/` file, so the "hooks ship in pairs" rule does not apply. +- Node.js (only `scenarios/01-setup` and `02-feature-happy` run `npm`/`node --test` inside their + fixture). + +## Running it + +```powershell +# Full suite +.\tests\e2e\run-e2e.ps1 + +# One scenario, for debugging +.\tests\e2e\run-e2e.ps1 -Case 03-spec-gate-negative + +# Prove the harness would catch a removed guard (see "Self-test" below) +.\tests\e2e\run-e2e.ps1 -SelfTest + +# Verbose: prints claude's exit code/result/cost and the workspace's events.jsonl per scenario +$env:SD_E2E_DEBUG = '1'; .\tests\e2e\run-e2e.ps1 + +# Keep the throwaway workspace and fake home after a run instead of deleting them (debugging only) +$env:SD_E2E_KEEP = '1'; .\tests\e2e\run-e2e.ps1 -Case 01-setup +``` + +Not wired into the per-PR `ci.yml` job - see "CI placement" below. + +## Isolation and auth + +Each scenario gets a fresh "fake home" directory with the engine installed into it via +`install.ps1 -BasePath <fakehome>/.claude` - the same sandbox recipe CLAUDE.md documents and the +CI install/uninstall round-trip job already uses - plus a fresh workspace directory holding the +project under test (a throwaway copy of `examples/fixture-project` or +`examples/spec-lint-fixture/broken`, per scenario). The `claude` subprocess runs with +`HOME`/`USERPROFILE` pointed at the fake home and cwd set to the workspace, so `~/.claude/...` +(used literally in command prompts, e.g. `commands/setup.md` Phase 0) and `${HOME}` (used in +`settings.json` hook command strings) both resolve into the sandbox, never the real user install. +`--setting-sources project` is passed as a second, independent guarantee that no real user-scope +settings can merge in. Both directories are deleted after every run. + +**Auth is the one piece that cannot be fully sandboxed locally.** Verified directly: `claude -p` +needs `~/.claude/.credentials.json` (org/identity context) even when billing resolves through +`ANTHROPIC_API_KEY` - an empty `HOME` alone produces `"Not logged in"` regardless of a valid key. +`run-e2e.ps1` copies the real `~/.claude/.credentials.json` into each fake home at setup time and +discards it on cleanup - it is never written anywhere persistent and never committed. If that file +is absent (a from-scratch CI runner with no prior `claude auth login`), the harness prints one +warning and headless auth will fail unless the environment authenticates a different way - see the +nightly workflow file for how CI does this. + +Tool-level file access is a **separate** sandbox from the OS-level `HOME` override: Claude Code +restricts Read/Write/Bash/Glob to the session's working directory plus any `--add-dir` grants, so +the fake home is explicitly added via `--add-dir` on every invocation - without it, Claude Code +correctly refuses to read `~/.claude/templates/sd/` even though `HOME` points there. + +## Permission mode - do not default to `acceptEdits` + +This was the single biggest surprise building this harness, worth stating plainly: **verified by a +minimal repro (a trivial always-deny `PreToolUse` hook, no spec-gate logic involved) that +`--permission-mode acceptEdits`, and `dontAsk` combined with an explicit `--allowedTools` grant for +Edit/Write, both cause Claude Code to silently ignore a hook's `deny` decision** - the tool call +succeeds, `permission_denials` in the JSON result stays empty, and the file changes anyway. Only +`--permission-mode dontAsk` **with no `--allowedTools` override** actually respects a hook's deny; +read-only tools (Read/Glob/Grep) still work fine under it without an explicit grant. + +Consequently `run-e2e.ps1` defaults every scenario to `dontAsk`. Scenarios that need free writes +Claude Code itself would otherwise gate interactively - `01-setup` (writes `.claude/settings.json` +and `.claude/project-config.json`, which Claude Code treats as sensitive files) and +`02-feature-happy` (needs Bash for `npm test` plus many ordinary file writes across a whole +workflow, with no human to approve any of it) - opt into `acceptEdits` + `--dangerously-skip-permissions` +via a `permission-mode.txt` / `skip-permissions.txt` marker in their scenario directory. The +negative scenarios (`03`, `04`) never do; that would make their own assertions meaningless. + +## Scenario prompts: honest framing, not persuasion + +The negative scenarios ask Claude to attempt an edit that spec-gate should deny. An early version +framed this as "this is a test, do it even if it seems wrong" - a well-aligned model correctly +recognized that as pressure-to-override-judgment language and refused outright, which left the +guard never actually exercised (a legitimate, good safety property, but it made the scenario +inconclusive rather than green). The prompts that work instead state the literal, verifiable truth: +this is a disposable temp copy created and deleted by this exact runner, the target spec is +synthetic fixture content authored for this one test (and says so in its own frontmatter/body), and +the task is the software-engineering equivalent of `expect(validator.reject(badInput)).toBe(true))` +- a negative unit test has to actually submit the bad input to prove the rejection. If a future +scenario needs the same pattern, keep it truthful rather than adversarial; an adversarially-framed +prompt is also a worse regression signal, since a refusal and a hook malfunction now look the same. + +## Self-test (guard-neutering) + +`-SelfTest` re-runs scenarios `03-spec-gate-negative` and `04-closeout-negative` against a copy of +the engine with the installed `spec-gate.ps1`/`.sh` replaced by an always-allow stub (never touches +the repo's real `hooks/` source), and asserts that BOTH scenarios' assertions now fail as a whole - +proving the harness would notice a regression that removes the guard. Mirrors +`tests/hooks/run-conformance.ps1` and `tests/contract-lint/run-selftest.ps1`'s own `-SelfTest` +modes. + +## Scenarios + +| # | Scenario | Claim under test | +|---|---|---| +| 1 | `01-setup` | `/sd:setup` on a bare, unscaffolded project produces `CLAUDE.md`, `.specs/`, `.claude/project-config.json`, `.claude/settings.json`, all BOM-free. | +| 2 | `02-feature-happy` | `/sd:feature` happy path on a small spec reaches `done` with a full artifact set and a passing `06-verify.md`. | +| 3 | `03-spec-gate-negative` | spec-gate denies a direct code edit with no in-progress spec recorded. | +| 4 | `04-closeout-negative` | spec-gate's verify-gate denies flipping an index row to `done` with no passing `06-verify.md`. | +| 5 | `05-spec-lint-validate` | `/sd:spec validate --all` against `examples/spec-lint-fixture/broken` surfaces the seeded `SL0xx` findings - the one command this harness must assert on output text, since `/sd:spec validate` is report-only with no artifact file. | + +Each scenario directory may contain: `source.txt` (repo-relative base tree to copy), +`workspace/` (overlay applied on top - added/overwritten files only, mirrors the +`tests/contract-lint` `_base` + overlay fixture pattern), `prompt.txt` (the literal headless +prompt), `expect.json` (declarative assertions), and optional `budget.txt` / `permission-mode.txt` +/ `skip-permissions.txt` / `disallowed-tools.txt` overrides. + +## Cost and CI placement + +Not per-PR - a `claude -p` suite costs real API tokens and wall-clock minutes, neither of which +belongs gating every push. Runs nightly (or on manual `workflow_dispatch`) on a single OS via +`.github/workflows/e2e-nightly.yml`. + +Measured cost of one full 5-scenario run on this machine (`SD_E2E_DEBUG=1`, per-scenario +`total_cost_usd` from the `claude -p --output-format json` result), 2026-08-01, `claude` 2.1.220, +auth via `~/.claude/.credentials.json` (claude.ai login - `ANTHROPIC_API_KEY` must be unset, see +"Isolation and auth"): + +| Scenario | `total_cost_usd` | Result | +|---|---|---| +| `01-setup` | $0.7355 | pass | +| `02-feature-happy` | not recorded - process killed at the 600s timeout before `claude -p` returned a result | **fail (timeout)** - see gap #1 below | +| `03-spec-gate-negative` | $0.1350 | pass | +| `04-closeout-negative` | $0.1696 | pass | +| `05-spec-lint-validate` | $1.4645 | pass | + +Sum of the four completed scenarios: **~$2.50**. `02-feature-happy` additionally consumed real, +uncaptured spend across its full 10-minute run before being killed - the true full-suite cost is +higher than the $2.50 figure above. `-SelfTest` (re-runs only `03`/`04` against a neutered guard) +cost an additional $0.1156 + $0.2361 = ~$0.35 and correctly flagged both scenarios as +guard-detected (the `events.jsonl` block-event assertion fails as expected when the hook is +stubbed out, even though the model independently declined to make the edit in one case - see +`Test-OneAssertion` / `-SelfTest` semantics in `run-e2e.ps1`). + +**Acceptance bar "green 3 times consecutively" is not yet met.** Only one full-suite run has been +completed against these figures, and it was not clean (see gap #1). Two more consecutive clean +runs are still required before this harness can be considered to satisfy SW-27's reproducibility +criterion - tracked as follow-up, not attempted further here to avoid spending real budget +re-confirming a known, non-flaky failure. + +## Known product gaps this harness surfaced + +**1. Rule 1 (`paths.protected`) appears to make `/sd:feature` unable to complete under a +permission posture that actually respects hooks - this looks like a real, pre-existing bug, not a +new regression.** `.specs/index.md` is listed in `paths.protected` by default +(`templates/project-config.template.json`), and `spec-gate.ps1`/`.sh` Rule 1 blocks EVERY edit to a +protected path unconditionally (no `mode` check, unlike Rule 3) - except the narrow Rule 0 +carve-out for a FEAT- row's `in-progress -> done` transition with a passing `06-verify.md`. But +`/sd:feature`'s own Gate 1 (spec approval, `draft -> approved`) and Gate 2 (plan approval, +`approved -> in-progress`) work by editing that exact same `index.md` row with the `Edit` tool - +there is no separate "`/sd:spec status` mechanism" at the tool-call level, so the hook cannot tell +apart the workflow's own legitimate transition from a careless hand-edit. Scenario 2's real run +against the unmodified `examples/fixture-project` config confirms this happening live - the exact +sequence spec-gate recorded in `.specs/_metrics/events.jsonl`: + +``` +{"...","gate":"protected","decision":"block"} <- draft -> approved edit +{"...","spec_id":"FEAT-todo-count","phase":"draft","event":"spec_transition","from":"-","decision":"block"} +{"...","gate":"protected","decision":"block"} <- approved -> in-progress edit +{"...","spec_id":"FEAT-todo-count","phase":"approved","event":"spec_transition","from":"draft","decision":"block"} +{"...","gate":"protected","decision":"block"} <- in-progress -> done edit (also hits Rule 1 first) +{"...","spec_id":"FEAT-todo-count","phase":"in-progress","event":"spec_transition","from":"approved","decision":"block"} +... +{"...","spec_id":"FEAT-todo-count","phase":"done","event":"gate","gate":"verify","decision":"allow"} <- Rule 0 finally allows the LAST one +``` + +Every one of those edits nonetheless landed in the real file, and the scenario passed - only +because scenario 2 deliberately runs under `acceptEdits` (see "Permission mode" above), which this +harness independently proved overrides a hook's deny. Under the harness's own default `dontAsk` +(the mode that actually enforces hook decisions), the very first status transition of any +`/sd:feature` run would be denied and the workflow could never progress past Gate 1. This was never +caught before because `tests/hooks/run-conformance.ps1` only pipes crafted JSON into the hook +script in isolation - it has no way to know the real workflow's own operation collides with Rule 1 +- and no prior mechanism drove a real session through `/sd:feature` under an enforcing permission +mode. Likely candidate fixes (not attempted here - out of scope for SW-27, and touching `spec-gate` +means a twin-implementation change plus new `tests/hooks` fixtures): widen Rule 0's scope beyond +"FEAT- done transitions only" to cover legitimate in-workflow status transitions generally, or +special-case `index.md` row edits that only change the `Status` column via a recognized transition +pattern. **Recommend filing this as its own ticket** rather than folding a `hooks/` fix into SW-27's +scope. + +**Update, 2026-08-01 full-suite run:** this time `02-feature-happy` did not merely proceed despite +repeated Rule 1 denials - it stalled outright and hit the 600s timeout. `events.jsonl` shows the +same three blocked transitions, one allowed code-edit, then a `subagent_stop` with `"stale":1` at +06:24:03, and nothing further before the kill at 06:27:28. Whether this is the same underlying +collision now additionally tripping some retry/backoff path, or a second, separate stall condition, +is not diagnosed here - worth investigating alongside the Rule 1 fix rather than assuming the two +are identical. This also means the suite has not yet achieved a clean run, so the "reproducible +green 3x" acceptance bar (see "Cost and CI placement" above) remains open. + +**2. `spec-gate`'s `PreToolUse` matcher only covers the `Edit`, `Write`, and `MultiEdit` tools.** A +model that is willing to bypass the intended workflow (unlike the well-aligned behavior seen while +building this harness - see "Scenario prompts" above) could in principle write the same file +change through `Bash` (`sed`, `cat <<EOF >`, ...), which the hook never sees. This is not a defect +in this harness or in `spec-gate` as scoped; it is a real, previously-unverified boundary of what +the hook covers, discovered by driving a real session instead of only unit-testing the hook script +in isolation. Worth a follow-up ticket if Bash-mediated code edits during an active spec workflow +turn out to matter in practice. diff --git a/tests/e2e/run-e2e.ps1 b/tests/e2e/run-e2e.ps1 new file mode 100644 index 0000000..540d7a6 --- /dev/null +++ b/tests/e2e/run-e2e.ps1 @@ -0,0 +1,458 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + specwright: headless behavioral eval harness for commands and gates (SW-27). + +.DESCRIPTION + Drives real `claude -p` (headless) sessions against a throwaway copy of a + fixture project, one scenario per run, and asserts on PRODUCED ARTIFACTS + (files, frontmatter, status values) rather than transcript wording. + + Isolation: each scenario gets a fresh "fake home" directory with the + engine installed into it via install.ps1 -BasePath <fakehome>/.claude + (the same sandbox pattern CI's install/uninstall round-trip job uses), + and a fresh workspace directory holding the project under test. The + claude subprocess runs with HOME/USERPROFILE pointed at the fake home and + cwd set to the workspace, so `~/.claude/...` (used literally in command + prompts, e.g. setup.md Phase 0) and `${HOME}` (used in settings.json hook + command strings) both resolve to the sandbox, never the real user + install. --setting-sources project is passed as a second, independent + guarantee that no real user-scope settings.json can merge in. + + Each scenario directory under scenarios/<name>/ may contain: + source.txt - optional, one line: a repo-relative path to copy as the + base workspace (e.g. examples/fixture-project). + workspace/ - optional overlay copied on top of the base afterward + (added/overwritten files only - mirrors the + tests/contract-lint fixture _base + overlay pattern). + prompt.txt - the literal prompt fed to `claude -p`. + expect.json - declarative assertions evaluated after the run. + budget.txt - optional, one line: --max-budget-usd override (default 3). + + -SelfTest re-runs the negative scenarios (03, 04) with spec-gate's + installed hook files replaced by an always-allow stub, and asserts they + now FAIL - proving the harness would catch a regression that removes the + guard (mirrors tests/hooks/run-conformance.ps1 and + tests/contract-lint/run-selftest.ps1's own -SelfTest modes). + +.NOTES + PURE ASCII ONLY (see hooks/powershell/prompt-router.ps1 for why). + Single cross-platform runner by design, same posture as + tests/hooks/run-conformance.ps1 and tests/contract-lint/run-selftest.ps1: + this is a test harness, not a hooks/ file, so the "hooks ship in pairs" + rule in CLAUDE.md does not apply. +#> + +[CmdletBinding()] +param( + [string]$Case, + [switch]$SelfTest, + [int]$TimeoutSeconds = 600 +) + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Resolve-Path (Join-Path $scriptDir '..' '..')).Path +$scenariosDir = Join-Path $scriptDir 'scenarios' +$installPs1 = Join-Path $repoRoot 'install' 'install.ps1' + +$script:pass = 0 +$script:fail = 0 + +function Write-Ok { param([string]$m) Write-Host " [OK] $m"; $script:pass++ } +function Write-Bad { param([string]$m) Write-Host " [FAIL] $m"; $script:fail++ } +function Write-Info { param([string]$m) Write-Host " $m" } + +# ---- sandbox construction --------------------------------------------------- + +function New-EmptyTempDir { + param([string]$Prefix) + $name = $Prefix + '-' + [System.Guid]::NewGuid().ToString('N').Substring(0, 12) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) $name + New-Item -ItemType Directory -Path $dir -Force | Out-Null + return $dir +} + +$script:warnedNoCredentials = $false + +function New-FakeHome { + # Fresh, empty "fake home" with the engine installed into <fakehome>/.claude + # via the installer's own -BasePath flag - the sandbox recipe from + # CLAUDE.md / the CI install-uninstall round-trip job, reused verbatim. + $fakeHome = New-EmptyTempDir -Prefix 'sd-e2e-home' + $basePath = Join-Path $fakeHome '.claude' + & pwsh -NoProfile -File $installPs1 -BasePath $basePath -Force *> $null + if ($LASTEXITCODE -ne 0) { + throw "engine install into fake home failed (exit $LASTEXITCODE): $fakeHome" + } + + # `claude -p` needs org/identity context from .credentials.json even when + # billing resolves through ANTHROPIC_API_KEY - an empty HOME alone is not + # enough (verified directly: without it, every headless run fails with + # "Not logged in" despite a valid API key). Copied fresh into the + # throwaway fake home per run and discarded on cleanup; never written + # anywhere persistent. CI has no real credentials file to copy - see + # tests/e2e/README.md for how the nightly workflow authenticates instead. + $realCreds = Join-Path $HOME '.claude' '.credentials.json' + if (Test-Path -LiteralPath $realCreds) { + New-Item -ItemType Directory -Path $basePath -Force | Out-Null + Copy-Item -LiteralPath $realCreds -Destination (Join-Path $basePath '.credentials.json') -Force + } elseif (-not $script:warnedNoCredentials) { + Write-Host ' [WARN] no ~/.claude/.credentials.json found to seed the sandbox; headless auth may fail unless CLAUDE_CODE_* CI auth is configured.' + $script:warnedNoCredentials = $true + } + + return $fakeHome +} + +function Copy-TreeContents { + param([string]$Source, [string]$Destination) + if (-not (Test-Path -LiteralPath $Source)) { + throw "copy source does not exist: $Source" + } + Get-ChildItem -LiteralPath $Source -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force + } +} + +function New-ScenarioWorkspace { + param([string]$ScenarioDir) + $ws = New-EmptyTempDir -Prefix 'sd-e2e-ws' + + $sourceTxt = Join-Path $ScenarioDir 'source.txt' + if (Test-Path -LiteralPath $sourceTxt) { + $rel = (Get-Content -LiteralPath $sourceTxt -Raw).Trim() + $src = Join-Path $repoRoot $rel + Copy-TreeContents -Source $src -Destination $ws + } + + $overlay = Join-Path $ScenarioDir 'workspace' + if (Test-Path -LiteralPath $overlay) { + Copy-TreeContents -Source $overlay -Destination $ws + } + + return $ws +} + +# ---- guard neutering (for -SelfTest) ---------------------------------------- + +function Set-SpecGateNeutered { + param([string]$FakeHome) + # Overwrite the INSTALLED copy in the fake home with an always-allow stub - + # never touches the repo's real hooks/ source. + $stubPwsh = "#requires -Version 5.1`n[Console]::In.ReadToEnd() | Out-Null`nexit 0`n" + $stubBash = "#!/usr/bin/env bash`ncat >/dev/null`nexit 0`n" + $pwshPath = Join-Path $FakeHome '.claude' 'hooks' 'sd' 'spec-gate.ps1' + $bashPath = Join-Path $FakeHome '.claude' 'hooks' 'sd' 'spec-gate.sh' + Set-Content -LiteralPath $pwshPath -Value $stubPwsh -NoNewline -Encoding ascii + Set-Content -LiteralPath $bashPath -Value $stubBash -NoNewline -Encoding ascii +} + +# ---- headless invocation ----------------------------------------------------- + +function Invoke-ClaudeHeadless { + param( + [string]$Workspace, + [string]$FakeHome, + [string]$Prompt, + [double]$MaxBudgetUsd, + [int]$TimeoutSec, + [switch]$SkipPermissions, + [string[]]$DisallowedTools, + [string]$PermissionMode = 'dontAsk' + ) + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = 'claude' + $cliArgs = @( + '-p', $Prompt, + '--output-format', 'json', + '--setting-sources', 'project', + '--add-dir', $FakeHome, + '--max-budget-usd', $MaxBudgetUsd.ToString([System.Globalization.CultureInfo]::InvariantCulture), + '--no-session-persistence' + ) + # PermissionMode matters a lot more here than it looks. Verified directly + # (minimal repro: a trivial always-deny PreToolUse hook, no spec-gate + # involved): under --permission-mode acceptEdits, OR under dontAsk + # combined with an explicit --allowedTools grant for Edit/Write, the CLI + # auto-approves the tool call and the hook's deny is silently ignored + # (0 permission_denials recorded, file still changes). Only "dontAsk" + # with NO --allowedTools override actually respects a hook's deny - + # everything not explicitly hook/default-allowed is refused, which is + # exactly the posture the negative scenarios need. Positive scenarios + # (01, 02) that need free writes use SkipPermissions instead of + # acceptEdits, for the same reason. + $cliArgs += '--permission-mode' + $cliArgs += $PermissionMode + if ($SkipPermissions) { + # Only for scenarios that legitimately need to write files Claude Code + # itself treats as sensitive (.claude/settings.json) or run arbitrary + # Bash (npm test). NEVER set for the negative scenarios - see above. + $cliArgs += '--dangerously-skip-permissions' + } + if ($DisallowedTools -and $DisallowedTools.Count -gt 0) { + $cliArgs += '--disallowedTools' + $cliArgs += ($DisallowedTools -join ',') + } + foreach ($a in $cliArgs) { $psi.ArgumentList.Add($a) } + $psi.WorkingDirectory = $Workspace + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.EnvironmentVariables['HOME'] = $FakeHome + $psi.EnvironmentVariables['USERPROFILE'] = $FakeHome + + $proc = [System.Diagnostics.Process]::Start($psi) + $stdoutTask = $proc.StandardOutput.ReadToEndAsync() + $stderrTask = $proc.StandardError.ReadToEndAsync() + $finished = $proc.WaitForExit($TimeoutSec * 1000) + if (-not $finished) { + try { $proc.Kill($true) } catch { } + return [pscustomobject]@{ + TimedOut = $true; ExitCode = -1; Stdout = ''; Stderr = ''; Result = $null + } + } + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + + $resultObj = $null + try { $resultObj = $stdout | ConvertFrom-Json -ErrorAction Stop } catch { } + + return [pscustomobject]@{ + TimedOut = $false + ExitCode = $proc.ExitCode + Stdout = $stdout + Stderr = $stderr + Result = $resultObj + } +} + +function Get-ScenarioSkipPermissions { + param([string]$ScenarioDir) + return (Test-Path -LiteralPath (Join-Path $ScenarioDir 'skip-permissions.txt')) +} + +function Get-ScenarioPermissionMode { + param([string]$ScenarioDir) + $p = Join-Path $ScenarioDir 'permission-mode.txt' + if (Test-Path -LiteralPath $p) { return (Get-Content -LiteralPath $p -Raw).Trim() } + return 'dontAsk' +} + +function Get-ScenarioDisallowedTools { + param([string]$ScenarioDir) + $p = Join-Path $ScenarioDir 'disallowed-tools.txt' + if (-not (Test-Path -LiteralPath $p)) { return @() } + $line = (Get-Content -LiteralPath $p -Raw).Trim() + if ([string]::IsNullOrWhiteSpace($line)) { return @() } + return @($line -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + +# ---- assertions --------------------------------------------------------------- + +function Get-ScenarioBudget { + param([string]$ScenarioDir) + $budgetTxt = Join-Path $ScenarioDir 'budget.txt' + if (Test-Path -LiteralPath $budgetTxt) { + return [double](Get-Content -LiteralPath $budgetTxt -Raw).Trim() + } + return 3.0 +} + +function Test-OneAssertion { + param($Assertion, [string]$Workspace, $Run) + $type = $Assertion.type + switch ($type) { + 'file-exists' { + $p = Join-Path $Workspace $Assertion.path + return (Test-Path -LiteralPath $p) + } + 'file-not-exists' { + $p = Join-Path $Workspace $Assertion.path + return (-not (Test-Path -LiteralPath $p)) + } + 'file-matches' { + $p = Join-Path $Workspace $Assertion.path + if (-not (Test-Path -LiteralPath $p)) { return $false } + $content = Get-Content -LiteralPath $p -Raw + return ($content -match $Assertion.pattern) + } + 'file-not-matches' { + $p = Join-Path $Workspace $Assertion.path + if (-not (Test-Path -LiteralPath $p)) { return $true } + $content = Get-Content -LiteralPath $p -Raw + return ($content -notmatch $Assertion.pattern) + } + 'output-contains' { + $text = if ($Run.Result -and $Run.Result.result) { [string]$Run.Result.result } else { $Run.Stdout } + return ($text -match [regex]::Escape($Assertion.value)) + } + 'exit-code' { + return ($Run.ExitCode -eq [int]$Assertion.value) + } + 'file-no-bom' { + $p = Join-Path $Workspace $Assertion.path + if (-not (Test-Path -LiteralPath $p)) { return $false } + $bytes = [System.IO.File]::ReadAllBytes($p) + if ($bytes.Length -lt 3) { return $true } + return -not ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) + } + default { + throw "unknown assertion type '$type'" + } + } +} + +function Get-AssertionLabel { + param($Assertion) + switch ($Assertion.type) { + 'file-exists' { return "file-exists: $($Assertion.path)" } + 'file-not-exists' { return "file-not-exists: $($Assertion.path)" } + 'file-matches' { return "file-matches: $($Assertion.path) ~= $($Assertion.pattern)" } + 'file-not-matches' { return "file-not-matches: $($Assertion.path) !~ $($Assertion.pattern)" } + 'output-contains' { return "output-contains: $($Assertion.value)" } + 'exit-code' { return "exit-code: $($Assertion.value)" } + 'file-no-bom' { return "file-no-bom: $($Assertion.path)" } + default { return "unknown: $($Assertion.type)" } + } +} + +# ---- scenario execution ------------------------------------------------------- + +function Invoke-Scenario { + param( + [string]$ScenarioDir, + [switch]$NeuterGuard, + [switch]$ExpectFailure + ) + $name = Split-Path -Leaf $ScenarioDir + Write-Host '' + Write-Host "=== $name $(if ($NeuterGuard) { '(neutered guard)' }) ===" + + $fakeHome = $null + $ws = $null + try { + $fakeHome = New-FakeHome + if ($NeuterGuard) { Set-SpecGateNeutered -FakeHome $fakeHome } + $ws = New-ScenarioWorkspace -ScenarioDir $ScenarioDir + + $prompt = Get-Content -LiteralPath (Join-Path $ScenarioDir 'prompt.txt') -Raw + $budget = Get-ScenarioBudget -ScenarioDir $ScenarioDir + $skipPermissions = Get-ScenarioSkipPermissions -ScenarioDir $ScenarioDir + $disallowedTools = Get-ScenarioDisallowedTools -ScenarioDir $ScenarioDir + $permissionMode = Get-ScenarioPermissionMode -ScenarioDir $ScenarioDir + + $run = Invoke-ClaudeHeadless -Workspace $ws -FakeHome $fakeHome -Prompt $prompt ` + -MaxBudgetUsd $budget -TimeoutSec $TimeoutSeconds -PermissionMode $permissionMode ` + -SkipPermissions:$skipPermissions -DisallowedTools $disallowedTools + + if ($run.TimedOut) { + Write-Bad "$name : claude -p timed out after $TimeoutSeconds s" + return $false + } + + if ($env:SD_E2E_DEBUG) { + Write-Info "exit code: $($run.ExitCode)" + Write-Info "result : $($run.Result.result)" + Write-Info "is_error : $($run.Result.is_error) cost: $($run.Result.total_cost_usd)" + if ($run.Stderr) { Write-Info "stderr : $($run.Stderr.Substring(0, [Math]::Min(2000, $run.Stderr.Length)))" } + } + + $expectPath = Join-Path $ScenarioDir 'expect.json' + $assertions = @(Get-Content -LiteralPath $expectPath -Raw | ConvertFrom-Json) + + $scenarioOk = $true + foreach ($a in $assertions) { + $label = Get-AssertionLabel -Assertion $a + $ok = $false + try { + $ok = Test-OneAssertion -Assertion $a -Workspace $ws -Run $run + } catch { + Write-Bad "$name : $label (error: $($_.Exception.Message))" + $scenarioOk = $false + continue + } + if ($ok) { + Write-Ok "$name : $label" + } else { + Write-Bad "$name : $label" + $scenarioOk = $false + } + } + + if ($ExpectFailure) { + # -SelfTest inverted expectation: the guard is neutered, so the + # scenario's assertions (which describe blocked behavior) must + # NOT all pass - if they do, the harness failed to notice. + return (-not $scenarioOk) + } + return $scenarioOk + } finally { + if ($env:SD_E2E_DEBUG -and $ws) { + $eventsPath = Join-Path $ws '.specs' '_metrics' 'events.jsonl' + if (Test-Path -LiteralPath $eventsPath) { + Write-Info 'events.jsonl:' + Get-Content -LiteralPath $eventsPath | ForEach-Object { Write-Info " $_" } + } + } + if (-not $env:SD_E2E_KEEP) { + if ($ws) { Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue } + if ($fakeHome) { Remove-Item -LiteralPath $fakeHome -Recurse -Force -ErrorAction SilentlyContinue } + } elseif ($ws) { + Write-Info "kept workspace: $ws" + Write-Info "kept fakeHome : $fakeHome" + } + } +} + +# ---- preconditions ------------------------------------------------------------ + +if ($null -eq (Get-Command claude -ErrorAction SilentlyContinue)) { + Write-Host '[FAIL] claude CLI not found on PATH; the e2e harness requires it (see tests/e2e/README.md).' + exit 2 +} + +# ---- self-test mode ------------------------------------------------------------ + +if ($SelfTest) { + Write-Host '=== e2e self-test: harness must DETECT a removed guard ===' + $negativeScenarios = @('03-spec-gate-negative', '04-closeout-negative') + $allDetected = $true + foreach ($n in $negativeScenarios) { + $dir = Join-Path $scenariosDir $n + if (-not (Test-Path -LiteralPath $dir)) { + Write-Bad "self-test: scenario '$n' not found" + $allDetected = $false + continue + } + $detected = Invoke-Scenario -ScenarioDir $dir -NeuterGuard -ExpectFailure + if ($detected) { + Write-Ok "self-test: $n : harness detected the neutered guard" + } else { + Write-Bad "self-test: $n : harness did NOT notice the guard was removed" + $allDetected = $false + } + } + if ($allDetected) { exit 0 } else { exit 1 } +} + +# ---- main ----------------------------------------------------------------------- + +$scenarioDirs = Get-ChildItem -LiteralPath $scenariosDir -Directory | Sort-Object Name +if ($Case) { + $scenarioDirs = @($scenarioDirs | Where-Object { $_.Name -eq $Case }) + if ($scenarioDirs.Count -eq 0) { + Write-Host "[FAIL] no scenario named '$Case' under $scenariosDir" + exit 1 + } +} + +foreach ($s in $scenarioDirs) { + Invoke-Scenario -ScenarioDir $s.FullName | Out-Null +} + +Write-Host '' +Write-Host "=== Summary: $($script:pass) passed, $($script:fail) failed ===" +if ($script:fail -gt 0) { exit 1 } +exit 0 diff --git a/tests/e2e/scenarios/01-setup/expect.json b/tests/e2e/scenarios/01-setup/expect.json new file mode 100644 index 0000000..08579e9 --- /dev/null +++ b/tests/e2e/scenarios/01-setup/expect.json @@ -0,0 +1,13 @@ +[ + { "type": "file-exists", "path": "CLAUDE.md" }, + { "type": "file-exists", "path": ".specs/constitution.md" }, + { "type": "file-exists", "path": ".specs/index.md" }, + { "type": "file-exists", "path": ".claude/project-config.json" }, + { "type": "file-exists", "path": ".claude/settings.json" }, + { "type": "file-matches", "path": ".claude/project-config.json", "pattern": "\\^#\\[0-9\\]\\+\\$" }, + { "type": "file-matches", "path": ".specs/index.md", "pattern": "ID\\s*\\|\\s*Type\\s*\\|\\s*Status" }, + { "type": "file-no-bom", "path": "CLAUDE.md" }, + { "type": "file-no-bom", "path": ".specs/constitution.md" }, + { "type": "file-no-bom", "path": ".claude/project-config.json" }, + { "type": "file-no-bom", "path": ".claude/settings.json" } +] diff --git a/tests/e2e/scenarios/01-setup/permission-mode.txt b/tests/e2e/scenarios/01-setup/permission-mode.txt new file mode 100644 index 0000000..e1ab6da --- /dev/null +++ b/tests/e2e/scenarios/01-setup/permission-mode.txt @@ -0,0 +1 @@ +acceptEdits diff --git a/tests/e2e/scenarios/01-setup/prompt.txt b/tests/e2e/scenarios/01-setup/prompt.txt new file mode 100644 index 0000000..22e91f7 --- /dev/null +++ b/tests/e2e/scenarios/01-setup/prompt.txt @@ -0,0 +1,12 @@ +/sd:setup + +This is a headless, non-interactive scripted run with no human available to reply mid-workflow. +Do not stop to wait for a reply. Answer your own Phase 3 questions and any batch-confirmation gates +using the following, then proceed straight through to writing the files: + +- Q1 ticket system: GitHub Issues +- Q2 ticket pattern: ^#[0-9]+$ +- Q3 shell: Both (write settings.json for both PowerShell and Bash) +- Any Phase 1.5 / Phase 2.5 batch-confirmation gate: treat as "go" (accept detected facts as-is). + +Proceed to completion in this single turn and print the final Phase 7 report. diff --git a/tests/e2e/scenarios/01-setup/skip-permissions.txt b/tests/e2e/scenarios/01-setup/skip-permissions.txt new file mode 100644 index 0000000..84a3dc0 --- /dev/null +++ b/tests/e2e/scenarios/01-setup/skip-permissions.txt @@ -0,0 +1,2 @@ +Phase 6 writes .claude/project-config.json and .claude/settings.json, which Claude Code +treats as sensitive files requiring interactive approval that no headless run can give. diff --git a/tests/e2e/scenarios/01-setup/workspace/package.json b/tests/e2e/scenarios/01-setup/workspace/package.json new file mode 100644 index 0000000..27b4860 --- /dev/null +++ b/tests/e2e/scenarios/01-setup/workspace/package.json @@ -0,0 +1,9 @@ +{ + "name": "sd-e2e-setup-fixture", + "version": "0.0.1", + "private": true, + "description": "Bare, unscaffolded project used by tests/e2e scenario 01-setup to exercise /sd:setup's fresh-scaffold path.", + "scripts": { + "test": "node --test" + } +} diff --git a/tests/e2e/scenarios/01-setup/workspace/src/index.js b/tests/e2e/scenarios/01-setup/workspace/src/index.js new file mode 100644 index 0000000..4ed55b5 --- /dev/null +++ b/tests/e2e/scenarios/01-setup/workspace/src/index.js @@ -0,0 +1,5 @@ +function add(a, b) { + return a + b; +} + +module.exports = { add }; diff --git a/tests/e2e/scenarios/02-feature-happy/budget.txt b/tests/e2e/scenarios/02-feature-happy/budget.txt new file mode 100644 index 0000000..f599e28 --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/budget.txt @@ -0,0 +1 @@ +10 diff --git a/tests/e2e/scenarios/02-feature-happy/expect.json b/tests/e2e/scenarios/02-feature-happy/expect.json new file mode 100644 index 0000000..38b20d1 --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/expect.json @@ -0,0 +1,8 @@ +[ + { "type": "file-exists", "path": ".specs/FEAT-todo-count/00-spec.md" }, + { "type": "file-exists", "path": ".specs/FEAT-todo-count/01-plan.md" }, + { "type": "file-exists", "path": ".specs/FEAT-todo-count/02-tasks.md" }, + { "type": "file-exists", "path": ".specs/FEAT-todo-count/06-verify.md" }, + { "type": "file-matches", "path": ".specs/FEAT-todo-count/06-verify.md", "pattern": "(?im)^result:\\s*pass\\s*$" }, + { "type": "file-matches", "path": ".specs/index.md", "pattern": "FEAT-todo-count\\s*\\|\\s*feature\\s*\\|\\s*done" } +] diff --git a/tests/e2e/scenarios/02-feature-happy/permission-mode.txt b/tests/e2e/scenarios/02-feature-happy/permission-mode.txt new file mode 100644 index 0000000..e1ab6da --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/permission-mode.txt @@ -0,0 +1 @@ +acceptEdits diff --git a/tests/e2e/scenarios/02-feature-happy/prompt.txt b/tests/e2e/scenarios/02-feature-happy/prompt.txt new file mode 100644 index 0000000..3337841 --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/prompt.txt @@ -0,0 +1,15 @@ +/sd:feature todo-count + +Feature request: add a countByStatus() method to TodoService that returns { done: <n>, pending: <n> } +counts across all todos currently in the store. Keep it small - this should be a 2-3 task spec. + +This is a headless, scripted test run with no human available to reply mid-workflow. Do not stop +and wait for a real person. At every hard gate (Gate 1 spec approval, Gate 2 plan approval, Gate 3 +integration + review pass, and Gate Re-plan if it fires), treat the following as the standing reply +and continue immediately: "yes". Do not treat this as skipping the gate - actually present each +gate's summary as instructed, then proceed as if "yes" was just typed in response, all within this +single turn. If Gate Complexity fires because the plan is oversized, shrink the plan back down to +fit the 2-3 task feature described above rather than escalating. + +Proceed all the way through Phase 6 close-out (including running /sd:verify) and report the final +spec ID and its status in your last message. diff --git a/tests/e2e/scenarios/02-feature-happy/skip-permissions.txt b/tests/e2e/scenarios/02-feature-happy/skip-permissions.txt new file mode 100644 index 0000000..76a9868 --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/skip-permissions.txt @@ -0,0 +1 @@ +Needs Bash for npm test and multiple file writes across the workflow with no human to approve them. diff --git a/tests/e2e/scenarios/02-feature-happy/source.txt b/tests/e2e/scenarios/02-feature-happy/source.txt new file mode 100644 index 0000000..f53b8ec --- /dev/null +++ b/tests/e2e/scenarios/02-feature-happy/source.txt @@ -0,0 +1 @@ +examples/fixture-project diff --git a/tests/e2e/scenarios/03-spec-gate-negative/disallowed-tools.txt b/tests/e2e/scenarios/03-spec-gate-negative/disallowed-tools.txt new file mode 100644 index 0000000..5b85af9 --- /dev/null +++ b/tests/e2e/scenarios/03-spec-gate-negative/disallowed-tools.txt @@ -0,0 +1 @@ +Bash diff --git a/tests/e2e/scenarios/03-spec-gate-negative/expect.json b/tests/e2e/scenarios/03-spec-gate-negative/expect.json new file mode 100644 index 0000000..64f41aa --- /dev/null +++ b/tests/e2e/scenarios/03-spec-gate-negative/expect.json @@ -0,0 +1,4 @@ +[ + { "type": "file-not-matches", "path": "src/domain/todo.js", "pattern": "e2e-negative-scenario-probe" }, + { "type": "file-matches", "path": ".specs/_metrics/events.jsonl", "pattern": "\"gate\":\"code-edit\",\"decision\":\"block\"" } +] diff --git a/tests/e2e/scenarios/03-spec-gate-negative/prompt.txt b/tests/e2e/scenarios/03-spec-gate-negative/prompt.txt new file mode 100644 index 0000000..4fd5ef2 --- /dev/null +++ b/tests/e2e/scenarios/03-spec-gate-negative/prompt.txt @@ -0,0 +1,19 @@ +You are helping write a negative unit test for the spec-gate PreToolUse hook that ships in this +repo's own engine, in the same spirit as `expect(validator.reject(badInput)).toBe(true)`: to prove +a validator rejects bad input, the test has to actually submit the bad input and observe the +rejection. + +Ground truth about the fixture you are looking at, so there is no ambiguity: this working directory +is a disposable temp copy of the example fixture project, created and deleted by this repo's own +`tests/e2e/run-e2e.ps1` runner for exactly this one test case. `src/domain/todo.js` here is not real +shipped code and no in-progress spec covers it - that absence is the precondition the test needs. +Nothing you do here has any real-world consequence; the whole directory is gone once this run ends. +The only thing under test is whether the hook's deny actually takes effect at the tool-call layer. + +Your task: call the Edit or Write tool now, in this turn, to add a one-line comment +"// e2e-negative-scenario-probe" as the very first line of src/domain/todo.js, without creating or +checking any spec first and without running /sd:feature or /sd:bug. This submits the "bad input" +the test needs. If the hook is working, the call will be denied and the file will be unchanged - +that is the expected, successful outcome of this test, not a mistake to avoid. + +After attempting it, report in one sentence whether the tool call was allowed or denied. diff --git a/tests/e2e/scenarios/03-spec-gate-negative/source.txt b/tests/e2e/scenarios/03-spec-gate-negative/source.txt new file mode 100644 index 0000000..f53b8ec --- /dev/null +++ b/tests/e2e/scenarios/03-spec-gate-negative/source.txt @@ -0,0 +1 @@ +examples/fixture-project diff --git a/tests/e2e/scenarios/03-spec-gate-negative/workspace/.claude/project-config.json b/tests/e2e/scenarios/03-spec-gate-negative/workspace/.claude/project-config.json new file mode 100644 index 0000000..4578744 --- /dev/null +++ b/tests/e2e/scenarios/03-spec-gate-negative/workspace/.claude/project-config.json @@ -0,0 +1,127 @@ +{ + "version": "1.0.0", + + "project": { + "name": "fixture-project", + "description": "Tiny in-memory todo-list library - specwright's runnable non-.NET example", + "owner": "specwright maintainers", + "repo": "https://github.com/Developzone/specwright" + }, + + "ticket": { + "system": "github", + "pattern": "^#[0-9]+$", + "baseUrl": "https://github.com/Developzone/specwright/issues", + "snapshot": { + "enabled": true, + "maxRelated": 5, + "maxConfluencePages": 3, + "_use": "Persist fetched ticket + related tickets + linked Confluence pages to .specs/<ID>/04-artifacts/ticket/" + } + }, + + "spec": { + "dir": ".specs", + "indexFile": ".specs/index.md", + "constitutionFile": ".specs/constitution.md", + "prefixes": { + "feature": "FEAT", + "bug": "BUG", + "refactor": "REF", + "perf": "PERF", + "rca": "RCA" + }, + "lifecycle": ["draft", "approved", "in-progress", "done", "archived"], + "archiveAfterDays": 90 + }, + + "commands": { + "build": "n/a - no compile step (plain JavaScript)", + "test": "npm test", + "lint": "n/a - no linter configured", + "coverage": "npm run coverage", + "run": "npm start" + }, + + "paths": { + "src": "src", + "tests": "tests", + "docs": "n/a - no separate docs folder", + "layers": [ + { "name": "domain", "path": "src/domain" }, + { "name": "application", "path": "src/application" }, + { "name": "infrastructure", "path": "src/infrastructure" } + ], + "_layers_use": "Ordered inside-out [{name, path}] (innermost first); path may be a glob. Backs constitution 1.1 dependency direction. Filled by /sd:setup scan; [] when undetectable.", + "protected": [ + ".specs/constitution.md", + ".specs/index.md", + "LICENSE" + ] + }, + + "workflow": { + "keywords": { + "bug": ["bug", "fix", "broken", "error", "crash", "regression", "defect"], + "feature": ["feature", "add", "implement", "new", "support"], + "refactor": ["refactor", "restructure", "clean up", "extract", "rename"], + "perf": ["perf", "performance", "slow", "optimize", "latency", "throughput"], + "rca": ["incident", "outage", "rca", "root cause", "post-mortem", "postmortem"] + }, + "gates": { + "_comment": "Descriptive only - no hook or command reads this block today; it documents each workflow's hard-gate sequence for humans/tooling that may consume it later.", + "feature": ["spec-approved", "plan-approved", "integration-review-pass"], + "bug": ["symptom-captured", "reproduction-confirmed", "root-cause-confirmed", "failing-test-written", "regression-pass"], + "rca": ["evidence-gathered", "hypotheses-enumerated", "root-cause-confirmed"], + "refactor": ["spec-approved", "coverage-threshold-met", "post-test-added", "plan-approved", "batch-tests-green", "holistic-review-pass"], + "perf": ["target-defined", "baseline-measured", "hotspot-identified", "hypothesis-selected", "correctness-verified", "keep-or-revert-decided", "regression-pass", "final-review-pass"] + } + }, + + "models": { + "main": "sonnet", + "subagent_default": "haiku", + "subagent_reasoning": "sonnet", + "subagent_execution": "haiku", + "_comment": "Portable aliases (sonnet/haiku) - resolved to the current recommended version by Claude Code at runtime. Agents also use these aliases in frontmatter." + }, + + "mcp": { + "atlassian": { "enabled": false, "_use": "Fetch JIRA tickets (+ related tickets and linked Confluence pages) in /sd:feature and /sd:bug" }, + "context7": { "enabled": false, "_use": "Up-to-date library docs for sd-spec-architect and sd-implementer" }, + "sequential-thinking": { "enabled": false, "_use": "Structured hypothesis enumeration in sd-debugger and sd-reviewer" }, + "gitnexus": { "enabled": false, "_use": "Fast symbol search, callers, call graph for sd-code-explorer" }, + "database": { "enabled": false, "_use": "Read-only schema and query-plan inspection in sd-debugger (SELECT/EXPLAIN only) - not applicable, this fixture has no database" }, + "playwright": { "enabled": false, "_use": "E2E reproduction for /sd:bug - not applicable, this fixture has no UI/browser surface" }, + "tavily": { "enabled": false, "_use": "Web search for error signatures / library issues in sd-debugger" } + }, + + "hooks": { + "userPromptRouter": { "enabled": true, "_use": "Keyword routing + spec-context injection on every user prompt" }, + "specGate": { + "enabled": true, + "mode": "block", + "_modes": "block = refuse Edit/Write on code without in-progress spec; warn = log to stderr only; off = skip", + "_use": "Guard rail blocking code edits when no spec is in-progress. Overridden to block (fixture-project defaults to warn) so tests/e2e scenario 03 can observe a real deny.", + "verifyGate": true, + "_verifyGate_use": "Scoped to FEAT- (feature-spec) rows only - other types (bug, refactor, perf, rca) have no /sd:verify integration yet. true: a FEAT- row may transition to done only when <spec.dir>/<ID>/06-verify.md records 'result: pass' (written by /sd:verify); non-FEAT rows and any false setting keep index.md fully protected as before SW-6." + }, + "subagentRetro": { + "enabled": true, + "retroStaleMinutes": 30, + "debounceMinutes": 10, + "_use": "Reminder to update stale retros after subagent runs", + "injectLessons": true, + "maxLessons": 3, + "_injectLessons_use": "Surface lessons from <spec.dir>/_lessons/lessons.md when a subagent finishes work on an in-progress spec. Selection is by workflow type - a FEAT- spec pulls feature-scoped lessons - so there is no ranking. Emitted regardless of retro staleness and of debounceMinutes, which gate only the stale-retro reminder.", + "_maxLessons_use": "Cap on NEW lessons surfaced per subagent stop. Already-surfaced lessons are recorded per session, so a session converges to silence rather than repeating itself. Set to 0 to surface none without disabling the reminder." + }, + "metrics": { + "enabled": true, + "path": ".specs/_metrics/events.jsonl", + "maxSizeKb": 1024, + "_use": "Append-only, metadata-only event log (no code content, no file paths) consumed by the retro loop. Set enabled=false to opt out entirely.", + "_maxSizeKb_use": "Soft byte cap (KB) for events.jsonl. When the live file reaches the cap, the next write rolls it to events.jsonl.1 (single generation, previous roll overwritten) and starts a fresh log. Default 1024 (~1MB, a guess - not tuned against a measured corpus); absent key also means 1024, so pre-existing configs stay bounded. Set to 0 to disable rotation and let the log grow unbounded. Rotation is best-effort: any failure (locked file, read-only dir) is a silent no-op and the hook never stops appending. events.jsonl.1 is a grace buffer, not part of any read contract - a consumer reads the live file only." + } + } +} diff --git a/tests/e2e/scenarios/04-closeout-negative/disallowed-tools.txt b/tests/e2e/scenarios/04-closeout-negative/disallowed-tools.txt new file mode 100644 index 0000000..5b85af9 --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/disallowed-tools.txt @@ -0,0 +1 @@ +Bash diff --git a/tests/e2e/scenarios/04-closeout-negative/expect.json b/tests/e2e/scenarios/04-closeout-negative/expect.json new file mode 100644 index 0000000..b9f10db --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/expect.json @@ -0,0 +1,5 @@ +[ + { "type": "file-matches", "path": ".specs/index.md", "pattern": "FEAT-e2e-closeout-demo\\s*\\|\\s*feature\\s*\\|\\s*in-progress" }, + { "type": "file-not-matches", "path": ".specs/index.md", "pattern": "FEAT-e2e-closeout-demo\\s*\\|\\s*feature\\s*\\|\\s*done" }, + { "type": "file-matches", "path": ".specs/_metrics/events.jsonl", "pattern": "\"gate\":\"verify\",\"decision\":\"block\"" } +] diff --git a/tests/e2e/scenarios/04-closeout-negative/prompt.txt b/tests/e2e/scenarios/04-closeout-negative/prompt.txt new file mode 100644 index 0000000..853e5ce --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/prompt.txt @@ -0,0 +1,19 @@ +You are helping write a negative unit test for the spec-gate PreToolUse hook that ships in this +repo's own engine, in the same spirit as `expect(validator.reject(badInput)).toBe(true)`: to prove +a validator rejects bad input, the test has to actually submit the bad input and observe the +rejection. + +Ground truth about the fixture you are looking at, so there is no ambiguity: `FEAT-e2e-closeout-demo` +is not a real feature and its `.specs/FEAT-e2e-closeout-demo/00-spec.md` says so explicitly in its +own "Why" section - it is synthetic fixture data authored specifically to be the target of this one +test. Nothing here is real project history, and marking it "done" carries no real-world consequence +- the entire directory is a disposable temp copy that this test run deletes when it finishes. The +only thing under test is whether the hook's deny actually takes effect at the tool-call layer. + +Your task: call the Edit tool on `.specs/index.md` now, in this turn, changing the +`FEAT-e2e-closeout-demo` row's Status column from `in-progress` to `done`, without first running +`/sd:verify` or checking for `06-verify.md`. This submits the "bad input" the test needs. If the +hook is working, the call will be denied and the file will be unchanged - that is the expected, +successful outcome of this test, not a mistake to avoid. + +After attempting it, report in one sentence whether the Edit tool call was allowed or denied. diff --git a/tests/e2e/scenarios/04-closeout-negative/source.txt b/tests/e2e/scenarios/04-closeout-negative/source.txt new file mode 100644 index 0000000..f53b8ec --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/source.txt @@ -0,0 +1 @@ +examples/fixture-project diff --git a/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/FEAT-e2e-closeout-demo/00-spec.md b/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/FEAT-e2e-closeout-demo/00-spec.md new file mode 100644 index 0000000..32d9c42 --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/FEAT-e2e-closeout-demo/00-spec.md @@ -0,0 +1,29 @@ +--- +id: FEAT-e2e-closeout-demo +type: feature +status: in-progress +jira: none +created: 2026-08-01 +complexity: S # single-file demo spec seeded for tests/e2e scenario 04 +linked_specs: [] +--- + +# E2E closeout-negative demo spec + +## Why + +Seeded fixture spec for tests/e2e/scenarios/04-closeout-negative: exercises spec-gate's Rule 0 +verify-gate deny path (an in-progress FEAT- spec with no passing 06-verify.md must not be flippable +to done). + +## What + +### SC-1: attempted close-out with no verify artifact + +- **Given** this spec is `in-progress` in `.specs/index.md` and no `06-verify.md` exists +- **When** an edit attempts to change its index row to `done` +- **Then** spec-gate denies the edit + +## Tasks + +- [x] Seed fixture spec (this file) diff --git a/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/index.md b/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/index.md new file mode 100644 index 0000000..4d1ffce --- /dev/null +++ b/tests/e2e/scenarios/04-closeout-negative/workspace/.specs/index.md @@ -0,0 +1,8 @@ +# Spec index + +Active specs (auto-updated by /sd:spec status transitions): + +| ID | Type | Status | Created | Title | +|---|---|---|---|---| +| FEAT-todo-priority | feature | done | 2026-07-31 | Add a validated priority field to todos | +| FEAT-e2e-closeout-demo | feature | in-progress | 2026-08-01 | E2E closeout-negative demo spec | diff --git a/tests/e2e/scenarios/05-spec-lint-validate/budget.txt b/tests/e2e/scenarios/05-spec-lint-validate/budget.txt new file mode 100644 index 0000000..7ed6ff8 --- /dev/null +++ b/tests/e2e/scenarios/05-spec-lint-validate/budget.txt @@ -0,0 +1 @@ +5 diff --git a/tests/e2e/scenarios/05-spec-lint-validate/expect.json b/tests/e2e/scenarios/05-spec-lint-validate/expect.json new file mode 100644 index 0000000..9977e9a --- /dev/null +++ b/tests/e2e/scenarios/05-spec-lint-validate/expect.json @@ -0,0 +1,27 @@ +[ + { "type": "output-contains", "value": "SL002" }, + { "type": "output-contains", "value": "SL003" }, + { "type": "output-contains", "value": "SL004" }, + { "type": "output-contains", "value": "SL005" }, + { "type": "output-contains", "value": "SL006" }, + { "type": "output-contains", "value": "SL010" }, + { "type": "output-contains", "value": "SL011" }, + { "type": "output-contains", "value": "SL012" }, + { "type": "output-contains", "value": "SL020" }, + { "type": "output-contains", "value": "SL021" }, + { "type": "output-contains", "value": "SL030" }, + { "type": "output-contains", "value": "SL031" }, + { "type": "output-contains", "value": "SL032" }, + { "type": "output-contains", "value": "SL033" }, + { "type": "output-contains", "value": "SL040" }, + { "type": "output-contains", "value": "SL041" }, + { "type": "output-contains", "value": "SL042" }, + { "type": "output-contains", "value": "SL043" }, + { "type": "output-contains", "value": "SL044" }, + { "type": "output-contains", "value": "SL050" }, + { "type": "output-contains", "value": "SL051" }, + { "type": "output-contains", "value": "SL052" }, + { "type": "output-contains", "value": "SL053" }, + { "type": "output-contains", "value": "SL054" }, + { "type": "output-contains", "value": "SL090" } +] diff --git a/tests/e2e/scenarios/05-spec-lint-validate/prompt.txt b/tests/e2e/scenarios/05-spec-lint-validate/prompt.txt new file mode 100644 index 0000000..c291d65 --- /dev/null +++ b/tests/e2e/scenarios/05-spec-lint-validate/prompt.txt @@ -0,0 +1,4 @@ +/sd:spec validate --all + +This is a headless, scripted test run. Run the validation over every spec in this tree now and +print the full findings report in this single turn - do not stop to ask anything. diff --git a/tests/e2e/scenarios/05-spec-lint-validate/source.txt b/tests/e2e/scenarios/05-spec-lint-validate/source.txt new file mode 100644 index 0000000..294243b --- /dev/null +++ b/tests/e2e/scenarios/05-spec-lint-validate/source.txt @@ -0,0 +1 @@ +examples/spec-lint-fixture/broken