From 7aaf87cf2514d1f6725d13766dec1112a5d074a2 Mon Sep 17 00:00:00 2001 From: Trong Tran Date: Wed, 29 Jul 2026 11:51:13 +0700 Subject: [PATCH] SW-26: add Check 8 cross-file contract lint (wave 1) The engine's shell was heavily verified; its product - the command prompts, agents and skills - had zero automated verification. Every relationship between those files was asserted in prose and checked by human review, and that gap had already shipped statically-detectable defects. Check 7 closed the inventory drift class. This closes the contract drift class: 17 rules across CL0xx reference resolution, CL3xx gate integrity and CL9xx suppression hygiene, as twin pure-file-ops scripts that emit TSV and nothing else. Severity lives only in the manifest registry, so a BLOCK/WARN divergence between the two implementations is structurally impossible, and a registry parity guard in each linter exits 2 when the rules it dispatches and the registry disagree. Gate counts become published claims: the manifest seeds Check 7 quantities, giving README <- manifest there and manifest <- disk in CL302, hence transitively README == disk with no gate parser duplicated into validate. Two real violations were fixed before the linter landed, so the first green run is distinguishable from a linter that never fires: architecture.md listed four items against a count of three, and a setup gate had no literal STOP. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 + CHANGELOG.md | 26 + CLAUDE.md | 14 +- CONTRIBUTING.md | 49 + commands/adr.md | 4 +- commands/perf.md | 2 + commands/setup.md | 9 +- docs/architecture.md | 2 +- docs/contract-lint.md | 185 ++++ scripts/contract-lint.ps1 | 804 ++++++++++++++++ scripts/contract-lint.sh | 899 ++++++++++++++++++ scripts/validate.ps1 | 81 +- scripts/validate.sh | 74 +- specwright.manifest.json | 140 ++- tests/contract-lint/.gitattributes | 9 + tests/contract-lint/README.md | 104 ++ .../fixtures/_base/agents/keeper.md | 16 + .../fixtures/_base/commands/alpha.md | 34 + .../fixtures/_base/commands/beta.md | 14 + .../_base/skills/sd-demo-rule/SKILL.md | 3 + .../fixtures/_base/specwright.manifest.json | 149 +++ .../fixtures/_base/templates/demo.template.md | 3 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../expected.json | 12 + .../overlay/agents/keeper.md | 18 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../expected.json | 11 + .../overlay/skills/sd-orphan-rule/SKILL.md | 4 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../expected.json | 11 + .../overlay/agents/hermit.md | 12 + .../cl008-unknown-spec-artifact/expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../cl300-gate-without-stop/expected.json | 12 + .../overlay/commands/alpha.md | 41 + .../overlay/specwright.manifest.json | 149 +++ .../cl301-gate-without-options/expected.json | 12 + .../overlay/commands/alpha.md | 39 + .../overlay/specwright.manifest.json | 149 +++ .../cl302-gate-count-disagrees/expected.json | 11 + .../overlay/commands/alpha.md | 41 + .../cl303-gate-numbering-gap/expected.json | 11 + .../overlay/commands/alpha.md | 34 + .../expected.json | 18 + .../overlay/commands/alpha.md | 41 + .../overlay/specwright.manifest.json | 151 +++ .../expected.json | 12 + .../overlay/commands/alpha.md | 35 + .../expected.json | 12 + .../overlay/commands/alpha.md | 38 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../expected.json | 12 + .../overlay/commands/alpha.md | 37 + .../fixtures/clean/expected.json | 4 + .../fp-bold-pseudo-gate/expected.json | 4 + .../overlay/commands/alpha.md | 36 + .../fp-gate-activity-heading/expected.json | 4 + .../overlay/commands/alpha.md | 38 + .../fp-hard-gate-prose-escape/expected.json | 4 + .../overlay/commands/alpha.md | 39 + .../fixtures/fp-phase0-stop/expected.json | 12 + .../fp-phase0-stop/overlay/commands/alpha.md | 41 + .../overlay/specwright.manifest.json | 149 +++ .../fp-substep-before-parent/expected.json | 4 + .../overlay/commands/alpha.md | 40 + .../overlay/specwright.manifest.json | 151 +++ tests/contract-lint/run-selftest.ps1 | 464 +++++++++ 73 files changed, 4816 insertions(+), 25 deletions(-) create mode 100644 docs/contract-lint.md create mode 100644 scripts/contract-lint.ps1 create mode 100644 scripts/contract-lint.sh create mode 100644 tests/contract-lint/.gitattributes create mode 100644 tests/contract-lint/README.md create mode 100644 tests/contract-lint/fixtures/_base/agents/keeper.md create mode 100644 tests/contract-lint/fixtures/_base/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/_base/commands/beta.md create mode 100644 tests/contract-lint/fixtures/_base/skills/sd-demo-rule/SKILL.md create mode 100644 tests/contract-lint/fixtures/_base/specwright.manifest.json create mode 100644 tests/contract-lint/fixtures/_base/templates/demo.template.md create mode 100644 tests/contract-lint/fixtures/cl001-unresolved-agent-reference/expected.json create mode 100644 tests/contract-lint/fixtures/cl001-unresolved-agent-reference/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/expected.json create mode 100644 tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/overlay/agents/keeper.md create mode 100644 tests/contract-lint/fixtures/cl003-unresolved-skill-reference/expected.json create mode 100644 tests/contract-lint/fixtures/cl003-unresolved-skill-reference/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/expected.json create mode 100644 tests/contract-lint/fixtures/cl004-skill-referenced-by-nobody/overlay/skills/sd-orphan-rule/SKILL.md create mode 100644 tests/contract-lint/fixtures/cl005-missing-templates-path/expected.json create mode 100644 tests/contract-lint/fixtures/cl005-missing-templates-path/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl006-unknown-command-reference/expected.json create mode 100644 tests/contract-lint/fixtures/cl006-unknown-command-reference/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/expected.json create mode 100644 tests/contract-lint/fixtures/cl007-agent-invoked-by-no-command/overlay/agents/hermit.md create mode 100644 tests/contract-lint/fixtures/cl008-unknown-spec-artifact/expected.json create mode 100644 tests/contract-lint/fixtures/cl008-unknown-spec-artifact/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl300-gate-without-stop/expected.json create mode 100644 tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/specwright.manifest.json create mode 100644 tests/contract-lint/fixtures/cl301-gate-without-options/expected.json create mode 100644 tests/contract-lint/fixtures/cl301-gate-without-options/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl301-gate-without-options/overlay/specwright.manifest.json create mode 100644 tests/contract-lint/fixtures/cl302-gate-count-disagrees/expected.json create mode 100644 tests/contract-lint/fixtures/cl302-gate-count-disagrees/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl303-gate-numbering-gap/expected.json create mode 100644 tests/contract-lint/fixtures/cl303-gate-numbering-gap/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/expected.json create mode 100644 tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/specwright.manifest.json create mode 100644 tests/contract-lint/fixtures/cl305-hard-gate-offers-override/expected.json create mode 100644 tests/contract-lint/fixtures/cl305-hard-gate-offers-override/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl900-suppression-without-reason/expected.json create mode 100644 tests/contract-lint/fixtures/cl900-suppression-without-reason/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl901-suppression-unknown-rule/expected.json create mode 100644 tests/contract-lint/fixtures/cl901-suppression-unknown-rule/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/expected.json create mode 100644 tests/contract-lint/fixtures/cl902-suppression-suppresses-nothing/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/clean/expected.json create mode 100644 tests/contract-lint/fixtures/fp-bold-pseudo-gate/expected.json create mode 100644 tests/contract-lint/fixtures/fp-bold-pseudo-gate/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/fp-gate-activity-heading/expected.json create mode 100644 tests/contract-lint/fixtures/fp-gate-activity-heading/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/fp-hard-gate-prose-escape/expected.json create mode 100644 tests/contract-lint/fixtures/fp-hard-gate-prose-escape/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/fp-phase0-stop/expected.json create mode 100644 tests/contract-lint/fixtures/fp-phase0-stop/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/fp-phase0-stop/overlay/specwright.manifest.json create mode 100644 tests/contract-lint/fixtures/fp-substep-before-parent/expected.json create mode 100644 tests/contract-lint/fixtures/fp-substep-before-parent/overlay/commands/alpha.md create mode 100644 tests/contract-lint/fixtures/fp-substep-before-parent/overlay/specwright.manifest.json create mode 100644 tests/contract-lint/run-selftest.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cbd42b..902a142 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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 37338d1..3d3ddf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 @@ -17,6 +37,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 under "Agents". No agent behaviour changed. ### Fixed +- `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 diff --git a/CLAUDE.md b/CLAUDE.md index 07ace7b..c662084 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,16 @@ 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 @@ -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 1b8add6..a1af6f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,55 @@ 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. + --- ## PR process 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/perf.md b/commands/perf.md index 9a56a77..4b9d413 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.** diff --git a/commands/setup.md b/commands/setup.md index 39361f9..98bbf9d 100644 --- a/commands/setup.md +++ b/commands/setup.md @@ -121,8 +121,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. @@ -186,7 +186,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 +196,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 `<>`. diff --git a/docs/architecture.md b/docs/architecture.md index a1db5a8..5235595 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,7 @@ The 5 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 | diff --git a/docs/contract-lint.md b/docs/contract-lint.md new file mode 100644 index 0000000..a8b8f1e --- /dev/null +++ b/docs/contract-lint.md @@ -0,0 +1,185 @@ +# 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. + +### 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 | + +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. Catching the +prose form honestly needs a per-gate declared-exception surface and is deferred to `CL306`. + +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. + +### 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 | + +`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) | planned | +| 3 | CL2xx role and tool integrity, CL4xx stack-agnostic prose, CL306 | planned, WARN first | +| 4 | CL5xx file budgets | planned, stays WARN | + +Two scope decisions were made deliberately in wave 1 and are recorded here so they read as +decisions rather than oversights: + +- **CL007 is loose.** "Invoked by" means any mention of the agent name in a command body. + Tightening it to a real invocation construct is CL1xx's job; doing it now would duplicate wave-2 + parsing. +- **CL006 does not scan `hooks/`,** although `/sd:` references live there. That is a wave-2 scope + extension. + +## 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/scripts/contract-lint.ps1 b/scripts/contract-lint.ps1 new file mode 100644 index 0000000..0d944ab --- /dev/null +++ b/scripts/contract-lint.ps1 @@ -0,0 +1,804 @@ +#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)'" +} + +# 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 +foreach ($g in $gates) { + $lines = $fileLines[$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)'" + } + } + } +} + +# 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..ad1dc96 --- /dev/null +++ b/scripts/contract-lint.sh @@ -0,0 +1,899 @@ +#!/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 +} + +# ---- 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" +} + +# 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" =~ ^-[[:blank:]]+\`([^\`]+)\` ]]; 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() { + local _f _l _kind _label _hard _end _i _has_stop _ol _ot + 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 + done <<< "$GATES" +} + +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_CL300_CL301_CL305 +rule_CL302_CL303_CL304 + +# ---- 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/validate.ps1 b/scripts/validate.ps1 index 1290091..89c13f5 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. @@ -121,7 +124,7 @@ 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 +141,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 +169,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 +193,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,7 +216,7 @@ 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" $psExe = (Get-Process -Id $PID).Path @@ -257,7 +260,7 @@ try { # ---- 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 +293,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' @@ -337,6 +340,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] @@ -422,6 +439,56 @@ if (-not (Test-Path -LiteralPath $manifestPath)) { } } +# ---- 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)" + } + } +} + # ---- summary --------------------------------------------------------------- Write-Section 'Summary' diff --git a/scripts/validate.sh b/scripts/validate.sh index f66f184..d09ecad 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. @@ -66,7 +69,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 +86,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 +103,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 +129,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,7 +157,7 @@ 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; } @@ -185,7 +188,7 @@ 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 +213,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" @@ -313,6 +316,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" @@ -399,6 +413,52 @@ else 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 + # ---- summary --------------------------------------------------------------- section "Summary" diff --git a/specwright.manifest.json b/specwright.manifest.json index 35b4d29..c02d18d 100644 --- a/specwright.manifest.json +++ b/specwright.manifest.json @@ -111,7 +111,8 @@ "([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?" ], "$claimPhrasesComment": [ @@ -219,6 +220,139 @@ { "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: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": "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/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" } + ], + + "$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": "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": "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/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"] + } } 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..a5df5b8 --- /dev/null +++ b/tests/contract-lint/README.md @@ -0,0 +1,104 @@ +# 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 `` 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 | +| `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 | +| `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 | `commands/bug.md`, `commands/release.md` | + +## 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..087e688 --- /dev/null +++ b/tests/contract-lint/fixtures/_base/agents/keeper.md @@ -0,0 +1,16 @@ +--- +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 + +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: +--- + +# /sd:alpha + +Demo workflow. Writes `.specs/ALPHA-/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: +--- + +# /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 / ) 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..f81505a --- /dev/null +++ b/tests/contract-lint/fixtures/_base/specwright.manifest.json @@ -0,0 +1,149 @@ +{ + "$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: 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": "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": "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" + ] + } +} 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 @@ +# <> + +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..f56d165 --- /dev/null +++ b/tests/contract-lint/fixtures/cl002-skills-entry-without-skill-md/overlay/agents/keeper.md @@ -0,0 +1,18 @@ +--- +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 + +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..a00ac62 --- /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. 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/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..857c3cb --- /dev/null +++ b/tests/contract-lint/fixtures/cl300-gate-without-stop/overlay/specwright.manifest.json @@ -0,0 +1,149 @@ +{ + "$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": "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": "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" + ] + } +} 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..857c3cb --- /dev/null +++ b/tests/contract-lint/fixtures/cl301-gate-without-options/overlay/specwright.manifest.json @@ -0,0 +1,149 @@ +{ + "$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": "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": "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" + ] + } +} 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..629e9aa --- /dev/null +++ b/tests/contract-lint/fixtures/cl304-conditional-gate-mismatch/overlay/specwright.manifest.json @@ -0,0 +1,151 @@ +{ + "$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": "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": "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" + ] + } +} 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/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-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..81dc2b8 --- /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", + "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..e38f8e1 --- /dev/null +++ b/tests/contract-lint/fixtures/fp-hard-gate-prose-escape/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) + +- If the user insists on closing without a draft, log a constitution exception and + proceed at their explicit risk acknowledgement. +- The user may override the outcome at this gate. +- On rejection: write nothing. 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..857c3cb --- /dev/null +++ b/tests/contract-lint/fixtures/fp-phase0-stop/overlay/specwright.manifest.json @@ -0,0 +1,149 @@ +{ + "$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": "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": "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" + ] + } +} 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..8e1392b --- /dev/null +++ b/tests/contract-lint/fixtures/fp-substep-before-parent/overlay/specwright.manifest.json @@ -0,0 +1,151 @@ +{ + "$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": "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": "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" + ] + } +} 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 +}