From d7e058cfd21e895f88ee82c4ab0ac05c0275335f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:50:01 +0000 Subject: [PATCH] Update workflow-preset to v3.1.1 Assisted-by: GitHub Actions (autonomous) --- presets/catalog.community.json | 8 +- presets/catalog.json | 6 +- presets/workflow-preset.release.json | 48 ++- presets/workflow-preset/CHANGELOG.md | 11 + presets/workflow-preset/README.md | 2 +- .../workflow-preset/commands/speckit.plan.md | 153 ++++++- .../docs/extension-governance.md | 32 ++ presets/workflow-preset/preset.yml | 2 +- .../templates/plan-template.md | 21 + .../templates/test-readiness-template.md | 10 +- .../contracts/speckit-cross-agent-protocol.md | 16 +- .../async_retry_compensation.json | 75 ++++ .../plan_bundles/minimal_repository.json | 67 +++ .../plan_bundles/non_ui_single_interface.json | 67 +++ .../tests/fixtures/plan_bundles/ui_only.json | 69 ++++ .../tests/test_preset_contract.py | 313 +++++++++++++- .../validators/speckit_plan_contract.py | 381 ++++++++++++++++++ .../validators/speckit_test_contract.py | 14 +- tests/test_presets.py | 2 +- 19 files changed, 1254 insertions(+), 43 deletions(-) create mode 100644 presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json create mode 100644 presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json create mode 100644 presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json create mode 100644 presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json create mode 100644 presets/workflow-preset/validators/speckit_plan_contract.py diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 89b63ddfd7..ba9bafb0b0 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -670,11 +670,11 @@ "workflow-preset": { "name": "Workflow Preset", "id": "workflow-preset", - "version": "3.1.0", + "version": "3.1.1", "description": "Constitution-managed architecture, behavior-first specification, design artifacts, and execution-ready task mapping", "author": "bigsmartben", "repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", - "download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.0/spec-kit-workflow-preset-v3.1.0.zip", + "download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.1/spec-kit-workflow-preset-v3.1.1.zip", "homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset", "documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md", "license": "MIT", @@ -697,8 +697,8 @@ ], "created_at": "2026-05-27T00:00:00Z", "updated_at": "2026-07-26T00:00:00Z", - "source_commit": "e20dcafde5a1b031643156db49eb3e988e322bf9", - "sha256": "9f92d3820ce5365a80a3816890850fb9cc190006fd3ad025ff941baa90165fcb" + "source_commit": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", + "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" } } } diff --git a/presets/catalog.json b/presets/catalog.json index 06a9a8996f..713e92fcf6 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -29,7 +29,7 @@ "workflow-preset": { "name": "Workflow Preset", "id": "workflow-preset", - "version": "3.1.0", + "version": "3.1.1", "description": "Constitution-managed architecture, behavior-first specification, design artifacts, and execution-ready task mapping", "author": "bigsmartben", "repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", @@ -52,8 +52,8 @@ "planning", "implementation" ], - "source_commit": "e20dcafde5a1b031643156db49eb3e988e322bf9", - "sha256": "9f92d3820ce5365a80a3816890850fb9cc190006fd3ad025ff941baa90165fcb" + "source_commit": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", + "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" } } } diff --git a/presets/workflow-preset.release.json b/presets/workflow-preset.release.json index 98d57ead12..4f67fd12f9 100644 --- a/presets/workflow-preset.release.json +++ b/presets/workflow-preset.release.json @@ -1,7 +1,7 @@ { "artifact": { - "name": "spec-kit-workflow-preset-v3.1.0.zip", - "sha256": "9f92d3820ce5365a80a3816890850fb9cc190006fd3ad025ff941baa90165fcb" + "name": "spec-kit-workflow-preset-v3.1.1.zip", + "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" }, "files": [ { @@ -10,7 +10,7 @@ }, { "path": "CHANGELOG.md", - "sha256": "ce932c8a0ef0bf82d6c342ea1fedb8e90cd11878674d168059d6b1e5e5561a3e" + "sha256": "59a583460843504156209338956f24334cbe90933bab8736d9b864dd5b0d11fc" }, { "path": "LICENSE", @@ -18,7 +18,7 @@ }, { "path": "README.md", - "sha256": "bdfe954c0737ec587eebf557ed042836f26f0f27a076e17d17ab895c0a2822b6" + "sha256": "69a358dd0fb5ccfefd49429797bbad8d77b43783bb3263fc0ba7ed289c714b2e" }, { "path": "commands/speckit.analyze.md", @@ -38,7 +38,7 @@ }, { "path": "commands/speckit.plan.md", - "sha256": "9e07188cab506100041ad1f9dbf9b92feb0bae3c9c5fde948ab2e4b9287e8eed" + "sha256": "bd9a75332655abbaa48f9d7a2673aa084090cd7069e240893d8926a460adc3a0" }, { "path": "commands/speckit.specify.md", @@ -50,11 +50,11 @@ }, { "path": "docs/extension-governance.md", - "sha256": "c2f8ae1500adf144c1d3102c6e00424ab4c420a4e25ded9f54974fd8eb38bc26" + "sha256": "feddb04f5a80eed12d0439a6c56eb7590df9b20f9cb7079ef9e612e2f0a5cab2" }, { "path": "preset.yml", - "sha256": "68967ef1e3fb93af09ebaf3894d05cf5a57325f7839df0661dc44f2f618ec86c" + "sha256": "9722b3121cba934f2ebd5ad77ed1c4d43eaa64c4c7e5f08fed43b1e94786f4a4" }, { "path": "requirements-dev.txt", @@ -126,7 +126,7 @@ }, { "path": "templates/plan-template.md", - "sha256": "653684caafb63216b5c6b1ba80adc4c04b7446494ccba55c6cd0cb80474437cf" + "sha256": "10b9da0be3591bac4272efd424e2019e79e828297a4907939bf9d734ee00011c" }, { "path": "templates/quickstart-template.md", @@ -158,7 +158,7 @@ }, { "path": "templates/test-readiness-template.md", - "sha256": "7695440deacf892f1646f12e12245e28f09f4d1383d8dcb130e1240bb58793b4" + "sha256": "e8c3fcd10c47e6b0112b1dad15d30da167735d19730bc131be0b6db08fa99b81" }, { "path": "templates/test/test-conditions.json", @@ -170,11 +170,27 @@ }, { "path": "tests/contracts/speckit-cross-agent-protocol.md", - "sha256": "faade06b315050d256d54d55fe15a8c4104ed669a0be4ad0dc01822fee776f71" + "sha256": "53aa0d7f739cd7ab64cc517997f7b370c87b3c2e8f551f1222b2b69a54b3e854" + }, + { + "path": "tests/fixtures/plan_bundles/async_retry_compensation.json", + "sha256": "4d875ff6e343aea2c518c974ca6c8d659361edb768ad24d436f24cf1d36d5864" + }, + { + "path": "tests/fixtures/plan_bundles/minimal_repository.json", + "sha256": "86349398d9168146a9c6c79f1980eef2cd27e14c3565aa7621da9f2483df765e" + }, + { + "path": "tests/fixtures/plan_bundles/non_ui_single_interface.json", + "sha256": "fe62e206f1167071064e840bde44c3848203953db3af7b381364d10e6347d34d" + }, + { + "path": "tests/fixtures/plan_bundles/ui_only.json", + "sha256": "cb653d0d759e156641e6d46943c51b889730790ecd8ce293edf98447c5e90d01" }, { "path": "tests/test_preset_contract.py", - "sha256": "526e9f4bb20d17a93b2c75e0ec9e4958d57aa34ab5cc286719fcb27013b4194e" + "sha256": "f5382ee7bd11720781057abecf2a5a39ed18e069a3adaedf2fb38ad2a1e0027a" }, { "path": "validators/__init__.py", @@ -188,14 +204,18 @@ "path": "validators/speckit_behavior_contract.py", "sha256": "112d2438a6fa4d1002ee45ebfb8a86ff1fe37390c80ef9492512a8a4debc141a" }, + { + "path": "validators/speckit_plan_contract.py", + "sha256": "e724eede9933107c2a627d49a8b0178ebe6b6433fd7e0dc89ee4f6ae7852bb71" + }, { "path": "validators/speckit_test_contract.py", - "sha256": "4c0827812f68c4c67258f1c57e5af22465d0d3e7abddc6f699bdbdc995ac6e8b" + "sha256": "1b741476ce8135e358157a0dd81c8a9757ead04ebf9195ed7349083953e5a8e2" } ], "preset_id": "workflow-preset", "schema_version": "1.0", - "source_commit": "e20dcafde5a1b031643156db49eb3e988e322bf9", + "source_commit": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", "source_repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", - "version": "3.1.0" + "version": "3.1.1" } diff --git a/presets/workflow-preset/CHANGELOG.md b/presets/workflow-preset/CHANGELOG.md index c2cb0d9a80..af3bf29208 100644 --- a/presets/workflow-preset/CHANGELOG.md +++ b/presets/workflow-preset/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 3.1.1 - 2026-07-27 + +- Hardened Plan bundle validation so blocked Test Readiness rows and blocked + Test Conditions prevent false `PLAN_OUTPUT_READY: READY` conclusions. +- Preserved independently verified X2-C outputs during blocked continuation and + kept blocked X2-B delivery distinct from N/A. +- Allowed pure domain/internal-object X2-A design without an unnecessary + interface contract. +- Added representative Plan bundle fixtures, release packaging assertions, and + local preset installation coverage for the corrected boundaries. + ## 3.1.0 - 2026-07-27 - Split SDD governance from repository Architecture and added independent diff --git a/presets/workflow-preset/README.md b/presets/workflow-preset/README.md index 1547ad3499..92c19afe42 100644 --- a/presets/workflow-preset/README.md +++ b/presets/workflow-preset/README.md @@ -128,7 +128,7 @@ specify preset add --dev /path/to/spec-kit-workflow-preset 已发布版本: ```bash -specify preset add --from https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.0/spec-kit-workflow-preset-v3.1.0.zip +specify preset add --from https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.1/spec-kit-workflow-preset-v3.1.1.zip ``` 安装后可检查预设信息: diff --git a/presets/workflow-preset/commands/speckit.plan.md b/presets/workflow-preset/commands/speckit.plan.md index 0c9ef16ec7..853d8d1a9a 100644 --- a/presets/workflow-preset/commands/speckit.plan.md +++ b/presets/workflow-preset/commands/speckit.plan.md @@ -23,11 +23,47 @@ pre/post hooks, Phase 0, Phase 1, and completion behavior in their official order. Do not add, remove, rename, reorder, duplicate, or reinterpret a Core phase or gate. Do not re-run Checklist or aggregate Planning Readiness. +## Deterministic Execution Spine + +Run this preset-internal spine inside the unchanged Core lifecycle: + +```text +Core setup + -> X0 scope/lane control + -> X1 decisions + -> X2-A / X2-B / X2-C active lanes + -> X2 cross-lane reconciliation + -> X3 VAL paths + -> refresh affected X2 reconciliation checks + -> unchanged Core post-design Constitution re-check + -> X4 closeout + -> derive PLAN_OUTPUT_READY +``` + +For every internal milestone or active lane, use exactly this Gate loop: + +| Gate step | Required action | +|---|---| +| Entry conditions | Confirm every dependency Gate is `READY` or explicitly `N/A`; otherwise stop the dependent scope. | +| Bounded reads | Read only the upstream inputs, packaged templates/schemas, repository facts, and already-produced Plan artifacts needed by this scope. | +| Owned writes | Write only the artifacts owned by the current milestone/lane. | +| Validation | Check required outputs, conditional decisions, stable refs, placeholders, ownership, and lane-specific prohibitions. | +| Evidence | Record `READY`, `BLOCKED: `, or `N/A: ` plus concrete artifact/section/ID evidence in `plan.md`. | +| Failure handling | Keep the owning lane blocked, record the smallest actionable blocker or context gap, and do not enter a dependent milestone. | + +File existence, prose confidence, or a summary statement is never Gate +evidence. A Gate is `READY` only when all of its required checks pass. An +inactive lane is `N/A` only through the decision rules below. Any unresolved +required check makes the Gate `BLOCKED`; do not continue into a scope that +depends on it. + ## External Input Boundary -Read only `spec.md`, `.specify/memory/constitution.md`, -`.specify/memory/architecture.md`, and current repository facts. Planning has -one strategy for every repository: +Authoritative upstream inputs are limited to `spec.md`, +`.specify/memory/constitution.md`, `.specify/memory/architecture.md`, and +current repository facts. “Read only” does not prohibit reading packaged +templates/schemas or the current Plan stage's already-generated artifacts for +validation and reconciliation. Planning has one strategy for every repository: ```text current repository facts @@ -54,6 +90,55 @@ Apply Change Scope Granularity: lock planned `M + U`; `plan.md` may record repository/module directory topology required by Core, but no task IDs, per-task paths, operation-level changes, or implementation order. +## Ownership And Conditional Artifact Decisions + +| Scope | Sole final writer | Owned outputs | Must not absorb | +|---|---|---|---| +| X0/X4 control | Plan Core Agent | `plan.md` control, Gate evidence, derivation index, closeout | complete designs, Test Conditions, tasks | +| X1 decisions | Plan Core Agent | `research.md` `DEC-*` records | complete designs, runnable paths, cross-command audit | +| X2-A | Plan Core Agent | `data-model.md`, contextual class/sequence artifacts, interface contracts | UI delivery detail, fixtures, assertions, task paths | +| X2-B | Plan Core Agent | `ui-ux-design.md`, `contracts/uif/` | API payload schemas, pixel tests, external-source certification | +| X2-C | Plan Core Agent | Test Conditions and selected technique children | UI styling/fidelity, validation run guidance, tasks | +| X3 | Plan Core Agent | `quickstart.md` `VAL-*` paths | test design decisions, implementation bodies, results | + +Use this decision table; never infer N/A merely because an artifact is absent: + +| Artifact/lane | Required when | N/A allowed when | Blocked when | +|---|---|---|---| +| `class-diagram.md` | multiple cooperating objects, dependency direction, patterns, or object ownership must be designed | one object or responsibilities are fully expressed without an object relationship view; record the concrete reason | trigger applies but responsibilities/relationships cannot yet be resolved | +| interface contracts | an externally observable protocol, input/output, error, compatibility/versioning, or state-effect boundary must be designed | X2-A is pure domain/internal-object design with no externally observable interface contract; record the concrete reason | an interface trigger applies but its observable contract cannot yet be resolved | +| `contracts/sequences.md` | observable cross-boundary order, async callback, retry, rollback, compensation, or failure propagation exists | no observable ordering or failure-flow design exists; record the concrete reason | trigger applies but participants/order/failure path is unresolved | +| X2-B + `ui-ux-design.md`/UIF | any `UX-*`, `UI-*`, `VIS-*`, interactive surface/state, responsive, asset, or accessibility delivery applies | feature has no user-visible or interactive delivery; cite the scoped Spec evidence | applicable UI/visual refs lack a local delivery mapping or stable blocker | +| BDD/scenario child | parent `TC-*` selects BDD/scenario technique | no parent selects that technique | selected technique lacks its parent-linked child | +| fixture child | a parent `TC-*` requires reusable/formal setup data | every applicable condition records a fixture-free rationale | required reusable setup is unresolved or missing | +| assertion child | a parent `TC-*` selects structured scenario/assertion technique | no parent selects a structured assertion technique and each oracle remains complete in its parent | selected technique lacks outcome assertions | + +A blocked applicable lane remains `Blocked`; never relabel it `N/A`. During a +resume, preserve each independently verified `Required` output and mark only +the affected output `Blocked`. In particular, X2-C does not require Test +Conditions, Quickstart, and Test Readiness to become blocked as a group. + +## Stage-Local Work Units + +If bounded subagents are available, the Plan Core Agent may assign independent +X1/X2/X3 derivation units. Every transient assignment must contain: + +```text +assigned_scope +allowed_reads +allowed_writes +required_outputs +validation_gate +blockers +context_gaps +``` + +The assignment is runtime context only, not a file, manifest, queue, handoff +protocol, or worker result. The Plan Core Agent remains the sole final writer +and sole Gate decision owner. It validates returned content before incorporating +it. If delegation is unavailable or a unit is blocked, process one assigned +scope at a time with the same fields and Gate loop. + ## X0 — Feature Plan Control Run after Core setup has materialized `plan.md`, before detailed research. @@ -169,6 +254,33 @@ Conditions or Test Readiness. `X2C_TEST_DESIGN_READY` requires all TC dimensions and internal refs, every technique-triggered child or stable blocker, and zero pixel-level Test entries. +## X2 — Cross-Lane Reconciliation + +After all active X2 lanes have reached `READY`, and before entering X3, the Plan +Core Agent performs one explicit reconciliation pass. Do not delegate the final +judgment. Inventory each declared `DEC-*`, design/interface/sequence ID, UIF, +`TC-*`, and requested `VAL-*` mapping once, then check: + +1. every consumed ref resolves to exactly one producer and uses one stable ID or + name; report missing, duplicate, renamed, and stale refs; +2. every material `DEC-*` reaches each affected active-lane output; +3. every UIF API/design ref resolves in X2-A and every applicable UI/VIS source + pair resolves in X2-B; +4. every `TC-*` resolves its requirement, design/UIF/interface refs and every + technique-triggered child; +5. every condition needing execution declares the expected `VAL-*` mapping or + a stable X3 blocker; +6. content remains with its sole owner; record misplaced domain, interface, UI, + Test, or validation-path content; +7. each blocker is recorded against its real owning lane, and every dependent + lane points to that blocker instead of copying or relabeling it. + +Record the inventory, findings, blocker owners, and evidence in the +`X2_RECONCILIATION_READY` section of `plan.md`. Any unresolved finding blocks +X3. After X3 writes `VAL-*`, refresh all reconciliation rows affected by +created, renamed, blocked, or removed paths; a requested mapping is not closed +until the actual `VAL-*` or blocker resolves. + ## X3 — Integration & Validation Paths Within Core Phase 1, populate `quickstart.md` using stable `VAL-*` records: @@ -187,6 +299,23 @@ implementation bodies, migrations, task IDs/order, or fabricated results. `X3_VALIDATION_PATHS_READY` requires each condition needing a runnable path to map to a complete `VAL-*` or stable runtime blocker. +## Continuation And Resume + +Treat the `plan.md` Internal Gate Summary and its evidence as the resume index: + +1. validate the recorded evidence rather than trusting the status text; +2. preserve an already-verified artifact when its bounded inputs and stable refs + are unchanged; +3. resume at the first Gate whose evidence is absent, invalid, `BLOCKED`, or + affected by changed input; +4. re-run that Gate and every downstream reconciliation/Gate that consumes the + changed artifact or ref; +5. never unconditionally overwrite a verified upstream artifact, and never + preserve a downstream `READY` status after an input/ref it depends on changed. + +No `PENDING` state is introduced. A Gate without closed evidence is simply not +ready and is treated as the first resume candidate. + ## X4 — Independent Closeout After Core design and its post-design Constitution re-check: @@ -196,14 +325,20 @@ After Core design and its post-design Constitution re-check: class | interface/sequence | blocker`. 2. Finalize UI/UX Delivery Readiness in `ui-ux-design.md` or explicit N/A. 3. Create `test-readiness.md` as the only Test/Tasks readiness SSOT, one row per - required `TC-*`. Do not create/retain authoritative + required `TC-*`. Each row is `READY` with evidence or `BLOCKED` with a + blocker; any blocked row blocks `PLAN_OUTPUT_READY`. Do not create/retain authoritative `behavior/behavior-testability.md`, `planning-readiness.md`, or `test-plan.md`. -4. Summarize gate status and lane-owned blockers in `plan.md`. - -`PLAN_OUTPUT_READY` equals X0 + X1 + applicable X2 gates + applicable X3 + -complete Design/UI/UX/Test readiness + resolved Plan-internal refs + no -placeholder presented as a decision. It validates Plan outputs only. +4. Re-run the final cross-lane reference and ownership checks, then summarize + Gate status and lane-owned blockers in `plan.md`. + +Derive `PLAN_OUTPUT_READY`; never set it independently. It is `READY` if and +only if X0 + X1 + `X2_RECONCILIATION_READY` + every applicable X2 Gate + +applicable X3 + complete Design/UI/UX/Test readiness are evidenced, every +conditional artifact is Required/READY or has a valid N/A reason, all +Plan-internal refs resolve, all blockers are lane-owned, and no placeholder is +presented as a decision. Otherwise it is `BLOCKED` with the failed Gate and +blocker evidence. It validates Plan outputs only. {CORE_TEMPLATE} diff --git a/presets/workflow-preset/docs/extension-governance.md b/presets/workflow-preset/docs/extension-governance.md index 31ef70d8ca..3415c085c2 100644 --- a/presets/workflow-preset/docs/extension-governance.md +++ b/presets/workflow-preset/docs/extension-governance.md @@ -288,6 +288,38 @@ Pixel-fidelity delivery and review belong only to UI/UX Delivery Readiness. Pixel, screenshot, diff, baseline, restoration, and rendered-visual-review work is rejected from Test Conditions and Test Readiness. +Plan internal milestones use one evidence-bearing loop: entry conditions, +bounded reads, owned writes, validation, `READY/BLOCKED/N/A` evidence, and stop +handling. A dependent milestone cannot start before its dependency Gate closes. +After active X2 lanes, Plan performs a cross-lane reconciliation over stable +`DEC-*`, design/interface/sequence, UIF, `TC-*`, and requested `VAL-*` refs; +X3 refreshes every affected reconciliation row after producing validation +paths. + +Conditional artifacts are decided by explicit triggers rather than file +presence. Class diagrams are triggered by cooperating-object responsibility or +dependency design; interface contracts by an externally observable boundary; +sequences by observable cross-boundary order/failure flow; X2-B by user-visible +or interactive delivery; and BDD/scenario/fixture/assertion children by their +parent Test Condition techniques. Pure domain/internal-object X2-A therefore +does not require an interface contract. Each non-triggered artifact has a +concrete N/A reason. + +Blocked applicability is never rewritten as N/A. During continuation, artifact +decisions remain independent: preserve verified Required outputs and block only +affected outputs. X2-C does not force Test Conditions, Quickstart, and Test +Readiness to share one Blocked decision. Every required Test Readiness row is +READY with evidence or BLOCKED with a blocker, and any blocked row prevents +`PLAN_OUTPUT_READY`. + +`plan.md` Gate evidence is also the continuation index. A rerun preserves +verified artifacts whose inputs and refs are unchanged, resumes at the first +unclosed or affected Gate, and revalidates its downstream consumers. No +`PENDING` state or persistent execution/transfer artifact is introduced. +`PLAN_OUTPUT_READY` is derived only from evidenced internal Gates, +reconciliation, readiness products, conditional decisions, resolved refs, and +the absence of decision placeholders. + Validation decisions stay in `research.md`, executable paths stay in `quickstart.md`, and `test-readiness.md` is the single Test/Tasks handoff. Do not restore `behavior/behavior-testability.md`, create a generic diff --git a/presets/workflow-preset/preset.yml b/presets/workflow-preset/preset.yml index a91e00b083..a623af08d4 100644 --- a/presets/workflow-preset/preset.yml +++ b/presets/workflow-preset/preset.yml @@ -2,7 +2,7 @@ schema_version: '1.0' preset: id: workflow-preset name: Workflow Preset - version: 3.1.0 + version: 3.1.1 description: Separated SDD governance, source-neutral specification, X0-X4 design, test-first contracts, and execution-ready task mapping author: bigsmartben diff --git a/presets/workflow-preset/templates/plan-template.md b/presets/workflow-preset/templates/plan-template.md index 6d6bde479f..5fd364062d 100644 --- a/presets/workflow-preset/templates/plan-template.md +++ b/presets/workflow-preset/templates/plan-template.md @@ -38,8 +38,25 @@ | X2A_DESIGN_READY | [status] | [ref] | | X2B_UIUX_READY | [status] | [ref] | | X2C_TEST_DESIGN_READY | [status] | [ref] | +| X2_RECONCILIATION_READY | [status] | [inventory/findings/blocker refs] | | X3_VALIDATION_PATHS_READY | [status] | [ref] | +Each status is derived from the cited evidence. File existence alone is not +evidence. A missing or invalid evidence cell means the Gate is not ready. + +### X2 Cross-Lane Reconciliation + +| Ref / mapping | Producer / owner | Consumers | Resolved target | Drift / ownership finding | Status / blocker | +|---|---|---|---|---|---| +| [DEC/UIF/TC/VAL/design ref] | [lane + artifact] | [lane artifacts] | [stable ID/path] | [none or finding] | [READY/BLOCKED: ID] | + +### Resume Checkpoint + +- **First unclosed/affected Gate**: [Gate ID] +- **Verified artifacts preserved**: [paths + evidence refs] +- **Changed inputs/refs**: [refs or none] +- **Downstream Gates/reconciliation to rerun**: [Gate IDs] + ## Artifact Navigation - Shared decisions: `./research.md` @@ -71,3 +88,7 @@ No task IDs, exact per-task paths, or implementation order belong here. - **X3 Validation Paths**: [READY/BLOCKED/N/A] - **Blockers by lane**: [IDs] - **PLAN_OUTPUT_READY**: READY | BLOCKED + +`PLAN_OUTPUT_READY` is derived from the Internal Gate Summary, reconciliation, +conditional artifact decisions, readiness products, resolved refs, and +placeholders. Do not mark it READY from file presence or prose summary alone. diff --git a/presets/workflow-preset/templates/test-readiness-template.md b/presets/workflow-preset/templates/test-readiness-template.md index 14246987cf..b866063e33 100644 --- a/presets/workflow-preset/templates/test-readiness-template.md +++ b/presets/workflow-preset/templates/test-readiness-template.md @@ -4,9 +4,11 @@ **Status**: READY | BLOCKED | NOT_APPLICABLE -| TC ID | Source refs | Level | Type | Technique | Fixture/data | Environment/mode | Oracle | Contract refs | VAL path | Evidence | Blocker | -|---|---|---|---|---|---|---|---|---|---|---|---| +| TC ID | Status | Source refs | Level | Type | Technique | Fixture/data | Environment/mode | Oracle | Contract refs | VAL path | Evidence | Blocker | +|---|---|---|---|---|---|---|---|---|---|---|---|---| Every required `TC-*` has exactly one row. BDD/scenario/UIF refs are optional -according to the Test Condition. Pixel-fidelity, screenshot, diff, baseline, -restoration, and rendered-visual-review items MUST NOT appear here. +according to the Test Condition. Each row is `READY` with evidence or `BLOCKED` +with a blocker; any blocked row keeps `PLAN_OUTPUT_READY` blocked. +Pixel-fidelity, screenshot, diff, baseline, restoration, and +rendered-visual-review items MUST NOT appear here. diff --git a/presets/workflow-preset/tests/contracts/speckit-cross-agent-protocol.md b/presets/workflow-preset/tests/contracts/speckit-cross-agent-protocol.md index 417f46c84f..aabddcff82 100644 --- a/presets/workflow-preset/tests/contracts/speckit-cross-agent-protocol.md +++ b/presets/workflow-preset/tests/contracts/speckit-cross-agent-protocol.md @@ -39,15 +39,23 @@ Every preset-owned command profile defines: - `stage`: X0–X4 milestones nested in Core Plan. - `owner_agent`: Plan Core Agent. +- `assigned_scope`: one bounded X1, X2-A, X2-B, X2-C, X3, or X4 derivation + unit; never an implementation unit. - `input_scope`: local Spec facts/blockers, Constitution, current repository facts, applicable Architecture refs, and assigned X1/X2/X3/X4 artifact families. External `SRC-*` locators are not allowed reads. +- `allowed_reads`: authoritative upstream inputs, current repository facts, + packaged Plan templates/schemas, and already-produced Plan artifacts named by + the assigned scope. - `allowed_writes`: final planning artifacts owned by `/speckit.plan`. -- `output_contract`: lane-qualified decisions, X2-A/X2-B/X2-C designs, - `TC-*`, `VAL-*`, independent readiness products, blockers, and - `context_gaps`. +- `required_outputs`: lane-qualified decisions, X2-A/X2-B/X2-C designs, + `TC-*`, `VAL-*`, or independent readiness content explicitly named by the + assigned scope. +- `output_contract`: required outputs plus `blockers` and `context_gaps`. - `validation_gate`: `PLAN_OUTPUT_READY` over Plan outputs and internal refs - only. + only, with a lane-local Gate on each assignment. +- `stop_conditions`: an unmet entry Gate, an out-of-scope read/write, an + unresolved required ref, or a blocker owned by another lane. - `fallback`: the Plan Core Agent processes one assigned scope at a time and preserves final-write ownership. diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json b/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json new file mode 100644 index 0000000000..a74a884035 --- /dev/null +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json @@ -0,0 +1,75 @@ +{ + "name": "async retry compensation", + "repository_mode": "populated", + "lanes": { + "X2-A": {"status": "Required"}, + "X2-B": {"status": "N/A", "reason": "The scoped worker flow has no user-visible surface."}, + "X2-C": {"status": "Required"} + }, + "artifacts": [ + {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "Async worker scope, gates, reconciliation, and closeout are evidenced."}, + {"path": "research.md", "decision": "Required", "owner": "X1", "content": "Retry, idempotency, and compensation decisions are recorded."}, + {"path": "data-model.md", "decision": "Required", "owner": "X2-A", "content": "Job attempt, idempotency key, and compensation lifecycle are defined."}, + {"path": "class-diagram.md", "decision": "Required", "owner": "X2-A", "content": "Coordinator, retry policy, and compensation port responsibilities are defined.", "declared_ids": ["OBJ-COORDINATOR", "OBJ-RETRY-POLICY"]}, + {"path": "contracts/job.json", "decision": "Required", "owner": "X2-A", "content": "Submission, callback, and failure contracts are defined.", "declared_ids": ["IF-JOB-001"]}, + {"path": "contracts/sequences.md", "decision": "Required", "owner": "X2-A", "content": "Submit, callback, retry, terminal failure, and compensation order are defined.", "declared_ids": ["SEQ-JOB-001"]}, + {"path": "ui-ux-design.md", "decision": "N/A", "reason": "No UI, visual, responsive, asset, or accessibility delivery applies."}, + {"path": "contracts/test/test-conditions.json", "decision": "Required", "owner": "X2-C", "content": "Retry and compensation condition is complete."}, + {"path": "contracts/behavior/scenario-instances.json", "decision": "Required", "owner": "X2-C", "content": "The retry exhaustion scenario references TC-ASYNC-001."}, + {"path": "contracts/behavior/data-fixtures.json", "decision": "Required", "owner": "X2-C", "content": "Reusable failed-provider responses reference TC-ASYNC-001."}, + {"path": "contracts/behavior/assertions.json", "decision": "Required", "owner": "X2-C", "content": "Rollback and compensation assertions reference TC-ASYNC-001."}, + {"path": "quickstart.md", "decision": "Required", "owner": "X3", "content": "The sandbox retry and compensation journey is defined."}, + {"path": "test-readiness.md", "decision": "Required", "owner": "X4", "content": "TC-ASYNC-001 has one readiness row."} + ], + "decisions": [ + {"id": "DEC-DATA-RETRY", "affected_refs": ["OBJ-COORDINATOR", "OBJ-RETRY-POLICY", "IF-JOB-001", "SEQ-JOB-001", "TC-ASYNC-001"]} + ], + "uif_contracts": [], + "test_conditions": { + "contract_type": "speckit.test.conditions.v1", + "feature": "async-job-recovery", + "conditions": [{ + "id": "TC-ASYNC-001", + "source_refs": ["FR-RETRY-001", "NFR-RELIABILITY-001"], + "risk_or_priority": "critical", + "levels": ["integration"], + "types": ["recovery", "reliability", "data_side_effect"], + "techniques": ["state_transition"], + "execution_mode": "sandbox", + "fixture_refs": ["FIX-PROVIDER-FAILURES"], + "environment_refs": ["ENV-SANDBOX"], + "oracle": {"kind": "state_and_compensation", "expected": "retry exhaustion produces one compensation and a terminal state"}, + "evidence_requirement": "state history and provider call log", + "related_refs": ["IF-JOB-001", "SEQ-JOB-001", "OBJ-COORDINATOR"], + "quickstart_ref": "VAL-ASYNC-001", + "status": "required" + }] + }, + "technique_children": { + "bdd": [], + "scenario": ["TC-ASYNC-001"], + "fixture": ["TC-ASYNC-001"], + "assertion": ["TC-ASYNC-001"] + }, + "test_readiness_rows": [ + {"tc_id": "TC-ASYNC-001", "status": "READY", "evidence": "quickstart.md#VAL-ASYNC-001"} + ], + "validation_paths": [ + {"id": "VAL-ASYNC-001", "covered_refs": ["TC-ASYNC-001", "IF-JOB-001", "SEQ-JOB-001", "OBJ-COORDINATOR"]} + ], + "reconciliation": { + "resolved_refs": ["DEC-DATA-RETRY", "OBJ-COORDINATOR", "OBJ-RETRY-POLICY", "IF-JOB-001", "SEQ-JOB-001", "TC-ASYNC-001", "VAL-ASYNC-001"], + "findings": [], + "blocker_owners": {} + }, + "gates": { + "X0_CONTROL_READY": {"status": "READY", "evidence": ["plan.md#x0-feature-plan-control"]}, + "X1_DECISIONS_READY": {"status": "READY", "evidence": ["research.md#DEC-DATA-RETRY"]}, + "X2A_DESIGN_READY": {"status": "READY", "evidence": ["class-diagram.md#objects", "contracts/sequences.md#SEQ-JOB-001"]}, + "X2B_UIUX_READY": {"status": "N/A", "evidence": ["plan.md#active-lane-matrix-no-ui"]}, + "X2C_TEST_DESIGN_READY": {"status": "READY", "evidence": ["contracts/test/test-conditions.json#TC-ASYNC-001"]}, + "X2_RECONCILIATION_READY": {"status": "READY", "evidence": ["plan.md#x2-cross-lane-reconciliation"]}, + "X3_VALIDATION_PATHS_READY": {"status": "READY", "evidence": ["quickstart.md#VAL-ASYNC-001"]} + }, + "plan_output_ready": "READY" +} diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json b/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json new file mode 100644 index 0000000000..ebe333d049 --- /dev/null +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json @@ -0,0 +1,67 @@ +{ + "name": "minimal repository", + "repository_mode": "minimal", + "lanes": { + "X2-A": {"status": "Required"}, + "X2-B": {"status": "N/A", "reason": "The scoped library API has no user-visible surface."}, + "X2-C": {"status": "Required"} + }, + "artifacts": [ + {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "The empty observed topology and intended module boundary are explicit."}, + {"path": "research.md", "decision": "Required", "owner": "X1", "content": "The runtime prerequisite and interface format decisions are recorded."}, + {"path": "data-model.md", "decision": "Required", "owner": "X2-A", "content": "A value object and its invariants are defined without inventing existing paths.", "declared_ids": ["OBJ-VALUE-001"]}, + {"path": "contracts/library.json", "decision": "Required", "owner": "X2-A", "content": "The intended library input, output, and errors are defined.", "declared_ids": ["IF-LIB-001"]}, + {"path": "class-diagram.md", "decision": "N/A", "reason": "One value object has no cooperating-object relationship."}, + {"path": "contracts/sequences.md", "decision": "N/A", "reason": "The pure library call has no cross-boundary ordering or failure propagation."}, + {"path": "ui-ux-design.md", "decision": "N/A", "reason": "No UI, visual, responsive, asset, or accessibility delivery applies."}, + {"path": "contracts/test/test-conditions.json", "decision": "Required", "owner": "X2-C", "content": "The value contract condition is complete."}, + {"path": "quickstart.md", "decision": "Required", "owner": "X3", "content": "The intended local validation path and missing runtime prerequisite are explicit."}, + {"path": "test-readiness.md", "decision": "Required", "owner": "X4", "content": "TC-MIN-001 has one readiness row."} + ], + "decisions": [ + {"id": "DEC-TECH-MINIMAL", "affected_refs": ["OBJ-VALUE-001", "IF-LIB-001", "TC-MIN-001"]} + ], + "uif_contracts": [], + "test_conditions": { + "contract_type": "speckit.test.conditions.v1", + "feature": "minimal-library", + "conditions": [{ + "id": "TC-MIN-001", + "source_refs": ["FR-001"], + "risk_or_priority": "medium", + "levels": ["unit", "contract"], + "types": ["functional"], + "techniques": ["boundary_value"], + "execution_mode": "local-after-runtime-bootstrap", + "no_fixture_rationale": "Boundary inputs are inline values.", + "environment_refs": ["ENV-RUNTIME-PREREQUISITE"], + "oracle": {"kind": "value_and_error", "expected": "valid values normalize and invalid values return the declared error"}, + "evidence_requirement": "test command output after the runtime prerequisite is satisfied", + "related_refs": ["OBJ-VALUE-001", "IF-LIB-001"], + "quickstart_ref": "VAL-MIN-001", + "status": "required" + }] + }, + "technique_children": {"bdd": [], "scenario": [], "fixture": [], "assertion": []}, + "test_readiness_rows": [ + {"tc_id": "TC-MIN-001", "status": "READY", "evidence": "quickstart.md#VAL-MIN-001"} + ], + "validation_paths": [ + {"id": "VAL-MIN-001", "covered_refs": ["TC-MIN-001", "OBJ-VALUE-001", "IF-LIB-001"]} + ], + "reconciliation": { + "resolved_refs": ["DEC-TECH-MINIMAL", "OBJ-VALUE-001", "IF-LIB-001", "TC-MIN-001", "VAL-MIN-001"], + "findings": [], + "blocker_owners": {} + }, + "gates": { + "X0_CONTROL_READY": {"status": "READY", "evidence": ["plan.md#repository-topology-minimal"]}, + "X1_DECISIONS_READY": {"status": "READY", "evidence": ["research.md#DEC-TECH-MINIMAL"]}, + "X2A_DESIGN_READY": {"status": "READY", "evidence": ["data-model.md#OBJ-VALUE-001", "contracts/library.json#IF-LIB-001"]}, + "X2B_UIUX_READY": {"status": "N/A", "evidence": ["plan.md#active-lane-matrix-no-ui"]}, + "X2C_TEST_DESIGN_READY": {"status": "READY", "evidence": ["contracts/test/test-conditions.json#TC-MIN-001"]}, + "X2_RECONCILIATION_READY": {"status": "READY", "evidence": ["plan.md#x2-cross-lane-reconciliation"]}, + "X3_VALIDATION_PATHS_READY": {"status": "READY", "evidence": ["quickstart.md#VAL-MIN-001"]} + }, + "plan_output_ready": "READY" +} diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json b/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json new file mode 100644 index 0000000000..e4843fc938 --- /dev/null +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json @@ -0,0 +1,67 @@ +{ + "name": "non-ui single interface", + "repository_mode": "populated", + "lanes": { + "X2-A": {"status": "Required"}, + "X2-B": {"status": "N/A", "reason": "The scoped Spec exposes no user-visible surface."}, + "X2-C": {"status": "Required"} + }, + "artifacts": [ + {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "Scope, gates, reconciliation, and closeout are evidenced."}, + {"path": "research.md", "decision": "Required", "owner": "X1", "content": "Interface and Test decisions are recorded.", "declared_ids": []}, + {"path": "data-model.md", "decision": "Required", "owner": "X2-A", "content": "The command request has no persisted domain state."}, + {"path": "contracts/command.json", "decision": "Required", "owner": "X2-A", "content": "The command input, output, and errors are defined.", "declared_ids": ["IF-001"]}, + {"path": "class-diagram.md", "decision": "N/A", "reason": "One command handler has no cooperating-object relationship to design."}, + {"path": "contracts/sequences.md", "decision": "N/A", "reason": "The command has no observable cross-boundary ordering."}, + {"path": "ui-ux-design.md", "decision": "N/A", "reason": "No UI, visual, responsive, asset, or accessibility delivery applies."}, + {"path": "contracts/test/test-conditions.json", "decision": "Required", "owner": "X2-C", "content": "The command contract condition is complete."}, + {"path": "quickstart.md", "decision": "Required", "owner": "X3", "content": "The contract validation command and oracle are defined."}, + {"path": "test-readiness.md", "decision": "Required", "owner": "X4", "content": "TC-001 has one readiness row."} + ], + "decisions": [ + {"id": "DEC-IF-001", "affected_refs": ["IF-001", "TC-001"]} + ], + "uif_contracts": [], + "test_conditions": { + "contract_type": "speckit.test.conditions.v1", + "feature": "command-status", + "conditions": [{ + "id": "TC-001", + "source_refs": ["FR-001"], + "risk_or_priority": "high", + "levels": ["contract"], + "types": ["functional"], + "techniques": ["contract_testing"], + "execution_mode": "local", + "no_fixture_rationale": "The command input is self-contained.", + "environment_refs": ["ENV-LOCAL"], + "oracle": {"kind": "response", "expected": "status payload matches the interface contract"}, + "evidence_requirement": "captured command output", + "related_refs": ["IF-001"], + "quickstart_ref": "VAL-001", + "status": "required" + }] + }, + "technique_children": {"bdd": [], "scenario": [], "fixture": [], "assertion": []}, + "test_readiness_rows": [ + {"tc_id": "TC-001", "status": "READY", "evidence": "quickstart.md#VAL-001"} + ], + "validation_paths": [ + {"id": "VAL-001", "covered_refs": ["TC-001", "IF-001"]} + ], + "reconciliation": { + "resolved_refs": ["DEC-IF-001", "IF-001", "TC-001", "VAL-001"], + "findings": [], + "blocker_owners": {} + }, + "gates": { + "X0_CONTROL_READY": {"status": "READY", "evidence": ["plan.md#x0-feature-plan-control"]}, + "X1_DECISIONS_READY": {"status": "READY", "evidence": ["research.md#DEC-IF-001"]}, + "X2A_DESIGN_READY": {"status": "READY", "evidence": ["contracts/command.json#IF-001"]}, + "X2B_UIUX_READY": {"status": "N/A", "evidence": ["plan.md#active-lane-matrix-no-ui"]}, + "X2C_TEST_DESIGN_READY": {"status": "READY", "evidence": ["contracts/test/test-conditions.json#TC-001"]}, + "X2_RECONCILIATION_READY": {"status": "READY", "evidence": ["plan.md#x2-cross-lane-reconciliation"]}, + "X3_VALIDATION_PATHS_READY": {"status": "READY", "evidence": ["quickstart.md#VAL-001"]} + }, + "plan_output_ready": "READY" +} diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json b/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json new file mode 100644 index 0000000000..d4a090a028 --- /dev/null +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json @@ -0,0 +1,69 @@ +{ + "name": "ui-only", + "repository_mode": "populated", + "lanes": { + "X2-A": {"status": "N/A", "reason": "The change is local presentation state with no domain or interface design."}, + "X2-B": {"status": "Required"}, + "X2-C": {"status": "Required"} + }, + "artifacts": [ + {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "UI-only scope, gates, reconciliation, and closeout are evidenced."}, + {"path": "research.md", "decision": "Required", "owner": "X1", "content": "UI state and component Test decisions are recorded."}, + {"path": "data-model.md", "decision": "N/A", "reason": "No domain concepts or persistence semantics change."}, + {"path": "class-diagram.md", "decision": "N/A", "reason": "No object responsibility relationship is introduced."}, + {"path": "contracts/sequences.md", "decision": "N/A", "reason": "The local interaction has no cross-boundary ordering."}, + {"path": "ui-ux-design.md", "decision": "Required", "owner": "X2-B", "content": "The banner component, states, responsive behavior, accessibility, variants, and fallback are defined."}, + {"path": "contracts/uif/banner.expected.json", "decision": "Required", "owner": "X2-B", "content": "The dismiss interaction and resulting hidden state are defined."}, + {"path": "contracts/test/test-conditions.json", "decision": "Required", "owner": "X2-C", "content": "The component behavior condition is complete."}, + {"path": "quickstart.md", "decision": "Required", "owner": "X3", "content": "The component interaction path and semantic oracle are defined."}, + {"path": "test-readiness.md", "decision": "Required", "owner": "X4", "content": "TC-UI-001 has one readiness row."} + ], + "decisions": [ + {"id": "DEC-UI-001", "affected_refs": ["UIF-001", "TC-UI-001"]} + ], + "uif_contracts": [ + {"id": "UIF-001", "source_refs": ["SRC-UI-001"], "requirement_refs": ["UI-001", "VIS-001"], "related_refs": []} + ], + "test_conditions": { + "contract_type": "speckit.test.conditions.v1", + "feature": "dismissible-banner", + "conditions": [{ + "id": "TC-UI-001", + "source_refs": ["UI-001", "VIS-001"], + "risk_or_priority": "medium", + "levels": ["component"], + "types": ["functional", "accessibility"], + "techniques": ["interaction_contract"], + "execution_mode": "local", + "no_fixture_rationale": "The component starts from deterministic local props.", + "environment_refs": ["ENV-COMPONENT"], + "oracle": {"kind": "state_and_accessibility", "expected": "dismiss hides the banner and focus remains valid"}, + "evidence_requirement": "component test output", + "related_refs": ["UIF-001"], + "quickstart_ref": "VAL-UI-001", + "status": "required" + }] + }, + "technique_children": {"bdd": [], "scenario": [], "fixture": [], "assertion": []}, + "test_readiness_rows": [ + {"tc_id": "TC-UI-001", "status": "READY", "evidence": "quickstart.md#VAL-UI-001"} + ], + "validation_paths": [ + {"id": "VAL-UI-001", "covered_refs": ["TC-UI-001", "UIF-001"]} + ], + "reconciliation": { + "resolved_refs": ["DEC-UI-001", "UIF-001", "TC-UI-001", "VAL-UI-001"], + "findings": [], + "blocker_owners": {} + }, + "gates": { + "X0_CONTROL_READY": {"status": "READY", "evidence": ["plan.md#x0-feature-plan-control"]}, + "X1_DECISIONS_READY": {"status": "READY", "evidence": ["research.md#DEC-UI-001"]}, + "X2A_DESIGN_READY": {"status": "N/A", "evidence": ["plan.md#active-lane-matrix-local-ui-only"]}, + "X2B_UIUX_READY": {"status": "READY", "evidence": ["ui-ux-design.md#delivery-readiness", "contracts/uif/banner.expected.json#UIF-001"]}, + "X2C_TEST_DESIGN_READY": {"status": "READY", "evidence": ["contracts/test/test-conditions.json#TC-UI-001"]}, + "X2_RECONCILIATION_READY": {"status": "READY", "evidence": ["plan.md#x2-cross-lane-reconciliation"]}, + "X3_VALIDATION_PATHS_READY": {"status": "READY", "evidence": ["quickstart.md#VAL-UI-001"]} + }, + "plan_output_ready": "READY" +} diff --git a/presets/workflow-preset/tests/test_preset_contract.py b/presets/workflow-preset/tests/test_preset_contract.py index 0865badf36..5295036671 100644 --- a/presets/workflow-preset/tests/test_preset_contract.py +++ b/presets/workflow-preset/tests/test_preset_contract.py @@ -15,6 +15,7 @@ audit_source_reference_contract, ) from validators.speckit_behavior_contract import validate_behavior_contract_bundle +from validators.speckit_plan_contract import validate_plan_artifact_bundle from validators.speckit_test_contract import ( validate_test_conditions, validate_test_readiness, @@ -32,6 +33,7 @@ AGENTS = ROOT / "AGENTS.md" CROSS_AGENT = ROOT / "tests" / "contracts" / "speckit-cross-agent-protocol.md" ARTIFACT_WORKFLOW = ROOT / ".github" / "workflows" / "preset-artifact.yml" +PLAN_BUNDLE_FIXTURES = ROOT / "tests" / "fixtures" / "plan_bundles" def read(path: Path) -> str: @@ -454,6 +456,47 @@ def test_plan_nests_x0_x4_in_core_without_new_cross_command_gate(self) -> None: self.assertIn("validates Plan outputs only", command) self.assertNotIn("Architecture Conformance Gate", command) + def test_plan_has_deterministic_gates_reconciliation_and_resume(self) -> None: + command = read(COMMANDS / "speckit.plan.md") + for term in ( + "Deterministic Execution Spine", + "Entry conditions", + "Bounded reads", + "Owned writes", + "Failure handling", + "Ownership And Conditional Artifact Decisions", + "X2 — Cross-Lane Reconciliation", + "X2_RECONCILIATION_READY", + "Continuation And Resume", + "first Gate whose evidence is absent", + "never unconditionally overwrite", + "Derive `PLAN_OUTPUT_READY`", + ): + self.assertIn(term, command) + for work_unit_field in ( + "assigned_scope", + "allowed_reads", + "allowed_writes", + "required_outputs", + "validation_gate", + "blockers", + "context_gaps", + ): + self.assertIn(work_unit_field, command) + + def test_plan_conditional_decision_table_covers_contextual_outputs(self) -> None: + command = read(COMMANDS / "speckit.plan.md") + for term in ( + "`class-diagram.md`", + "`contracts/sequences.md`", + "X2-B + `ui-ux-design.md`/UIF", + "BDD/scenario child", + "fixture child", + "assertion child", + "never infer N/A merely because an artifact is absent", + ): + self.assertIn(term, command) + def test_plan_control_template_has_lanes_navigation_and_closeout(self) -> None: template = read(TEMPLATES / "plan-template.md") for term in ( @@ -465,6 +508,8 @@ def test_plan_control_template_has_lanes_navigation_and_closeout(self) -> None: "Design Object Derivation Index", "X4 Closeout Summary", "PLAN_OUTPUT_READY", + "X2 Cross-Lane Reconciliation", + "Resume Checkpoint", ): self.assertIn(term, template) self.assertIn("Repository Topology", template) @@ -487,6 +532,16 @@ def test_plan_artifact_templates_have_non_overlapping_ownership(self) -> None: self.assertIn("Cleanup/reset", quickstart) self.assertIn("Every required `TC-*` has exactly one row", readiness) self.assertIn("MUST NOT appear", readiness) + readiness_table = [ + line for line in readiness.splitlines() if line.startswith("| TC ID") + ][0] + readiness_separator = [ + line for line in readiness.splitlines() if line.startswith("|---") + ][0] + self.assertEqual( + readiness_table.count("|"), + readiness_separator.count("|"), + ) def test_removed_behavior_parents_are_not_packaged(self) -> None: for path in ( @@ -499,6 +554,250 @@ def test_removed_behavior_parents_are_not_packaged(self) -> None: self.assertFalse(path.exists(), path) +class PlanBundleSemanticTests(unittest.TestCase): + def test_four_representative_plan_bundles_are_closed(self) -> None: + fixtures = sorted(PLAN_BUNDLE_FIXTURES.glob("*.json")) + self.assertEqual( + { + "async_retry_compensation.json", + "minimal_repository.json", + "non_ui_single_interface.json", + "ui_only.json", + }, + {path.name for path in fixtures}, + ) + for fixture in fixtures: + with self.subTest(bundle=fixture.name): + validate_plan_artifact_bundle(load_json(fixture)) + + def test_plan_bundle_rejects_n_a_without_reason_and_missing_children(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + del bundle["artifacts"][5]["reason"] + with self.assertRaisesRegex(ValueError, "N/A artifact missing reason"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["artifacts"] = [ + artifact + for artifact in bundle["artifacts"] + if artifact["path"] != "quickstart.md" + ] + with self.assertRaisesRegex(ValueError, "missing required output"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "async_retry_compensation.json") + bundle["technique_children"]["assertion"] = [] + with self.assertRaisesRegex(ValueError, "technique-triggered assertion"): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_rejects_ref_drift_placeholder_and_false_ready(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["test_conditions"]["conditions"][0]["related_refs"] = ["UIF-RENAMED"] + with self.assertRaisesRegex(ValueError, "unresolved internal refs"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "minimal_repository.json") + bundle["artifacts"][0]["content"] = "TODO decide scope" + with self.assertRaisesRegex(ValueError, "unresolved placeholder"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["gates"]["X2_RECONCILIATION_READY"] = { + "status": "BLOCKED", + "evidence": ["BLK-REF-001"], + "blockers": ["BLK-REF-001"], + } + bundle["reconciliation"]["blocker_owners"] = { + "BLK-REF-001": "X2 reconciliation" + } + with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_READY is inconsistent"): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_validates_test_readiness_rows(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["test_readiness_rows"] = [] + with self.assertRaisesRegex(ValueError, "test readiness TC mismatch"): + validate_plan_artifact_bundle(bundle) + + def test_blocked_test_readiness_row_blocks_plan_output(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["test_readiness_rows"][0] = { + "tc_id": "TC-001", + "status": "BLOCKED", + "blocker": "BLK-TEST-ENV-001", + } + bundle["reconciliation"]["blocker_owners"] = { + "BLK-TEST-ENV-001": "X4" + } + with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_READY is inconsistent"): + validate_plan_artifact_bundle(bundle) + + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_blocked_test_condition_blocks_plan_output(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + condition = bundle["test_conditions"]["conditions"][0] + condition["status"] = "blocked" + condition["blocker"] = "BLK-TC-001" + bundle["test_readiness_rows"] = [] + bundle["reconciliation"]["blocker_owners"] = {"BLK-TC-001": "X2-C"} + with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_READY is inconsistent"): + validate_plan_artifact_bundle(bundle) + + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_allows_blocked_x2b_without_relabeling_it_n_a(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["lanes"]["X2-B"] = { + "status": "Blocked", + "blocker": "BLK-UI-001", + } + uiux = next( + artifact + for artifact in bundle["artifacts"] + if artifact["path"] == "ui-ux-design.md" + ) + uiux.clear() + uiux.update( + { + "path": "ui-ux-design.md", + "decision": "Blocked", + "owner": "X2-B", + "blocker": "BLK-UI-001", + } + ) + bundle["gates"]["X2B_UIUX_READY"] = { + "status": "BLOCKED", + "evidence": ["BLK-UI-001"], + "blockers": ["BLK-UI-001"], + } + bundle["reconciliation"]["blocker_owners"] = {"BLK-UI-001": "X2-B"} + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_blocked_x2c_preserves_unaffected_required_outputs(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["lanes"]["X2-C"] = { + "status": "Blocked", + "blocker": "BLK-X2C-001", + } + readiness = next( + artifact + for artifact in bundle["artifacts"] + if artifact["path"] == "test-readiness.md" + ) + readiness.clear() + readiness.update( + { + "path": "test-readiness.md", + "decision": "Blocked", + "owner": "X4", + "blocker": "BLK-READINESS-001", + } + ) + bundle["test_readiness_rows"] = [] + bundle["gates"]["X2C_TEST_DESIGN_READY"] = { + "status": "BLOCKED", + "evidence": ["BLK-X2C-001"], + "blockers": ["BLK-X2C-001"], + } + bundle["reconciliation"]["blocker_owners"] = { + "BLK-X2C-001": "X2-C", + "BLK-READINESS-001": "X4", + } + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_required_x2a_allows_pure_domain_design_without_interface(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "minimal_repository.json") + bundle["artifacts"] = [ + artifact + for artifact in bundle["artifacts"] + if artifact["path"] != "contracts/library.json" + ] + bundle["decisions"][0]["affected_refs"].remove("IF-LIB-001") + bundle["test_conditions"]["conditions"][0]["related_refs"].remove( + "IF-LIB-001" + ) + bundle["validation_paths"][0]["covered_refs"].remove("IF-LIB-001") + bundle["reconciliation"]["resolved_refs"].remove("IF-LIB-001") + bundle["gates"]["X2A_DESIGN_READY"]["evidence"] = [ + "data-model.md#OBJ-VALUE-001" + ] + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_allows_x2c_and_x3_n_a_with_reasons(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["lanes"]["X2-C"] = { + "status": "N/A", + "reason": "The scoped documentation-only change has no Test obligation.", + } + for artifact in bundle["artifacts"]: + if artifact["path"] in { + "contracts/test/test-conditions.json", + "quickstart.md", + "test-readiness.md", + }: + path = artifact["path"] + artifact.clear() + artifact.update( + { + "path": path, + "decision": "N/A", + "reason": "No Test or runnable validation obligation applies.", + } + ) + bundle["decisions"][0]["affected_refs"] = ["IF-001"] + bundle.pop("test_conditions") + bundle.pop("technique_children") + bundle.pop("test_readiness_rows") + bundle["validation_paths"] = [] + bundle["reconciliation"]["resolved_refs"] = ["DEC-IF-001", "IF-001"] + bundle["gates"]["X2C_TEST_DESIGN_READY"] = { + "status": "N/A", + "evidence": ["plan.md#active-lane-matrix-no-test"], + } + bundle["gates"]["X3_VALIDATION_PATHS_READY"] = { + "status": "N/A", + "evidence": ["plan.md#active-lane-matrix-no-validation-path"], + } + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_allows_pixel_delivery_language_only_in_uiux(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["artifacts"][5][ + "content" + ] = "Pixel-perfect delivery and visual diff review remain owned by UI/UX." + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_rejects_pixel_test_scope(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["test_conditions"]["conditions"][0][ + "evidence_requirement" + ] = "screenshot diff" + with self.assertRaisesRegex(ValueError, "pixel-level visual scope"): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_rejects_unknown_plan_output_state(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") + bundle["gates"]["X2_RECONCILIATION_READY"] = { + "status": "BLOCKED", + "evidence": ["BLK-REF-001"], + "blockers": ["BLK-REF-001"], + } + bundle["reconciliation"]["blocker_owners"] = { + "BLK-REF-001": "X2 reconciliation" + } + bundle["plan_output_ready"] = "NOT_A_STATE" + with self.assertRaisesRegex( + ValueError, + "PLAN_OUTPUT_READY must be READY or BLOCKED", + ): + validate_plan_artifact_bundle(bundle) + + class SchemaAndValidatorTests(unittest.TestCase): def test_all_json_artifacts_parse_and_all_schemas_are_valid(self) -> None: for path in [*SCHEMAS.glob("*.json"), *TEMPLATES.rglob("*.json")]: @@ -534,7 +833,16 @@ def test_bdd_requires_child_and_readiness_has_one_row_per_required_tc(self) -> N with self.assertRaisesRegex(ValueError, "no BDD child"): validate_test_conditions(payload) validate_test_conditions(payload, available_bdd_tc_refs={"TC-001"}) - validate_test_readiness(payload, [{"tc_id": "TC-001"}]) + validate_test_readiness( + payload, + [ + { + "tc_id": "TC-001", + "status": "READY", + "evidence": "quickstart.md#VAL-001", + } + ], + ) with self.assertRaisesRegex(ValueError, "TC mismatch"): validate_test_readiness(payload, []) @@ -883,6 +1191,9 @@ def test_release_workflow_smokes_install_and_preserves_core_implement(self) -> N "specify preset resolve plan-template", "test -f .claude/skills/speckit-implement/SKILL.md", "test ! -e .specify/presets/workflow-preset/commands/speckit.implement.md", + "validators/speckit_plan_contract.py", + "tests/fixtures/plan_bundles/minimal_repository.json", + "tests/fixtures/plan_bundles/ui_only.json", ): self.assertIn(term, workflow) diff --git a/presets/workflow-preset/validators/speckit_plan_contract.py b/presets/workflow-preset/validators/speckit_plan_contract.py new file mode 100644 index 0000000000..34b4a06404 --- /dev/null +++ b/presets/workflow-preset/validators/speckit_plan_contract.py @@ -0,0 +1,381 @@ +"""Pure in-memory semantic checks for representative Plan artifact bundles.""" +from __future__ import annotations + +from typing import Any, Iterable + +from validators.speckit_test_contract import ( + validate_test_conditions, + validate_test_readiness, +) + + +GATES = ( + "X0_CONTROL_READY", + "X1_DECISIONS_READY", + "X2A_DESIGN_READY", + "X2B_UIUX_READY", + "X2C_TEST_DESIGN_READY", + "X2_RECONCILIATION_READY", + "X3_VALIDATION_PATHS_READY", +) +INTERNAL_REF_PREFIXES = ("DEC-", "OBJ-", "IF-", "SEQ-", "UIF-", "TC-", "VAL-") +PLACEHOLDERS = ("[placeholder]", "", "TODO", "TBD") + + +def _duplicates(values: Iterable[str]) -> set[str]: + seen: set[str] = set() + duplicates: set[str] = set() + for value in values: + if value in seen: + duplicates.add(value) + seen.add(value) + return duplicates + + +def _strings(value: Any) -> Iterable[str]: + if isinstance(value, dict): + for item in value.values(): + yield from _strings(item) + elif isinstance(value, list): + for item in value: + yield from _strings(item) + elif isinstance(value, str): + yield value + + +def _validate_artifact_decisions(artifacts: list[dict[str, Any]]) -> None: + paths = [artifact.get("path") for artifact in artifacts] + if any(not isinstance(path, str) or not path for path in paths): + raise ValueError("every Plan artifact decision requires a path") + if _duplicates(paths): + raise ValueError("Plan artifact decisions contain duplicate paths") + + for artifact in artifacts: + path = artifact["path"] + decision = artifact.get("decision") + if decision == "Required": + if not artifact.get("owner") or not artifact.get("content"): + raise ValueError(f"required artifact missing owner/content: {path}") + elif decision == "N/A": + if not artifact.get("reason"): + raise ValueError(f"N/A artifact missing reason: {path}") + if artifact.get("content"): + raise ValueError(f"N/A artifact unexpectedly has content: {path}") + elif decision == "Blocked": + if not artifact.get("owner") or not artifact.get("blocker"): + raise ValueError(f"blocked artifact missing owner/blocker: {path}") + else: + raise ValueError(f"artifact has invalid decision: {path}") + + +def _validate_technique_children( + conditions: dict[str, Any], + children: dict[str, list[str]], +) -> None: + for condition in conditions["conditions"]: + tc_id = condition["id"] + techniques = {str(item).casefold() for item in condition["techniques"]} + required_children: set[str] = set() + if "bdd" in techniques: + required_children.add("bdd") + if techniques & {"scenario", "state_transition", "structured_scenario"}: + required_children.add("scenario") + if condition.get("fixture_refs"): + required_children.add("fixture") + if techniques & {"scenario", "state_transition", "structured_assertion"}: + required_children.add("assertion") + for child in required_children: + if tc_id not in children.get(child, []): + raise ValueError(f"{tc_id} missing technique-triggered {child} child") + + +def validate_plan_artifact_bundle(bundle: dict[str, Any]) -> None: + """Validate Required/N/A decisions, refs, gates, and derived readiness.""" + + lanes = bundle.get("lanes") + if not isinstance(lanes, dict) or set(lanes) != {"X2-A", "X2-B", "X2-C"}: + raise ValueError("Plan bundle must decide X2-A, X2-B, and X2-C") + for lane, decision in lanes.items(): + status = decision.get("status") + if status == "N/A" and not decision.get("reason"): + raise ValueError(f"{lane} N/A missing reason") + if status == "Blocked" and not decision.get("blocker"): + raise ValueError(f"{lane} blocked missing blocker") + if status not in {"Required", "N/A", "Blocked"}: + raise ValueError(f"{lane} has invalid applicability") + + artifacts = bundle.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise ValueError("Plan bundle must include artifact decisions") + _validate_artifact_decisions(artifacts) + artifacts_by_path = {artifact["path"]: artifact for artifact in artifacts} + for required_path in ("plan.md", "research.md"): + if artifacts_by_path.get(required_path, {}).get("decision") != "Required": + raise ValueError(f"Plan bundle missing required output: {required_path}") + for contextual_path in ("class-diagram.md", "contracts/sequences.md"): + if contextual_path not in artifacts_by_path: + raise ValueError( + f"Plan bundle missing contextual artifact decision: {contextual_path}" + ) + if lanes["X2-A"]["status"] == "Required": + if artifacts_by_path.get("data-model.md", {}).get("decision") != "Required": + raise ValueError("active X2-A missing required data-model.md") + x2b_status = lanes["X2-B"]["status"] + uiux_decision = artifacts_by_path.get("ui-ux-design.md", {}).get("decision") + if x2b_status == "Required": + if artifacts_by_path.get("ui-ux-design.md", {}).get("decision") != "Required": + raise ValueError("active X2-B missing required ui-ux-design.md") + if not bundle.get("uif_contracts"): + raise ValueError("active X2-B missing required UIF contract") + elif x2b_status == "N/A": + if uiux_decision != "N/A": + raise ValueError("N/A X2-B must record ui-ux-design.md N/A") + elif uiux_decision not in {"Required", "Blocked"}: + raise ValueError( + "blocked X2-B must preserve or block ui-ux-design.md, not mark it N/A" + ) + + x2c_status = lanes["X2-C"]["status"] + x2c_paths = ( + "contracts/test/test-conditions.json", + "quickstart.md", + "test-readiness.md", + ) + x2c_decisions = { + path: artifacts_by_path.get(path, {}).get("decision") for path in x2c_paths + } + if x2c_status == "N/A": + for path, decision in x2c_decisions.items(): + if decision != "N/A": + raise ValueError( + f"N/A X2-C must record {path} as N/A" + ) + if bundle.get("test_conditions") not in (None, {}): + raise ValueError("N/A X2-C must not carry Test Conditions") + if bundle.get("test_readiness_rows") not in (None, []): + raise ValueError("N/A X2-C must not carry Test Readiness rows") + if bundle.get("technique_children") not in (None, {}): + raise ValueError("N/A X2-C must not carry technique children") + if bundle.get("validation_paths") not in (None, []): + raise ValueError("N/A X2-C must not carry VAL paths") + else: + allowed_decisions = ( + {"Required"} if x2c_status == "Required" else {"Required", "Blocked"} + ) + for path, decision in x2c_decisions.items(): + if decision not in allowed_decisions: + if x2c_status == "Required": + raise ValueError(f"active X2-C missing required output: {path}") + raise ValueError( + f"Blocked X2-C must record {path} as Required/Blocked" + ) + + conditions_decision = x2c_decisions["contracts/test/test-conditions.json"] + if conditions_decision == "Required": + conditions = bundle.get("test_conditions") + if not isinstance(conditions, dict): + raise ValueError("required Test Conditions artifact missing content") + bdd_refs = set(bundle.get("technique_children", {}).get("bdd", [])) + validate_test_conditions( + conditions, + available_bdd_tc_refs=bdd_refs or None, + ) + _validate_technique_children( + conditions, + bundle.get("technique_children", {}), + ) + condition_records = conditions["conditions"] + else: + if bundle.get("test_conditions") not in (None, {}): + raise ValueError("non-required Test Conditions artifact must not carry content") + if bundle.get("technique_children") not in (None, {}): + raise ValueError( + "non-required Test Conditions artifact must not carry technique children" + ) + condition_records = [] + + readiness_decision = x2c_decisions["test-readiness.md"] + if readiness_decision == "Required": + if conditions_decision != "Required": + raise ValueError("required Test Readiness depends on required Test Conditions") + readiness_rows = bundle.get("test_readiness_rows") + if not isinstance(readiness_rows, list): + raise ValueError("required Test Readiness missing structured rows") + validate_test_readiness(conditions, readiness_rows) + else: + if bundle.get("test_readiness_rows") not in (None, []): + raise ValueError("non-required Test Readiness must not carry rows") + + quickstart_decision = x2c_decisions["quickstart.md"] + if quickstart_decision != "Required": + if bundle.get("validation_paths") not in (None, []): + raise ValueError("non-required quickstart must not carry VAL paths") + + declared_ids: list[str] = [] + declared_ids.extend( + decision.get("id", "") for decision in bundle.get("decisions", []) + ) + for artifact in artifacts: + declared_ids.extend(artifact.get("declared_ids", [])) + declared_ids.extend( + contract.get("id", "") for contract in bundle.get("uif_contracts", []) + ) + for contract in bundle.get("uif_contracts", []): + if not contract.get("source_refs") or not contract.get("requirement_refs"): + raise ValueError(f"{contract.get('id', 'UIF')} missing source/UI mappings") + if any( + not str(ref).startswith("SRC-") + for ref in contract["source_refs"] + ): + raise ValueError(f"{contract['id']} has invalid source mapping") + if any( + not str(ref).startswith(("UI-", "VIS-")) + for ref in contract["requirement_refs"] + ): + raise ValueError(f"{contract['id']} has invalid UI/VIS mapping") + declared_ids.extend( + condition["id"] for condition in condition_records + ) + declared_ids.extend( + path.get("id", "") for path in bundle.get("validation_paths", []) + ) + if any(not item for item in declared_ids) or _duplicates(declared_ids): + raise ValueError("Plan bundle has missing or duplicate stable IDs") + known_ids = set(declared_ids) + + consumed_refs: set[str] = set() + for decision in bundle.get("decisions", []): + consumed_refs.update(decision.get("affected_refs", [])) + for condition in condition_records: + consumed_refs.update(condition.get("related_refs", [])) + if condition.get("quickstart_ref"): + consumed_refs.add(condition["quickstart_ref"]) + for contract in bundle.get("uif_contracts", []): + consumed_refs.update(contract.get("related_refs", [])) + for path in bundle.get("validation_paths", []): + consumed_refs.update(path.get("covered_refs", [])) + + unresolved = sorted( + ref + for ref in consumed_refs + if ref.startswith(INTERNAL_REF_PREFIXES) and ref not in known_ids + ) + if unresolved: + raise ValueError(f"Plan bundle has unresolved internal refs: {unresolved}") + + reconciliation = bundle.get("reconciliation") + if not isinstance(reconciliation, dict): + raise ValueError("Plan bundle missing X2 reconciliation") + if reconciliation.get("findings"): + raise ValueError("Plan bundle reconciliation has unresolved findings") + blocker_owners = reconciliation.get("blocker_owners") + if not isinstance(blocker_owners, dict): + raise ValueError("Plan bundle reconciliation missing blocker owners") + for lane, decision in lanes.items(): + blocker = decision.get("blocker") + if blocker and blocker_owners.get(blocker) != lane: + raise ValueError(f"{blocker} has incorrect owning lane") + for artifact in artifacts: + blocker = artifact.get("blocker") + if blocker and blocker_owners.get(blocker) != artifact.get("owner"): + raise ValueError(f"{blocker} has incorrect artifact owner") + for condition in condition_records: + for blocker_field in ("blocker", "x3_blocker"): + blocker = condition.get(blocker_field) + if blocker and blocker not in blocker_owners: + raise ValueError( + f"{condition['id']} {blocker_field} missing reconciliation owner" + ) + for row in bundle.get("test_readiness_rows", []): + blocker = row.get("blocker") + if blocker and blocker not in blocker_owners: + raise ValueError( + f"{row['tc_id']} Test Readiness blocker missing reconciliation owner" + ) + resolved_refs = set(reconciliation.get("resolved_refs", [])) + missing_reconciled = sorted( + ref + for ref in known_ids | consumed_refs + if ref.startswith(INTERNAL_REF_PREFIXES) and ref not in resolved_refs + ) + if missing_reconciled: + raise ValueError( + f"Plan bundle refs absent from reconciliation: {missing_reconciled}" + ) + + validation_ids = { + path["id"] for path in bundle.get("validation_paths", []) + } + for condition in condition_records: + quickstart_ref = condition.get("quickstart_ref") + if quickstart_ref and quickstart_ref not in validation_ids: + raise ValueError(f"{condition['id']} references missing VAL path") + + gates = bundle.get("gates") + if not isinstance(gates, dict) or set(gates) != set(GATES): + raise ValueError("Plan bundle must record every internal Gate") + for gate, record in gates.items(): + status = record.get("status") + evidence = record.get("evidence") + if status not in {"READY", "N/A", "BLOCKED"}: + raise ValueError(f"{gate} has invalid status") + if not isinstance(evidence, list) or not evidence: + raise ValueError(f"{gate} missing concrete evidence") + if status == "BLOCKED" and not record.get("blockers"): + raise ValueError(f"{gate} blocked without blocker evidence") + for blocker in record.get("blockers", []): + if blocker not in blocker_owners: + raise ValueError(f"{gate} blocker missing reconciliation owner") + if gate not in { + "X2A_DESIGN_READY", + "X2B_UIUX_READY", + "X2C_TEST_DESIGN_READY", + "X3_VALIDATION_PATHS_READY", + } and status == "N/A": + raise ValueError(f"{gate} cannot be N/A") + for lane, gate in ( + ("X2-A", "X2A_DESIGN_READY"), + ("X2-B", "X2B_UIUX_READY"), + ("X2-C", "X2C_TEST_DESIGN_READY"), + ): + expected = { + "Required": "READY", + "N/A": "N/A", + "Blocked": "BLOCKED", + }[lanes[lane]["status"]] + if gates[gate]["status"] != expected: + raise ValueError(f"{gate} is inconsistent with {lane} applicability") + x3_status = gates["X3_VALIDATION_PATHS_READY"]["status"] + if x2c_status == "N/A" and x3_status != "N/A": + raise ValueError("X3 must be N/A when X2-C is N/A") + if x2c_status != "N/A" and x3_status == "N/A": + raise ValueError("X3 cannot be N/A when X2-C is active or blocked") + + text = " ".join(_strings(bundle)) + normalized = text.casefold() + if any(token.casefold() in normalized for token in PLACEHOLDERS): + raise ValueError("Plan bundle contains unresolved placeholder") + + derived_ready = ( + all(record["status"] in {"READY", "N/A"} for record in gates.values()) + and not reconciliation.get("findings") + and all(lane["status"] != "Blocked" for lane in lanes.values()) + and all( + artifact["decision"] != "Blocked" + for artifact in artifacts + ) + and all( + row.get("status") == "READY" + for row in bundle.get("test_readiness_rows", []) + ) + and all( + condition.get("status") == "required" + for condition in condition_records + ) + ) + claimed_state = bundle.get("plan_output_ready") + if claimed_state not in {"READY", "BLOCKED"}: + raise ValueError("PLAN_OUTPUT_READY must be READY or BLOCKED") + claimed_ready = claimed_state == "READY" + if claimed_ready != derived_ready: + raise ValueError("PLAN_OUTPUT_READY is inconsistent with Gate evidence") diff --git a/presets/workflow-preset/validators/speckit_test_contract.py b/presets/workflow-preset/validators/speckit_test_contract.py index 4dab5dd2e4..bbf30d34f0 100644 --- a/presets/workflow-preset/validators/speckit_test_contract.py +++ b/presets/workflow-preset/validators/speckit_test_contract.py @@ -93,7 +93,10 @@ def validate_test_conditions( if not condition.get("evidence_requirement"): raise ValueError(f"{condition_id} missing evidence_requirement") - if condition.get("status") == "blocked" and not condition.get("blocker"): + status = condition.get("status") + if status not in {"required", "blocked"}: + raise ValueError(f"{condition_id} has invalid Test Condition status") + if status == "blocked" and not condition.get("blocker"): raise ValueError(f"{condition_id} blocked condition missing blocker") if _contains_pixel_scope(condition): @@ -122,5 +125,14 @@ def validate_test_readiness( missing = sorted(required_ids - set(row_ids)) extra = sorted(set(row_ids) - required_ids) raise ValueError(f"test readiness TC mismatch missing={missing} extra={extra}") + for row in readiness_rows: + tc_id = row["tc_id"] + status = row.get("status") + if status not in {"READY", "BLOCKED"}: + raise ValueError(f"{tc_id} has invalid Test Readiness status") + if status == "READY" and not row.get("evidence"): + raise ValueError(f"{tc_id} READY Test Readiness missing evidence") + if status == "BLOCKED" and not row.get("blocker"): + raise ValueError(f"{tc_id} BLOCKED Test Readiness missing blocker") if _contains_pixel_scope(readiness_rows): raise ValueError("test readiness contains pixel-level visual scope") diff --git a/tests/test_presets.py b/tests/test_presets.py index 3188115d97..c8e480dbe3 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4651,7 +4651,7 @@ def test_workflow_preset_catalog_matches_manifest(self): assert catalog_updated_at >= datetime(2026, 6, 18, tzinfo=timezone.utc) assert entry["bundled"] is True - assert entry["version"] == "3.1.0" + assert entry["version"] == "3.1.1" assert entry["version"] == manifest["preset"]["version"] assert entry["repository"] == manifest["preset"]["repository"] assert entry["requires"]["speckit_version"] == manifest["requires"]["speckit_version"]