From f55f6049e13acc5d6657a00df9b3782ccc9de1b0 Mon Sep 17 00:00:00 2001 From: bigsmartben <245982990@qq.com> Date: Fri, 31 Jul 2026 11:22:15 +0800 Subject: [PATCH 1/2] Update workflow-preset to v3.2.1 Assisted-by: Codex --- presets/catalog.community.json | 8 +- presets/catalog.json | 6 +- presets/workflow-preset.release.json | 46 +- presets/workflow-preset/CHANGELOG.md | 17 + presets/workflow-preset/README.md | 39 +- .../commands/speckit.checklist.md | 174 +- .../commands/speckit.clarify.md | 170 +- .../workflow-preset/commands/speckit.plan.md | 83 +- .../commands/speckit.specify.md | 26 +- .../docs/extension-governance.md | 93 +- presets/workflow-preset/preset.yml | 27 +- .../templates/constitution-template.md | 6 +- .../templates/requirements/behavior-gate.md | 21 +- .../templates/requirements/domain-gate.md | 23 +- .../templates/requirements/nfr-gate.md | 18 +- .../templates/requirements/visual-gate.md | 38 +- .../templates/spec-template.md | 41 +- .../issue_50_revision_gap.json | 171 ++ .../tests/test_preset_contract.py | 1238 ++++++++++++- .../speckit_requirement_gate_contract.py | 1525 +++++++++++++++++ tests/integrations/test_cli.py | 2 +- tests/test_presets.py | 2 +- 22 files changed, 3551 insertions(+), 223 deletions(-) create mode 100644 presets/workflow-preset/tests/fixtures/requirement_gates/issue_50_revision_gap.json create mode 100644 presets/workflow-preset/validators/speckit_requirement_gate_contract.py diff --git a/presets/catalog.community.json b/presets/catalog.community.json index bdf168d4ed..e013ae947f 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.2.0", + "version": "3.2.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.2.0/spec-kit-workflow-preset-v3.2.0.zip", + "download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.2.1/spec-kit-workflow-preset-v3.2.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": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", - "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" + "source_commit": "6e80f25d0334f9e71504b46223565a517b2dedb8", + "sha256": "6667df31c2828be89e6d2256c38e60d207749e783c38dd5efb3f3dbe2c15261b" } } } diff --git a/presets/catalog.json b/presets/catalog.json index 1828b087e4..6ebf864fbc 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -29,7 +29,7 @@ "workflow-preset": { "name": "Workflow Preset", "id": "workflow-preset", - "version": "3.2.0", + "version": "3.2.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": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", - "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" + "source_commit": "6e80f25d0334f9e71504b46223565a517b2dedb8", + "sha256": "6667df31c2828be89e6d2256c38e60d207749e783c38dd5efb3f3dbe2c15261b" } } } diff --git a/presets/workflow-preset.release.json b/presets/workflow-preset.release.json index 019abddefb..510650b0ac 100644 --- a/presets/workflow-preset.release.json +++ b/presets/workflow-preset.release.json @@ -1,7 +1,7 @@ { "artifact": { - "name": "spec-kit-workflow-preset-v3.2.0.zip", - "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" + "name": "spec-kit-workflow-preset-v3.2.1.zip", + "sha256": "6667df31c2828be89e6d2256c38e60d207749e783c38dd5efb3f3dbe2c15261b" }, "files": [ { @@ -10,7 +10,7 @@ }, { "path": "CHANGELOG.md", - "sha256": "096623162acd4a80f087359bb9616e576b44fae45c6c014006bddf26084b8ea2" + "sha256": "7181feed95ca3230fb312fb90078a2fdf43c0a435f6a779e184fa1740f12ebab" }, { "path": "LICENSE", @@ -18,7 +18,7 @@ }, { "path": "README.md", - "sha256": "ddd3fd57ab57e98e0ac3d0c7eed0143218d831db9dda5ef627e32c93cc1fc3a0" + "sha256": "68e60cbe43e984193d78f6018fd4f26d648abeb1fd74c615674f66c24b598ab3" }, { "path": "commands/speckit.analyze.md", @@ -26,11 +26,11 @@ }, { "path": "commands/speckit.checklist.md", - "sha256": "ff4452ea7e85fe0be28b0c88d4dd5b29ab5a1dc9738e4f2681d4d99c3cf17814" + "sha256": "d9067b32c8a6f594923b48491a06619e2793b410bff32078fb7d77741e23ed42" }, { "path": "commands/speckit.clarify.md", - "sha256": "e8d5af23f6a1b75d3e1dc45d9f79200f1a61774b6f1007f2d725049ec8d801bb" + "sha256": "fcb8475093a1bae533ea5c7cf3e409efbe1b90c6a645abb197e7289e3787c0ce" }, { "path": "commands/speckit.constitution.md", @@ -38,11 +38,11 @@ }, { "path": "commands/speckit.plan.md", - "sha256": "e82cc984ab6d898ea4358cc1cc7a80d4b32c43fbd89bd408bb8ab41c20acead1" + "sha256": "e57071e8e32f3d725e5e2c47d0d12b60fac6c2a2586fe35ed7faf85ee28dc579" }, { "path": "commands/speckit.specify.md", - "sha256": "19bc99aa51d826721d49c0a4fe406d15d5f20bb6b82038465986824bdd6ae093" + "sha256": "4ed1b9ac9a8d12e77e07464573f5c1da089cbb0c62445fa88a8717ac542e6345" }, { "path": "commands/speckit.tasks.md", @@ -50,11 +50,11 @@ }, { "path": "docs/extension-governance.md", - "sha256": "7a19c10f824371b27e9233f4699ffe8f1bf04326ed93b1061cf04f9d378e0232" + "sha256": "74316df398872e064d6336038ddd2597873e6f84c695b18c7297746992829737" }, { "path": "preset.yml", - "sha256": "97387ed796ade8df30f2385b8864c4b3affd325e30c15554f24f51df1442b94e" + "sha256": "332672bfb6251f963fa69c446826d093f1a7ec367e0d3c0892206fbb5fdabc37" }, { "path": "requirements-dev.txt", @@ -122,7 +122,7 @@ }, { "path": "templates/constitution-template.md", - "sha256": "77a3ce00c81e0a44ce5287a4ce39cde1d8d74e57777ad3a50b1ae599f230eb80" + "sha256": "078d4e60b25dc1f37ebb8d398bec81154418f4666b7ec6cca45bfe83a83dea28" }, { "path": "templates/plan-template.md", @@ -134,19 +134,19 @@ }, { "path": "templates/requirements/behavior-gate.md", - "sha256": "687e14fb6d0262722f813d998dc3b28dc23d44d909d3b425a79f536cdf308af5" + "sha256": "c7f23d2bf223b691971b4d9da89fcaa1fa69168cf12f82d259e20084d838e242" }, { "path": "templates/requirements/domain-gate.md", - "sha256": "537f8998f43160f101f33f3755df08c4faaef72c359672b3be3e01d5aff8bcda" + "sha256": "a2f1138ba7efcb2dab2e7fb7930bd629aaf56d874ccf02cf9b43c72a3e74f539" }, { "path": "templates/requirements/nfr-gate.md", - "sha256": "6c05f7cfad45c9891e932994c09dfc8cd14bebe1aecd99821cf38270a52fbd1f" + "sha256": "c893eb596043de7a2a139bb8938fbfbe531ec49d02afcbfed40f5f21e2f069a7" }, { "path": "templates/requirements/visual-gate.md", - "sha256": "f5d4993d015f726d369de8ede3b0065fe8f5c6412a6b306cacffdc8fb43f41a4" + "sha256": "b2ff3247967865a9d2b10dcdb450a19c9373eb432f96254e6c0b42568345cc01" }, { "path": "templates/sequences-template.md", @@ -154,7 +154,7 @@ }, { "path": "templates/spec-template.md", - "sha256": "db0b7fe137cd48d7c97f037592704629b1b9d60aee8e57692bea6701d6540930" + "sha256": "edb57eb04bc7db6a74ced08c6b05140034c71d045018bdf7242101e7c24aeefc" }, { "path": "templates/test-readiness-template.md", @@ -188,9 +188,13 @@ "path": "tests/fixtures/plan_bundles/ui_only.json", "sha256": "3d3f47c1b5d1064456d6f9c02345cdf045af07066ab627217ef7ca1131576246" }, + { + "path": "tests/fixtures/requirement_gates/issue_50_revision_gap.json", + "sha256": "4959f6b59cc7131a4989daf29109fa84d8a4b69bfded9d3057ecf4ca9a6da17d" + }, { "path": "tests/test_preset_contract.py", - "sha256": "d4182caa33afc838ac9296144d30e1298a7b3b67b7f24c6aca4fc6c3b6a742ea" + "sha256": "a36cdc1da73179b38c946e8db8e454bd8405bb6d58f0d5d871d0ea8b25ef7b14" }, { "path": "validators/__init__.py", @@ -208,6 +212,10 @@ "path": "validators/speckit_plan_contract.py", "sha256": "db08c3417d86a58a16dffc1fe6fed77318752d03c8df07502c538df55db21045" }, + { + "path": "validators/speckit_requirement_gate_contract.py", + "sha256": "ed9e4ff2a2fd869e9e82b904b9d26a767dd6d7632f030a0f6553fa267bedfa15" + }, { "path": "validators/speckit_spec_contract.py", "sha256": "072845c892da168a263c3790df2da3363d03db33effec28b8a2956633e46f3b5" @@ -223,7 +231,7 @@ ], "preset_id": "workflow-preset", "schema_version": "1.0", - "source_commit": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", + "source_commit": "6e80f25d0334f9e71504b46223565a517b2dedb8", "source_repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", - "version": "3.2.0" + "version": "3.2.1" } diff --git a/presets/workflow-preset/CHANGELOG.md b/presets/workflow-preset/CHANGELOG.md index 3a6325639c..ac7c5f94cd 100644 --- a/presets/workflow-preset/CHANGELOG.md +++ b/presets/workflow-preset/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- Replaced the six-file Requirement Gate layout with one canonical + `checklists/requirements.md`, physically grouped by stable Spec semantic refs + and containing six strictly derived logical Gate summaries. +- Added shared root-cause Blockers, stable Spec/Blocker split-merge-retirement + lifecycle, one-question Clarify aggregation, partial/full/zero-question + synchronization, and preserved non-authoritative legacy/advisory files. +- Moved the canonical Gate preflight before hooks, Core write-bearing setup, and + Plan template materialization; stale, malformed, or blocked input now stops + with zero Plan writes and no `planning-readiness.md`. +- Added a self-contained Issue #50 Revision-gap fixture plus installed Core + wrapper composition smoke coverage. +- Hardened the in-memory Gate contract with exact SHA-256 syntax, template Rule + keys, current-Spec evidence/N/A references, closed canonical fields, + placeholder rejection, complete semantic-group coverage, deterministic + Clarify recovery, wrapper compatibility findings, and explicit Spec/Blocker + lifecycle tests. + ## 3.2.0 - 2026-07-30 - Added one canonical `UI-*` specification model with deterministic source, diff --git a/presets/workflow-preset/README.md b/presets/workflow-preset/README.md index 5d868182c1..7e4db4cc02 100644 --- a/presets/workflow-preset/README.md +++ b/presets/workflow-preset/README.md @@ -12,10 +12,10 @@ core(核心命令)负责。 | 命令 | 本预设的职责 | 写入边界 | |---|---|---| | `/speckit.constitution` | 分离治理规则与仓库技术架构 | `constitution.md`、`architecture.md` | -| `/speckit.specify` | 编写完整 WHAT/WHY 需求 | `spec.md` | -| `/speckit.clarify` | 按影响 × 不确定性消除产品歧义 | 仅 `spec.md` | -| `/speckit.checklist` | 用问题形式检查需求写作质量 | `checklists/*.md` | -| `/speckit.plan` | 在 Core Plan 生命周期内完成 X0–X4 | Plan 设计产物 | +| `/speckit.specify` | 编写完整 WHAT/WHY 需求与稳定语义 ID | `spec.md` | +| `/speckit.clarify` | 按共享根因只问一次,并同步复核规划就绪状态 | `spec.md`、已有的 `checklists/requirements.md` | +| `/speckit.checklist` | 生成单一 Requirement Gate(需求门禁),内含六个逻辑维度 | 仅 `checklists/requirements.md` | +| `/speckit.plan` | 在任何写入前只读通过需求门禁,再完成 X0–X4 | Plan 设计产物 | | `/speckit.tasks` | 把已批准的 Plan 记录映射成可执行任务 | 仅 `tasks.md` | | `/speckit.analyze` | 只读审计跨命令追踪链 | 不写文件 | | `/speckit.implement` | 由 Spec Kit core 执行 `tasks.md` | 本预设无覆盖 | @@ -25,7 +25,7 @@ core(核心命令)负责。 ```text Constitution + Architecture ↓ - Spec → Clarify → Checklist + Spec → Checklist → Clarify ↓ Core Plan(内含 X0–X4) ↓ @@ -41,8 +41,31 @@ Single Source of Truth)。功能需求、非功能需求、UX/UI、视觉、 数据、集成、依赖、边界、假设和排除项都使用可选载体;不适用时明确写 N/A。 -`/speckit.specify` 与 `/speckit.clarify` 不生成或修改 checklist。 -`/speckit.checklist` 只提出可回答的问题,不把实现方案写回需求。 +`/speckit.checklist` 只生成一个 `checklists/requirements.md`。文件按 +Spec semantic ref(规范语义标识)分组;requirements、behavior、UX、 +security、NFR、visual 是六个逻辑 Gate(门禁维度),不是六份文件。每个 +Check(检查项)只保存在语义组内,Six-Gate Summary(六门禁汇总)只保存状态、 +引用和数量,不复制问题或产品答案。每个 Check 还保留模板 Rule key(规则键); +PASS 证据用 `spec.md#<语义标识>` 指向当前 Spec。 + +例如,`FR-017` 的“谁能授权注销”可能同时影响 Requirements、Behavior 和 +Security 三个 Check,但它们共同引用一个 `BLK-FR-017-01` 根因;Clarify +只问一次。若“删除哪些云端数据”是另一个缺口,则保留第二个 Blocker,不会因为 +都属于 `FR-017` 而误合并。 + +Specify 的 ID 跟随产品语义,不跟随行号或措辞。只改写文字时保留 ID;拆分、 +合并、退休或 N/A 都记录迁移关系。Clarify 先把答案写回 `spec.md`,再刷新 +Check 证据、共享 Blocker、`Spec Revision`、六门禁汇总与 Planning Readiness +(规划就绪状态)。即使零问题也会复核陈旧 Revision。 + +Plan 只读取当前 `spec.md` 和这一个 `requirements.md`。门禁必须在 hooks、 +Plan 模板实例化和任何 X0–X4 写入之前通过;Revision 陈旧、任一 Gate +BLOCKED、残留 OPEN Blocker 或引用畸形都会以零 Plan 写入停止。已解决、退休或 +被替代的 Blocker 只保留为生命周期历史,不属于当前 Blocker inventory(清单)。 +不会生成单独的 `planning-readiness.md`。 + +旧 advisory checklist(建议性清单)和错误的六文件领域布局会原样保留,但 +Checklist、Clarify 与 Plan 都忽略它们,也不会从中导入产品答案。 例如:退款需求可以同时声明 `FR-001`(退款规则)、`NFR-001`(响应时间)和 `UI-001`(加载/成功/失败状态);若已供应的设计说明缺少结账失败态证据, @@ -154,7 +177,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.2.0/spec-kit-workflow-preset-v3.2.0.zip +specify preset add --from https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.2.1/spec-kit-workflow-preset-v3.2.1.zip ``` 安装后可检查预设信息: diff --git a/presets/workflow-preset/commands/speckit.checklist.md b/presets/workflow-preset/commands/speckit.checklist.md index 5baded34db..fea31de358 100644 --- a/presets/workflow-preset/commands/speckit.checklist.md +++ b/presets/workflow-preset/commands/speckit.checklist.md @@ -1,50 +1,150 @@ --- -description: Generate question-form unit tests for requirement writing without evaluating or repairing the specification. +description: Build the one canonical requirements.md Gate from stable Spec semantic refs. strategy: wrap --- ## Preset Checklist Ownership -`$ARGUMENTS` is an optional focus. Generate either a broad requirements -checklist or a focused domain checklist under `checklists/.md`. - -Checklist reads the current `spec.md` and writes only the selected checklist. -It MUST NOT modify `spec.md`, answer its own questions, ask clarification -questions, invoke Specify/Clarify, aggregate Planning Readiness, compute -PASS/BLOCKED, route blockers, validate IDs/numbering/references, or read Plan and -Tasks as strategy inputs. - -Every checklist item is a question-form “unit test for requirements writing”. -Use checklist-local `CHK-*` IDs as formatting, not as a global consistency -claim. Cite a relevant spec section or append `[Gap]` when the expected -requirement text cannot be located. - -For broad scope, cover completeness, clarity, consistency, measurability, -scenarios, edge/failure cases, NFRs, security/privacy, data/integration, -dependencies, assumptions, exclusions, and success criteria. For focused scope, -generate domain-aware questions without inventing a second specification -schema. - -Source-focused questions may ask whether each `SRC-*` is clearly identified, -feature-scoped, assigned one allowed role, and connected to observable local -requirements or an explicit blocker. Checklist MUST NOT dereference a locator, -acquire missing evidence, validate authenticity/freshness/publication state, or -answer whether an external source is correct. - -Examples: - -```markdown -- [ ] CHK-UX-001 Are empty, loading, error, and recovery states specified for each critical journey? [Completeness] [Spec § UX] -- [ ] CHK-VIS-001 Is “brand-consistent” grounded in an explicit source or observable criterion? [Clarity] [Gap] -- [ ] CHK-NFR-001 Are user-visible performance expectations measurable for the critical path? [Measurability] [Spec § NFR] +This wrapper keeps Core user input, official read-only feature/path resolution, +before/after hooks, and completion behavior. It intentionally narrows Core's +write target: + +```text +any $ARGUMENTS focus + -> exactly FEATURE_DIR/checklists/requirements.md + -> no other checklist output +``` + +`$ARGUMENTS` changes only concern priority and checking depth inside that file. +It never selects a filename or creates an advisory/focus/Domain checklist. +Within the embedded Core template, instructions to write +`checklists/.md`, `checklists/.md`, advisory files, or multiple +Planning Readiness files are superseded by this single-output contract. If the +installed Core cannot honor that override while retaining its lifecycle, stop +with `REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE`; do not generate extra files +and ignore them afterward. + +Checklist reads current `spec.md` and writes only the canonical Requirement +Gate. It MUST NOT modify `spec.md`, accept product decisions, ask clarification +questions, call Clarify or Plan, or read Plan/Tasks as strategy inputs. + +## Canonical Execution + +1. Use Core's official path-only mechanism to resolve `FEATURE_DIR` and + `FEATURE_SPEC`; require the current Spec and stop if Plan already exists. +2. Compute SHA-256 over the exact `spec.md` bytes as + `sha256:`. +3. Inventory every active, replaced, retired, or explicitly N/A stable Spec + semantic ref. Product semantics come only from `spec.md`. +4. Load `templates/requirements/*.md` as rule fragments for the six logical + Gates: `requirements`, `behavior`, `ux`, `security`, `nfr`, and `visual`. + Fragments never become runtime files. +5. Rebuild template-owned content in the one canonical file by stable Spec ref, + Check ID, Blocker ID, and lifecycle relation. Preserve clearly delimited + manual notes byte-for-byte; notes never affect a Check, Gate, or readiness + result. +6. Derive Six-Gate Summary from Check/Blocker records, then derive Planning + Readiness from the current Revision, all six Gate results, and the current + open Blocker inventory. +7. Atomically replace only + `FEATURE_DIR/checklists/requirements.md`. + +Regeneration is idempotent recomputation, not append. Remove duplicate/stale +template-owned records. A wording-only Spec change preserves IDs. Spec or +Blocker split/merge/retirement follows explicit replacement relations; never +silently reassign an old ID to a different meaning. + +## Canonical Document Contract + +The physical document has exactly this owner structure: + +```text +File Metadata + Contract: speckit.requirement-gate.v1 + Stage: requirements + Spec Revision: sha256: + Planning Readiness: PASS | BLOCKED +Semantic Requirement Groups + + cross-Gate Check records + shared root-cause Blocker records +Six-Gate Summary +Planning Readiness +``` + +Each Check record contains: + +```text +Check ID | template Rule key | Gate | atomic concern | Spec refs +| PASS + current spec.md evidence as spec.md# + OR BLOCKED + exactly one shared Blocker ref +``` + +The Rule key is the stable origin from `templates/requirements/*.md`; it makes +fragment-to-Check mapping auditable without copying fragment text into the +Summary. Every PASS evidence ref resolves each Spec ref owned by the Check. + +Each Blocker record contains: + +```text +Stable ID | primary Spec ref | minimal semantic root cause +| semantic key | affected Check IDs | class | owner/route +| OPEN / RESOLVED / RETIRED / SUPERSEDED | replacement refs +``` + +Never derive one `BLK-*` mechanically from each `CHK-*`. One root cause may be +referenced by Requirements, Behavior, and Security Checks. Two different gaps +under the same Spec ref remain two Blockers, and similarly named gaps under +different Spec refs remain separate. `product-decision`, `source-evidence`, +`template-structure`, and `legacy-layout` classes never silently convert or +merge. + +The six Gate Summary rows contain only: + +```text +Gate | Applicability | current Spec-backed N/A reason naming a stable Spec ref +| Status | Check refs/count | open Blocker refs/count ``` -Generated items remain unchecked questions. If gaps are exposed, recommend that -the user independently rerun Specify or Clarify with the relevant focus. +Check questions and product content appear only in Semantic Requirement Groups, +never again in the summary. All six rows exist exactly once even when focus was +supplied. `NOT_APPLICABLE` requires a concrete current Spec reason; absence is +not N/A. + +Planning Readiness is `PASS` only when the file Revision is current, all +references resolve uniquely, all applicable Gates are `PASS`, every N/A has a +current reason, every passing Check has current Spec evidence, and the open +Blocker inventory is empty. It is stored once as strictly derived state. Do not +create `planning-readiness.md`. + +“Zero Blocker” means zero `OPEN` current root causes. `RESOLVED`, `RETIRED`, and +`SUPERSEDED` rows are lifecycle history, not current Blocker inventory, and +cannot be referenced by a current BLOCKED Check. + +## Legacy Boundary + +Preserve existing advisory checklists and the obsolete six-file Domain layout +byte-for-byte. Do not read, merge, delete, rewrite, or import answers from them. +Report obsolete `behavior.md`, `ux.md`, `security.md`, `nfr.md`, or `visual.md` +as `REQUIREMENT_GATE_LEGACY_LAYOUT`; the new canonical +`requirements.md` remains the only authority. + +External locators are opaque. Checklist MUST NOT dereference a locator or validate external +meaning. A source-evidence gap preserves its class and original route. {CORE_TEMPLATE} +## Authoritative Core Conflict Resolution + +The Preset Checklist Ownership and Canonical Execution sections are the +effective write policy after Core merge. Execute Core hooks, input handling, +path resolution, and completion around that policy, but do not execute any +embedded Core step that creates Domain/advisory/focus checklist files or +aggregates multiple runtime files. + ## Preset Completion Addition -Report the checklist path, focus, and item count. Do not report a readiness -status or mutate the specification. +Report the canonical path, focus, semantic-group/Check/Blocker counts, six Gate +results, Spec Revision, Planning Readiness, preserved legacy paths, and any +wrapper compatibility blocker. Do not claim product completeness or modify the +Spec. diff --git a/presets/workflow-preset/commands/speckit.clarify.md b/presets/workflow-preset/commands/speckit.clarify.md index 5930c58cef..59f5cc98e5 100644 --- a/presets/workflow-preset/commands/speckit.clarify.md +++ b/presets/workflow-preset/commands/speckit.clarify.md @@ -1,5 +1,5 @@ --- -description: Resolve high-impact product ambiguity and record accepted decisions only in spec.md. +description: Resolve shared product root causes and synchronize spec.md with the one Requirement Gate. strategy: replace scripts: sh: scripts/bash/check-prerequisites.sh --json --paths-only @@ -21,35 +21,57 @@ Run enabled, unconditional `hooks.before_clarify` before analysis and Run `{SCRIPT}` once from the repository root and read `FEATURE_DIR` and `FEATURE_SPEC`. If resolution fails or `spec.md` is missing, stop and recommend -running `/speckit.specify`; do not create a specification here. - -## Ownership - -Read and write only `FEATURE_SPEC` aside from official path/hook mechanics. -Do not read blocked checklists as a queue. Do not create, recompute, answer, -toggle, or mutate checklist files. Do not aggregate readiness, revise gates, -validate IDs/numbering/references, create external-source artifacts, or modify Plan, -Tasks, Architecture, contracts, or tests. - -External source-evidence blockers remain in their canonical `SRC-*` rows and -stay outside the product-decision question loop. Do not dereference a locator, -acquire missing evidence, validate external state, or require external +`/speckit.specify`; do not create a specification here. + +## Two-File Ownership + +Clarify may update only: + +1. `FEATURE_SPEC`: accepted product decisions, their stable semantic refs, and + Clarification history; +2. the existing `FEATURE_DIR/checklists/requirements.md`: current Check + evaluations/evidence, shared Blocker lifecycle, Spec Revision, Six-Gate + Summary, and Planning Readiness. + +The Gate must identify `Stage: requirements` and +`Contract: speckit.requirement-gate.v1`. Clarify does not create a missing Gate +or repair malformed Canonical Layout. Before any Gate update, validate exact +lowercase SHA-256 metadata, unique/resolvable Semantic Group, Rule key, Check, +Spec and Blocker refs, shared-Blocker affected refs, and the six unique Summary +rows. A structural failure preserves the Gate byte-for-byte and routes to +Checklist. It never writes Plan, Architecture, +contracts, Test artifacts, Tasks, `planning-readiness.md`, advisory files, or +obsolete Domain checklist files. + +`spec.md` remains the only product-requirement truth. The Gate stores questions, +references, evaluations, evidence, and root-cause Blockers, never accepted +answer prose as a second copy. + +External source-evidence Blockers stay outside the product-decision loop. +Clarify does not dereference a locator, acquire evidence, change the Blocker +class/owner, or require external write-back or synchronization. -## Cross-Domain Ambiguity Map +## Shared-Root Ambiguity Map -Build an in-memory map across: +Read `spec.md` and only the canonical `requirements.md`. Inventory Semantic +Requirement Groups once. Build one candidate per OPEN +`class: product-decision` Blocker ID: + +```text +Blocker ID | primary Spec ref | minimal missing/conflicting meaning +| all affected Check IDs/Gates | impact × uncertainty +``` -- scope and observable behavior; -- roles, permissions, security/privacy, and compliance; -- domain/data semantics and lifecycle; -- UX journeys, UI states, accessibility, and failure recovery; -- NFRs and measurable completion signals; -- integrations, dependency failures, boundaries, constraints, and terminology. +Three Gate Checks referencing one Blocker produce one question. Two distinct +Blockers under the same Spec ref remain two candidates. Similar topics under +different Spec refs do not merge automatically. Source-evidence, +template-structure, malformed-Gate, and legacy-layout Blockers retain their +existing routes and are not product questions. -Prioritize candidates by `impact × uncertainty`. Do not use a UI-first fixed -order. Exclude decisions already answered, low-impact stylistic preferences, -source-evidence gaps, and implementation choices better owned by Plan. +Prioritize by `impact × uncertainty`; do not use a UI-first fixed order. +Exclude already answered decisions, low-impact style preferences, and +implementation choices owned by Plan. ## Question Loop @@ -59,37 +81,85 @@ future queue. Use either 2–5 mutually exclusive options with with `**Suggested:** ...`. Accept `yes`, `recommended`, or `suggested` for the displayed recommendation. -After each accepted answer: +For each accepted answer: 1. ensure `## Clarifications` and `### Session YYYY-MM-DD` exist; 2. append exactly one `- Q: ... -> A: ...` entry; -3. update the existing canonical section that owns the decision; -4. replace the ambiguous statement rather than duplicating it; -5. preserve the originating `SRC-*` provenance and clarification history while - making the accepted local decision current; -6. atomically save `spec.md`. - -The accepted answer is owned locally by `spec.md` and may supersede an -ambiguous projected statement. Do not present the superseded statement as the -current decision, erase its provenance, or require a change to the external -source. - -## Local Validation After Every Write - -Check only: +3. update the existing stable Spec semantic ref that owns the decision; +4. preserve that ID for meaning-preserving wording changes; when meaning + splits, merges, retires, or becomes N/A, record explicit successor refs and + reasons rather than silently renumbering; +5. replace the ambiguity instead of duplicating it, preserve the originating `SRC-*` provenance, + validate the Spec locally, and atomically save `spec.md`; +6. compute SHA-256 over the new exact Spec bytes; +7. re-evaluate every affected Check in the semantic group against current + Spec evidence; one answer may pass Checks in several Gates; +8. update shared Blocker affected refs/status, then strictly rederive all six + Gate Summary rows and Planning Readiness; +9. atomically save only the canonical `requirements.md`. + +If the second write fails, report the Spec write as completed and the Gate as +stale/unsynchronized. Never report Planning Readiness PASS. The next Clarify +run recovers deterministically from the current Spec plus existing Gate; do not +create a transaction file, journal, manifest, or third coordination artifact. + +## Local Validation After Every Spec Write + +Check: - one history bullet per accepted answer and no more than five; -- the targeted ambiguity is removed; -- the accepted answer appears once in its owning section; -- no contradiction or terminology drift was introduced; +- the accepted answer appears once in its owning stable semantic ref; +- the targeted ambiguity is removed without contradiction or terminology drift; +- ID lifecycle and replacement refs remain traceable; - Markdown structure and template-owned headings remain valid; -- no artifact other than `spec.md` was changed. - -These are local write-safety checks, not requirement completeness or -cross-artifact validation. +- no product-requirement artifact other than `spec.md` changed. + +These are local write-safety checks, not cross-command consistency analysis. + +## Mandatory Closeout Reconciliation + +Run this closeout after partial clarification, full clarification, and zero +questions: + +1. compute the current exact Spec SHA-256; +2. require the one existing canonical `requirements.md`; +3. if its Canonical Layout is malformed, preserve it unchanged and route + `REQUIREMENT_GATE_MALFORMED` to Checklist; +4. if structurally valid, re-evaluate every Semantic Requirement Group—not + only groups asked about—against the current Spec; +5. require every PASS evidence ref to resolve as + `spec.md#` against the current Spec; refresh every PASS + evidence ref, every OPEN/RESOLVED shared Blocker and its + affected Check refs, the file Revision, all six derived Summary rows, and + derived Planning Readiness; +6. preserve unresolved groups after the five-question limit and preserve + source-evidence Blockers with their class and owner; +7. ignore every other checklist path byte-for-byte. + +Zero questions never means zero work: a stale Revision, an interrupted prior +sync, or independently updated valid Spec still requires full reconciliation. +Do not rerun Checklist merely to refresh evaluation state or Revision. + +If re-evaluation finds a Check with neither current Spec evidence nor an +existing root-cause Blocker, do not invent a Blocker or publish a malformed +current Gate. Preserve the existing Gate as stale, report +`REQUIREMENT_GATE_RECONCILIATION_BLOCKER_REQUIRED`, and route the missing +Checklist-owned question structure to Checklist. + +## Missing, Malformed, And Legacy Inputs + +| Input | Action | +|---|---| +| canonical `requirements.md` missing | stop Gate work; route to Checklist; do not create it | +| malformed Canonical Layout | preserve unchanged; route to Checklist | +| stale but structurally valid Revision | perform full closeout reconciliation | +| advisory/focus checklist | ignore and preserve byte-for-byte | +| obsolete six-file Domain layout | ignore and preserve; report `REQUIREMENT_GATE_LEGACY_LAYOUT` | + +Never import product answers from a legacy or advisory checklist. ## Completion Report -Report questions asked, decisions recorded, sections updated, remaining product -ambiguities, source-evidence blockers, and hook status. Recommend independently -rerunning Checklist when requirement-writing quality should be reassessed. +Report question groups, decisions, updated Spec refs, affected Gates/Checks, +remaining shared Blockers by owner, current Revision, Planning Readiness, +two-file synchronization status, ignored legacy paths, and hook status. diff --git a/presets/workflow-preset/commands/speckit.plan.md b/presets/workflow-preset/commands/speckit.plan.md index 7f6121b5ea..02fb971bfc 100644 --- a/presets/workflow-preset/commands/speckit.plan.md +++ b/presets/workflow-preset/commands/speckit.plan.md @@ -11,7 +11,9 @@ The X labels are preset-internal milestones nested inside the official Core Plan lifecycle: ```text -Core setup + plan-template materialization -> X0 +read-only path resolution + Requirement Gate preflight + -> Core pre-execution hooks + -> Core setup + plan-template materialization -> X0 Core Phase 0 Outline & Research -> X1 Core Phase 1 Design & Contracts -> X2 and X3 Core post-design Constitution re-check -> unchanged @@ -21,7 +23,66 @@ Preset closeout before completion report -> X4 Preserve Core user input, setup scripts, Technical Context, Constitution Check, 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. +phase or gate. Do not re-run Checklist or Clarify and do not write checklist +state. + +## Canonical Requirement Gate Preflight + +Complete this preflight before `hooks.before_plan`, Core setup without +`--paths-only`, plan-template materialization, directory creation, X0, or any +other Plan write: + +1. Run the inherited setup script once in its official + `--json --paths-only` / `-Json -PathsOnly` mode. This is read-only feature + and path resolution; it must not create the feature directory or materialize + `plan.md`. +2. Read the resolved current `spec.md` and only + `checklists/requirements.md`. Do not scan or aggregate another checklist. +3. Compute SHA-256 over the exact Spec bytes as + `sha256:<64 lowercase hex characters>` and reject any other Revision shape. +4. Require `Contract: speckit.requirement-gate.v1`, one File Metadata block, + one Semantic Requirement Groups section, one Six-Gate Summary, and one + Planning Readiness record. +5. Validate unique/resolvable Spec refs, Check IDs, Blocker IDs, and affected + Check refs. Every Check has one template Rule key, one Gate, one atomic + concern, and PASS evidence in `spec.md#` form that covers + its current Spec refs. +6. Require exactly one Summary row for each logical Gate: + `requirements`, `behavior`, `ux`, `security`, `nfr`, and `visual`. +7. Recompute the six rows and Planning Readiness structurally. Do not trust + stored status text independently. + +PASS requires the file Revision to equal the current Spec digest, every +applicable Gate to be `PASS`, every N/A Gate to have a concrete current +Spec-backed reason, every passing Check to cite current Spec evidence, zero +unresolved or orphaned refs, an empty current Blocker inventory, and strictly +derived `Planning Readiness: PASS`. + +The current Blocker inventory contains `OPEN` root causes only. Historical +`RESOLVED`, `RETIRED`, or `SUPERSEDED` lifecycle rows may remain for +traceability, but no current Check may reference them and they do not count as +open Blockers. + +Any missing/malformed/stale/blocked state emits +`REQUIREMENT_GATE_PREFLIGHT_BLOCKED` with the smallest reason and stops with +zero Plan writes. Do not run hooks, refresh an old Plan, create a placeholder +Plan, modify either upstream file, or call/simulate Checklist or Clarify. +Product-decision Blockers route to Clarify, Gate structure to Checklist, +source-evidence to its recorded owner, and wrapper ordering incompatibility to +`REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE`. + +Only after PASS, resume the inherited Core Plan lifecycle at its pre-execution +hooks; after those hooks, run the first write-bearing setup step, Phase 0, +Phase 1, post-design Constitution re-check, post-execution hooks, and completion +in their official order. The embedded Core +multi-checklist preflight is superseded by this canonical preflight and must not +execute a second scan. If wrapper composition cannot place this section before +Core's first write, stop with the explicit compatibility Blocker instead of +checking after materialization. + +Plan validates producer-owned structure/current state only; it never answers a +Gate question, interprets product semantics anew, updates a checklist, or +creates `planning-readiness.md`. ## Deterministic Execution Spine @@ -59,9 +120,10 @@ depends on it. ## External Input Boundary -Authoritative upstream inputs are limited to `spec.md`, +Authoritative product and technical 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 +current repository facts. Planning Readiness checklists are read-only gate +evidence, never product or design inputs. “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: @@ -397,6 +459,19 @@ blocker evidence. It validates Plan outputs only. {CORE_TEMPLATE} +## Authoritative Core Preflight Merge Rule + +After wrapper composition, the Canonical Requirement Gate Preflight above +replaces only Core's inherited multi-file Requirement Gate preflight. Core user +input, path-only resolution, write-bearing setup, hooks, Phase 0, Phase 1, +post-design Constitution re-check, and completion remain in their official +relative order. The effective merged command must have this order: + +```text +path-only resolution -> canonical preflight -> Core pre-execution hooks + -> Core write-bearing setup +``` + ## Preset Completion Addition Report each X0–X4 gate, active/N/A lanes, artifacts created or omitted with diff --git a/presets/workflow-preset/commands/speckit.specify.md b/presets/workflow-preset/commands/speckit.specify.md index bc6d290fda..58769a9282 100644 --- a/presets/workflow-preset/commands/speckit.specify.md +++ b/presets/workflow-preset/commands/speckit.specify.md @@ -93,6 +93,26 @@ Populate applicable carriers for: - source references, unresolved product decisions, source-evidence blockers, and clarification history. +Every atomic point that a Requirements, Behavior, UX, Security, NFR, or Visual +Gate can inspect has a stable semantic ref in `spec.md`. Use the applicable +families `FR-*`, `NFR-*`, `UX-*`, `UI-*`, `VIS-*`, `SEC-*`, `DAT-*`, `DEP-*`, +`BND-*`, `ASM-*`, `EXC-*`, and Core success/outcome refs. Scenario prose, +security/privacy, data/integration, dependency/boundary, assumption, and +exclusion content must resolve to those refs instead of relying on a heading or +line number. + +IDs are stable for product meaning, not wording or position: + +- preserve an ID for meaning-preserving edits; +- on split, retain the old ref as `REPLACED` and name every successor; +- on merge, select one current ref and record every merged ref's successor; +- on retirement or N/A, retain the old ref and a concrete reason; +- never silently reuse a retired/refactored ID for a different meaning. + +Record non-active relations in the template's Semantic ID Lifecycle table. +Specify does not create Gate Check or Blocker IDs and does not read or update +the Requirement Gate. + Optional domains remain optional. Use a specific `Not Applicable` statement only when the supplied feature context establishes non-applicability; absence alone is not proof. The template is a carrier, not a completeness result. @@ -149,10 +169,12 @@ Before finishing, check only the artifact this command owns: - every applicable restoration, pixel-profile, and cross-platform adaptation structure is complete or carries its owning stable blocker; - assumptions and unresolved decisions are not presented as confirmed facts; +- every checkable atomic requirement carrier has a stable semantic ref and + every split/merge/retirement/N/A relation is explicit; - no implementation design or foreign-stage artifact was written. -Do not compute completeness, PASS/BLOCKED readiness, ID uniqueness, numbering -gaps, stale refs, cross-artifact coverage, or cross-command consistency. +Do not compute completeness, PASS/BLOCKED readiness, Gate Check/Blocker state, +numbering gaps, cross-artifact coverage, or cross-command consistency. ## Completion Report diff --git a/presets/workflow-preset/docs/extension-governance.md b/presets/workflow-preset/docs/extension-governance.md index 8ab9467d94..ce4e06c24b 100644 --- a/presets/workflow-preset/docs/extension-governance.md +++ b/presets/workflow-preset/docs/extension-governance.md @@ -10,6 +10,8 @@ This document defines the ownership boundaries for `workflow-preset`. - `schemas/` contains machine-readable behavior contracts. - `validators/speckit_behavior_contract.py` contains pure in-memory behavior cross-field checks. +- `validators/speckit_requirement_gate_contract.py` contains pure in-memory + canonical Requirement Gate, Clarify reconciliation, and Plan preflight checks. - `tests/test_preset_contract.py` is the executable preset contract. ## Preset Boundary @@ -73,9 +75,9 @@ them, and they do not become a Plan-local conformance gate. | Stage | Owner | Durable output | |---|---|---| | `/speckit.constitution` | preset replacement | independently authorized Constitution and project Architecture outputs | -| `/speckit.specify` | preset replacement | full-spectrum WHAT/WHY content in `spec.md` | -| `/speckit.clarify` | preset replacement | accepted product decisions in `spec.md` | -| `/speckit.checklist` | preset wrapper | unanswered requirement-writing questions | +| `/speckit.specify` | preset replacement | full-spectrum WHAT/WHY content and stable semantic IDs in `spec.md` | +| `/speckit.clarify` | preset replacement | accepted product decisions in `spec.md`; current evaluation/derived state in the one Requirement Gate | +| `/speckit.checklist` | preset wrapper | one semantic-grouped `checklists/requirements.md` containing six logical Gates | | `/speckit.plan` | preset wrapper | design, behavior contracts, and validation design | | `/speckit.tasks` | preset wrapper | executable checklist in `tasks.md` | | `/speckit.analyze` | preset wrapper | read-only cross-command consistency findings | @@ -95,7 +97,10 @@ protocol, manual execution queue, or implementation validator. The workflow is a producer-to-consumer pipeline: ```text -spec.md + optional independent requirement-writing checklists +spec.md with stable semantic refs + -> one checklists/requirements.md with six logical Gates + -> Clarify decision write + gate reconciliation + -> zero-write Plan requirement-gate preflight -> X0 plan control -> X1 shared decisions -> X2-A domain/object/interface + X2-B UI/UX + X2-C Test contracts @@ -107,26 +112,64 @@ spec.md + optional independent requirement-writing checklists Tasks maps upstream artifacts into checklist items. It must not create another planning system or execution protocol. -## Requirement Command Independence +## Requirement Command Ownership -Specify, Clarify, and Checklist are independent: +Specify owns product projection, Checklist owns the question set, and Clarify +owns accepted product decisions plus post-decision gate reconciliation: | Command | Writes | Does not own | |---|---|---| -| Specify | one `spec.md` plus official feature bootstrap metadata | checklists, completeness/readiness, ID validation | -| Clarify | accepted decisions in `spec.md` | checklist recomputation, source acquisition, cross-artifact checks | -| Checklist | `checklists/.md` questions | answers, spec repair, readiness aggregation | +| Specify | one `spec.md` plus official feature bootstrap metadata; stable semantic IDs and their replacement/retirement relations | checklists, completeness/readiness, Check/Blocker evaluation | +| Checklist | one canonical `checklists/requirements.md` | answers, spec repair, other checklist files, downstream design | +| Clarify | accepted decisions in `spec.md`; current Check evidence, shared Blockers, Revision, Summary, and readiness in existing canonical `requirements.md` | new checklist questions, malformed-layout repair, source acquisition, downstream design | -The full-spectrum `spec-template` supplies optional carriers for functional, -NFR, UX, UI, visual, security/privacy, data/integration, dependency, boundary, -assumption, exclusion, source, unresolved-decision, source-blocker, and -measurable outcome content. A carrier's presence is not a completeness claim. +The full-spectrum `spec-template` supplies optional, stable-ID carriers for +functional, NFR, UX, UI, visual, security/privacy, data/integration, +dependency, boundary, assumption, exclusion, source, unresolved-decision, +source-blocker, and measurable outcome content. IDs follow product meaning: +wording-only edits preserve them; split, merge, retirement, and N/A retain +explicit lifecycle records. A carrier's presence is not a completeness claim. Specify and Clarify are replacement commands because active Core side effects -would otherwise create or re-evaluate `checklists/requirements.md`. Their -replacement contracts preserve user input, feature/path resolution, extension -hooks, local write safety, and completion reporting. Checklist remains a Core -wrapper and produces only unanswered question-form checks. +would otherwise create or re-evaluate `checklists/requirements.md` outside the +preset contract. Their replacement contracts preserve user input, feature/path +resolution, extension hooks, local write safety, and completion reporting. +Checklist remains a Core wrapper but supersedes Core's multi-file write target. +With or without focus it atomically rebuilds only +`checklists/requirements.md`. The physical main structure is keyed by stable +Spec semantic ref. Cross-Gate Check records and shared root-cause Blockers live +only inside those groups. Every Check retains its template Rule key, and PASS +evidence resolves its current Spec refs as `spec.md#`. The +six logical Gates are `requirements`, `behavior`, +`ux`, `security`, `nfr`, and `visual`; their Summary carries only +applicability/status, refs, and counts, never duplicated questions or product +answers. + +One semantic root cause can block several Checks and Gates. A Spec ref can also +have multiple distinct root causes. Check IDs and Blocker IDs are therefore +many-to-one, not mechanically paired. Blockers retain class, owner, affected +Checks, and split/merge/retirement history. Clarify asks once per OPEN +`product-decision` Blocker, updates the Spec first, then synchronizes the +canonical Gate. Even a zero-question closeout re-evaluates all groups and +refreshes the exact Spec SHA-256. Missing/malformed Gate structure is preserved +and routed to Checklist; source-evidence Blockers retain their original owner. +This closes state directly without a `Clarify -> Checklist -> Clarify` loop. + +Plan runs a read-only path resolver and consumes only current `spec.md` plus the +one canonical `requirements.md`. Before hooks, template materialization, X0, or +any Plan write, it recomputes Revision, references, all six Summary rows, and +Planning Readiness. Any mismatch emits +`REQUIREMENT_GATE_PREFLIGHT_BLOCKED` and produces zero Plan writes. Plan never +repairs upstream state or calls an upstream command. No `planning-readiness.md` +exists. + +Existing advisory files and the obsolete six-file Domain layout are preserved +byte-for-byte but ignored by Checklist, Clarify, Plan, and Planning Readiness. +They are never answer sources or fallback authority. + +“Zero Blocker” in Planning Readiness means no `OPEN` current root cause. +Resolved, retired, and superseded Blocker rows are traceability history; no +current BLOCKED Check may reference them. Examples: @@ -375,6 +418,22 @@ separate implementation, functional validation, forbidden visual execution, and Final Code Review. This metadata exists only for contract tests; it is not written to `tasks.md` and is not an execution manifest or transfer protocol. +`validators/speckit_requirement_gate_contract.py` is likewise a pure in-memory +test helper. It models the one canonical bundle, stable Spec/Check/Blocker +references, shared root causes, strictly derived Six-Gate Summary and Planning +Readiness, partial/full/zero-question clarification, stale Revision +replacement, ID lifecycle, legacy selection, and read-only Plan preflight +without parsing or writing Markdown. Stable findings include +`REQUIREMENT_GATE_SPEC_REF_UNKNOWN`, +`REQUIREMENT_GATE_SPEC_REF_MISSING`, +`REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD`, +`REQUIREMENT_GATE_CHECK_EVIDENCE_INVALID`, +`REQUIREMENT_GATE_BLOCKER_CROSS_GROUP`, +`REQUIREMENT_GATE_BLOCKER_AFFECTED_CHECK_MISMATCH`, +`REQUIREMENT_GATE_SUMMARY_DRIFT`, +`PLANNING_READINESS_DERIVATION_INVALID`, and +`REQUIREMENT_GATE_PREFLIGHT_BLOCKED`. + ## Cross-Agent Rules Planning and task derivation may use bounded, stage-local subagents when the diff --git a/presets/workflow-preset/preset.yml b/presets/workflow-preset/preset.yml index 0ec44feebd..59458f0a05 100644 --- a/presets/workflow-preset/preset.yml +++ b/presets/workflow-preset/preset.yml @@ -2,9 +2,9 @@ schema_version: '1.0' preset: id: workflow-preset name: Workflow Preset - version: 3.2.0 - description: Separated SDD governance, source-neutral specification, X0-X4 design, - test-first contracts, and execution-ready task mapping + version: 3.2.1 + description: Stable-ID single Requirement Gate, separated SDD governance, X0-X4 + design, test-first contracts, and execution-ready task mapping author: bigsmartben repository: https://github.com/bigsmartben/spec-kit-workflow-preset license: MIT @@ -21,7 +21,7 @@ provides: - type: template name: spec-template file: templates/spec-template.md - description: Add full-spectrum optional requirement carriers to the core specification + description: Add full-spectrum stable-ID requirement carriers to the core specification template replaces: spec-template strategy: wrap @@ -85,13 +85,15 @@ provides: - type: command name: speckit.clarify file: commands/speckit.clarify.md - description: Resolve high-impact product ambiguity only in spec.md + description: Resolve shared product root causes and synchronize spec.md with the + one Requirement Gate replaces: speckit.clarify strategy: replace - type: command name: speckit.checklist file: commands/speckit.checklist.md - description: Generate broad or focused question-form tests for requirement writing + description: Build one semantic-grouped requirements.md with six logical requirement + Gates replaces: speckit.checklist strategy: wrap - type: command @@ -111,8 +113,8 @@ provides: - type: command name: speckit.plan file: commands/speckit.plan.md - description: Nest X0-X4 control, parallel design, validation paths, and readiness - inside Core Plan + description: Hard-gate the canonical requirements.md before Core Plan, then run + X0-X4 design replaces: speckit.plan strategy: wrap - type: command @@ -137,26 +139,25 @@ provides: - type: template name: requirement-domain-gate-template file: templates/requirements/domain-gate.md - description: Template for UX, security, and additional requirement-domain gates + description: Assembly rules for Requirements, UX, and Security logical Gates replaces: requirement-domain-gate-template strategy: replace - type: template name: requirement-behavior-gate-template file: templates/requirements/behavior-gate.md - description: Template for observable behavior and case coverage readiness + description: Assembly rules for the Behavior logical Gate replaces: requirement-behavior-gate-template strategy: replace - type: template name: requirement-nfr-gate-template file: templates/requirements/nfr-gate.md - description: Template for non-functional requirement readiness + description: Assembly rules for the NFR logical Gate replaces: requirement-nfr-gate-template strategy: replace - type: template name: requirement-visual-gate-template file: templates/requirements/visual-gate.md - description: Question-form check for visual requirements and source-reference - quality + description: Assembly rules for Visual and UI requirement quality replaces: requirement-visual-gate-template strategy: replace - type: template diff --git a/presets/workflow-preset/templates/constitution-template.md b/presets/workflow-preset/templates/constitution-template.md index 3555810319..d07ad0d598 100644 --- a/presets/workflow-preset/templates/constitution-template.md +++ b/presets/workflow-preset/templates/constitution-template.md @@ -20,9 +20,9 @@ The Constitution is the sole governance SSOT for the SDD workflow: | Command | Owns | Durable write boundary | |---|---|---| | Constitution | workflow governance and repository Architecture generation contracts | `constitution.md`, independently authorized `architecture.md` | -| Specify | WHAT/WHY requirements | `spec.md` | -| Clarify | accepted product decisions | `spec.md` | -| Checklist | requirement-writing quality questions | `checklists/.md` | +| Specify | WHAT/WHY requirements plus stable semantic ID lifecycle | `spec.md` | +| Clarify | accepted product decisions plus canonical Requirement Gate reconciliation | `spec.md`; current evaluation/derived state in existing `checklists/requirements.md` | +| Checklist | six logical Gate dimensions grouped by Spec semantic ref | only `checklists/requirements.md` | | Plan | feature technical, UI/UX, and Test design | feature Plan artifacts | | Tasks | concrete path binding and ordered checklist work | `tasks.md` | | Analyze | read-only cross-command consistency | none | diff --git a/presets/workflow-preset/templates/requirements/behavior-gate.md b/presets/workflow-preset/templates/requirements/behavior-gate.md index fa80c8b35c..56c9ce6a2c 100644 --- a/presets/workflow-preset/templates/requirements/behavior-gate.md +++ b/presets/workflow-preset/templates/requirements/behavior-gate.md @@ -1,9 +1,16 @@ -# Behavior Requirements Writing Checklist: [FEATURE] +# Behavior Gate Rule Fragment -- [ ] CHK-BEH-001 Are primary, alternate, negative, boundary, permission, and validation outcomes specified when applicable? [Scenario Coverage] -- [ ] CHK-BEH-002 Does each observable behavior identify actor, trigger, outcome, and failure feedback? [Clarity] -- [ ] CHK-BEH-003 Are lifecycle and recovery expectations internally consistent? [Consistency] -- [ ] CHK-BEH-004 Are behavior exclusions and assumptions explicit? [Completeness] +This is a Checklist assembly fragment, not a standalone checklist and not a +runtime artifact. Apply its rules inside the matching Spec semantic group in +`checklists/requirements.md`. -Use citations or `[Gap]`. Do not answer the questions or generate behavior -contracts. +| Rule key | Gate | Atomic concern / question pattern | +|---|---|---| +| BEH-CASES | behavior | Are primary, alternate, negative, boundary, permission, validation, and state-conflict outcomes explicit when applicable? | +| BEH-OBSERVABLE | behavior | Does the behavior identify actor, trigger, observable outcome, and failure feedback? | +| BEH-LIFECYCLE | behavior | Are lifecycle, retry, recovery, and terminal outcomes internally consistent? | +| BEH-SCOPE | behavior | Are behavior exclusions, assumptions, and boundary effects explicit? | + +Generate one stable Check per applicable Spec ref and atomic concern. Several +Checks may share one semantic root-cause Blocker; never create a Blocker merely +because a Rule key exists. diff --git a/presets/workflow-preset/templates/requirements/domain-gate.md b/presets/workflow-preset/templates/requirements/domain-gate.md index 8084a624c9..5ac97500ae 100644 --- a/presets/workflow-preset/templates/requirements/domain-gate.md +++ b/presets/workflow-preset/templates/requirements/domain-gate.md @@ -1,13 +1,16 @@ -# [DOMAIN] Requirements Writing Checklist: [FEATURE] +# Requirement Domain Gate Rule Fragment -**Purpose**: Question the quality of `[DOMAIN]` requirement writing. +This parameterized fragment supplies Checklist assembly rules for +`requirements`, `ux`, and `security`. It never becomes +`checklists/.md`. -**Spec**: [spec.md] +| Rule key | Allowed Gate | Atomic concern / question pattern | +|---|---|---| +| DOM-SCOPE | requirements / ux / security | Is the applicable product scope explicit for this Spec semantic ref? | +| DOM-ACTOR-STATE | requirements / ux / security | Are actors, states, permissions, failures, and boundaries unambiguous? | +| DOM-MEASURE | requirements / ux / security | Is the observable outcome measurable without implementation detail? | +| DOM-COVERAGE | requirements / ux / security | Are assumptions, dependencies, exclusions, and edge cases stated or explicitly N/A? | -- [ ] CHK-[DOMAIN]-001 Is the domain's applicable scope explicit? [Completeness] -- [ ] CHK-[DOMAIN]-002 Are actors, states, failures, and boundaries unambiguous? [Clarity] -- [ ] CHK-[DOMAIN]-003 Are observable outcomes measurable without implementation detail? [Measurability] -- [ ] CHK-[DOMAIN]-004 Are assumptions, dependencies, exclusions, and edge cases stated? [Coverage] - -Items are unanswered quality questions. Use a spec citation or `[Gap]`; do not -compute PASS/BLOCKED or repair `spec.md`. +Instantiate a cross-Gate Check only under a stable Spec semantic ref. A passing +Check cites current `spec.md` evidence; a blocked Check cites exactly one shared +root-cause Blocker. diff --git a/presets/workflow-preset/templates/requirements/nfr-gate.md b/presets/workflow-preset/templates/requirements/nfr-gate.md index 7645084019..5ff9adca9a 100644 --- a/presets/workflow-preset/templates/requirements/nfr-gate.md +++ b/presets/workflow-preset/templates/requirements/nfr-gate.md @@ -1,8 +1,14 @@ -# NFR Requirements Writing Checklist: [FEATURE] +# NFR Gate Rule Fragment -- [ ] CHK-NFR-001 Are applicable performance expectations measurable on named user-visible paths? [Measurability] -- [ ] CHK-NFR-002 Are reliability, recovery, security/privacy, accessibility, and compatibility expectations stated or specifically N/A? [Completeness] -- [ ] CHK-NFR-003 Are thresholds, populations, environments, and observation windows unambiguous? [Clarity] -- [ ] CHK-NFR-004 Do NFRs avoid prescribing implementation unless it is an authorized constraint? [Abstraction] +This is a logical-Gate rule fragment assembled into the one canonical +`checklists/requirements.md`; it is not an independent file contract. -Use citations or `[Gap]`. Do not calculate readiness. +| Rule key | Gate | Atomic concern / question pattern | +|---|---|---| +| NFR-MEASURE | nfr | Are applicable quality expectations measurable on named observable paths? | +| NFR-COVERAGE | nfr | Are reliability, recovery, security/privacy, accessibility, and compatibility outcomes specified or concretely N/A? | +| NFR-CONTEXT | nfr | Are thresholds, populations, environments, and observation windows unambiguous? | +| NFR-ABSTRACTION | nfr | Does the requirement avoid prescribing implementation unless it is an authorized constraint? | + +Generate stable Checks by Spec ref plus concern. Reuse a shared Blocker when +multiple Gates expose the same missing product fact. diff --git a/presets/workflow-preset/templates/requirements/visual-gate.md b/presets/workflow-preset/templates/requirements/visual-gate.md index efa093c667..1de32c5f53 100644 --- a/presets/workflow-preset/templates/requirements/visual-gate.md +++ b/presets/workflow-preset/templates/requirements/visual-gate.md @@ -1,17 +1,25 @@ -# Visual and UI Requirements Writing Checklist: [FEATURE] +# Visual and UI Gate Rule Fragment -- [ ] CHK-UI-001 Are critical surfaces, loading/empty/error/success/disabled/focus states, and recovery feedback specified? [Completeness] -- [ ] CHK-UX-001 Are journeys, navigation, keyboard/accessibility behavior, and responsive expectations observable? [Clarity] -- [ ] CHK-VIS-001 Does every `UI-*`/`VIS-*` row identify its kind, observable statement, `SRC-*`, evidence locator, surface, state, viewport/context, derivation classification, measurable acceptance condition, and status/blocker? [Traceability] -- [ ] CHK-VIS-002 Does each visual source have the `visual-input` role, a bounded feature slice, supplied content/facts, and only `UI-*`/`VIS-*` projections? [Consistency] -- [ ] CHK-UI-002 Are state, viewport, responsive, asset, accessibility, long-copy, and safe-region claims backed by corresponding supplied evidence or an explicit blocker? [Evidence] -- [ ] CHK-UI-003 Are `observed`, `derived`, `assumed`, `unresolved`, and `conflicting` statements distinguishable without presenting an assumption or gap as observed? [Inference] -- [ ] CHK-RST-001 When restoration applies, are content, information structure, appearance, interaction/feedback, UI states, responsive viewports, accessibility, and asset identity/substitution each required, N/A with reason, or blocked? [Coverage] -- [ ] CHK-PXR-001 Does every pixel-restoration profile cover the complete applicable surface × state × viewport matrix with one baseline, rendering context, fidelity mode, measurable envelope, and stable accepted-exception policy per target? [Measurability] -- [ ] CHK-PXR-002 Does each accepted exception have a stable `PEX-*` ref, exact region and bound, while unrelated regions retain the profile fidelity rule? [Containment] -- [ ] CHK-ADP-001 Does each cross-platform scope name source/target platforms, an allowed adaptation mode, target contexts, and one allowed decision for every applicable equivalence dimension? [Completeness] -- [ ] CHK-ADP-002 Do `adapt`, `add`, and `omit` decisions cite affected `UI-*`/`VIS-*` plus `SRC-*` evidence or a target hard constraint, with the declared conflict precedence preserved? [Traceability] -- [ ] CHK-BND-001 Are observable UI outcomes kept in `spec.md` while concrete components, capture/comparison methods, and implementation choices remain downstream? [Ownership] +This fragment contributes Visual/UX/Requirements concerns to Semantic +Requirement Groups in the one canonical Gate. It is never emitted as +`checklists/visual.md`. -Use citations or `[Gap]`. Do not dereference or validate external sources, -acquire evidence, answer these questions, or modify `spec.md`. +| Rule key | Gate | Atomic concern / question pattern | +|---|---|---| +| UI-STATES | ux | Are critical surfaces, loading/empty/error/success/disabled/focus states, and recovery feedback specified? | +| UX-JOURNEY | ux | Are journeys, navigation, keyboard/accessibility behavior, and responsive outcomes observable? | +| VIS-TRACE | visual | Does every `UI-*`/`VIS-*` row identify kind, observable statement, `SRC-*`, evidence locator, surface, state, viewport/context, derivation, measurable acceptance, and status/blocker? | +| VIS-SOURCE | visual | Does visual input have the right role, bounded feature slice, supplied facts, and only allowed projections? | +| UI-EVIDENCE | visual | Are state, viewport, responsive, asset, accessibility, long-copy, and safe-region claims supported or blocked? | +| UI-INFERENCE | visual | Are observed, derived, assumed, unresolved, and conflicting statements distinguishable? | +| RST-COVERAGE | visual | When restoration applies, are all required equivalence dimensions classified? | +| PXR-PROFILE | visual | Does each pixel profile cover its target matrix, baseline, rendering context, fidelity, envelope, and exception policy? | +| PXR-EXCEPTION | visual | Is every accepted exception bounded while unrelated regions retain the profile rule? | +| ADP-COVERAGE | visual | Does cross-platform scope identify platforms, adaptation mode, target contexts, and every applicable dimension? | +| ADP-TRACE | visual | Do adapt/add/omit decisions cite affected UI/VIS refs and evidence or hard constraints? | +| UI-BOUNDARY | requirements | Are observable outcomes kept in `spec.md` while components and delivery methods remain downstream? | + +Examples of generated Check families include `CHK-UI-003`, `CHK-RST-001`, +`CHK-PXR-001`, `CHK-PXR-002`, `CHK-ADP-001`, `CHK-ADP-002`, and +`CHK-BND-001`; final IDs also bind the owning Spec semantic ref. Do not acquire +external evidence or answer product questions while assembling these rules. diff --git a/presets/workflow-preset/templates/spec-template.md b/presets/workflow-preset/templates/spec-template.md index 1c11a04749..c746806b8f 100644 --- a/presets/workflow-preset/templates/spec-template.md +++ b/presets/workflow-preset/templates/spec-template.md @@ -173,23 +173,39 @@ UI/UX delivery design, not this specification. ### Security and Privacy -- [Requirement, constraint, assumption, or specific N/A reason.] +- **SEC-001**: [Observable security/privacy requirement, constraint, or specific N/A reason.] ### Data and Integration Constraints -- [Data semantics, external dependency, boundary, failure, compatibility, or specific N/A reason.] +- **DAT-001**: [Data semantics, lifecycle, failure, compatibility, or specific N/A reason.] ### Dependencies and Boundaries -- [Owned/non-owned scope and external dependency.] +- **DEP-001**: [External dependency, observable failure behavior, or explicit N/A.] +- **BND-001**: [Owned/non-owned product boundary and observable responsibility.] ## Assumptions -- [Documented default that is not presented as confirmed fact.] +- **ASM-001**: [Documented default that is not presented as confirmed fact.] ## Exclusions -- [Explicitly out-of-scope outcome.] +- **EXC-001**: [Explicitly out-of-scope outcome.] + +## Semantic ID Lifecycle + +Stable refs follow product meaning rather than wording, heading, or line +position. List only changed/non-active identities; an unchanged active ref +remains in its owning section. + +| Semantic ref | Lifecycle | Successor/current refs | Concrete reason | Last applicable meaning | +|---|---|---|---|---| +| FR-000 | [REPLACED / RETIRED / NOT_APPLICABLE] | [One or more current refs, or `None`.] | [Split, merge, retirement, or N/A reason.] | [Prior atomic WHAT/WHY meaning.] | + +Meaning-preserving wording changes keep the same ID. A split preserves the old +ID with every successor; a merge chooses one current ID and maps the others to +it. Retired or N/A refs remain traceable with a reason. Never silently reuse an +old ID for different semantics. ## Source References @@ -199,24 +215,27 @@ content/facts and cited evidence locators, never on the locator alone. | SRC ref | Role | Opaque locator / description | Revision / identity | Bounded feature scope | Supplied content / facts | Projected requirement refs | Status / blocker | |---|---|---|---|---|---|---|---| -| SRC-001 | requirement-input | [Conversation direction, document, reference, or description.] | [Optional supplied identity or `Not supplied`.] | [Current feature slice.] | [Supplied WHAT/WHY facts, evidence packet refs, or `None`.] | [FR/NFR/UX/UI/VIS refs, or `None`.] | [projected / retained / NEEDS CLARIFICATION / BLOCKED with stable reason.] | +| SRC-001 | requirement-input | [Conversation direction, document, reference, or description.] | [Optional supplied identity or `Not supplied`.] | [Current feature slice.] | [Supplied WHAT/WHY facts, evidence packet refs, or `None`.] | [FR/NFR/UX/UI/VIS/SEC/DAT/DEP/BND/ASM/EXC refs, or `None`.] | [projected / retained / NEEDS CLARIFICATION / BLOCKED with stable reason.] | Allowed roles are exactly `requirement-input`, `visual-input`, `technical-evidence`, and `context-only`. `context-only` and -`technical-evidence` do not support normative `FR/NFR/UX/UI/VIS` projection. -`visual-input` may project only `UI-*` and `VIS-*`. A broad source without a +`technical-evidence` do not support normative stable requirement projection. +`visual-input` may project only `UI-*` and `VIS-*`; the other requirement +families require `requirement-input`. A broad source without a safe feature slice stays blocked or needs clarification; it is not imported in full. A row with no supplied content/facts stays `BLOCKED: SRC_EVIDENCE_MISSING` and has no projected requirement refs. ## Unresolved Product Decisions -- [NEEDS CLARIFICATION: high-impact product decision, or `None`.] +- **Affected refs: FR-001** — [NEEDS CLARIFICATION: high-impact product + decision, or `None`. Every item names its current stable semantic refs.] ## Source Evidence Blockers -- [SRC ref + missing evidence + affected local refs, or `None`. The matching - Source References row remains the canonical status.] +- **Affected refs: UI-001** — [SRC ref + missing evidence + affected stable + local refs, or `None`. The matching Source References row remains the + canonical status.] ## Clarifications diff --git a/presets/workflow-preset/tests/fixtures/requirement_gates/issue_50_revision_gap.json b/presets/workflow-preset/tests/fixtures/requirement_gates/issue_50_revision_gap.json new file mode 100644 index 0000000000..5d7e8cbe24 --- /dev/null +++ b/presets/workflow-preset/tests/fixtures/requirement_gates/issue_50_revision_gap.json @@ -0,0 +1,171 @@ +{ + "path": "checklists/requirements.md", + "metadata": { + "stage": "requirements", + "contract": "speckit.requirement-gate.v1", + "spec_revision": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "planning_readiness": "BLOCKED" + }, + "semantic_groups": [ + { + "spec_ref": "FR-017", + "checks": [ + { + "id": "CHK-REQ-017", + "rule_key": "DOM-SCOPE", + "gate": "requirements", + "concern": "authorization subject", + "spec_refs": ["FR-017"], + "status": "BLOCKED", + "evidence_refs": [], + "blocker_ref": "BLK-FR-017-01" + }, + { + "id": "CHK-BEH-017", + "rule_key": "BEH-OBSERVABLE", + "gate": "behavior", + "concern": "authorization behavior", + "spec_refs": ["FR-017"], + "status": "BLOCKED", + "evidence_refs": [], + "blocker_ref": "BLK-FR-017-01" + }, + { + "id": "CHK-SEC-017", + "rule_key": "DOM-ACTOR-STATE", + "gate": "security", + "concern": "authorization permission", + "spec_refs": ["FR-017"], + "status": "BLOCKED", + "evidence_refs": [], + "blocker_ref": "BLK-FR-017-01" + }, + { + "id": "CHK-UX-017", + "rule_key": "UX-JOURNEY", + "gate": "ux", + "concern": "deletion scope feedback", + "spec_refs": ["FR-017"], + "status": "BLOCKED", + "evidence_refs": [], + "blocker_ref": "BLK-FR-017-02" + }, + { + "id": "CHK-NFR-017", + "rule_key": "NFR-MEASURE", + "gate": "nfr", + "concern": "completion timing", + "spec_refs": ["FR-017"], + "status": "PASS", + "evidence_refs": ["spec.md#FR-017"], + "blocker_ref": null + }, + { + "id": "CHK-VIS-017", + "rule_key": "UI-EVIDENCE", + "gate": "visual", + "concern": "non-visual applicability", + "spec_refs": ["FR-017"], + "status": "PASS", + "evidence_refs": ["spec.md#FR-017"], + "blocker_ref": null + } + ], + "blockers": [ + { + "id": "BLK-FR-017-01", + "primary_spec_ref": "FR-017", + "semantic_key": "authorization-subject", + "gap": "The actor allowed to authorize deletion is unresolved.", + "affected_check_ids": [ + "CHK-BEH-017", + "CHK-REQ-017", + "CHK-SEC-017" + ], + "class": "product-decision", + "owner": "clarify", + "status": "OPEN", + "replacement_refs": [] + }, + { + "id": "BLK-FR-017-02", + "primary_spec_ref": "FR-017", + "semantic_key": "deletion-scope", + "gap": "The cloud-data deletion scope is unresolved.", + "affected_check_ids": ["CHK-UX-017"], + "class": "product-decision", + "owner": "clarify", + "status": "OPEN", + "replacement_refs": [] + } + ] + } + ], + "gate_summary": [ + { + "gate": "requirements", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "BLOCKED", + "check_refs": ["CHK-REQ-017"], + "blocker_refs": ["BLK-FR-017-01"], + "check_count": 1, + "blocker_count": 1 + }, + { + "gate": "behavior", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "BLOCKED", + "check_refs": ["CHK-BEH-017"], + "blocker_refs": ["BLK-FR-017-01"], + "check_count": 1, + "blocker_count": 1 + }, + { + "gate": "ux", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "BLOCKED", + "check_refs": ["CHK-UX-017"], + "blocker_refs": ["BLK-FR-017-02"], + "check_count": 1, + "blocker_count": 1 + }, + { + "gate": "security", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "BLOCKED", + "check_refs": ["CHK-SEC-017"], + "blocker_refs": ["BLK-FR-017-01"], + "check_count": 1, + "blocker_count": 1 + }, + { + "gate": "nfr", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "PASS", + "check_refs": ["CHK-NFR-017"], + "blocker_refs": [], + "check_count": 1, + "blocker_count": 0 + }, + { + "gate": "visual", + "applicability": "APPLICABLE", + "applicability_reason": null, + "status": "PASS", + "check_refs": ["CHK-VIS-017"], + "blocker_refs": [], + "check_count": 1, + "blocker_count": 0 + } + ], + "planning_readiness": { + "status": "BLOCKED", + "spec_revision": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "blocker_refs": ["BLK-FR-017-01", "BLK-FR-017-02"] + } +} diff --git a/presets/workflow-preset/tests/test_preset_contract.py b/presets/workflow-preset/tests/test_preset_contract.py index 0d724f12a0..c0a029a245 100644 --- a/presets/workflow-preset/tests/test_preset_contract.py +++ b/presets/workflow-preset/tests/test_preset_contract.py @@ -3,6 +3,9 @@ from copy import deepcopy import json import re +import shutil +import subprocess +import tempfile import unittest from pathlib import Path @@ -17,6 +20,20 @@ ) from validators.speckit_behavior_contract import validate_behavior_contract_bundle from validators.speckit_plan_contract import validate_plan_artifact_bundle +from validators.speckit_requirement_gate_contract import ( + CANONICAL_REQUIREMENT_GATE_PATH, + REQUIREMENT_GATE_CONTRACT, + REQUIREMENT_RULE_GATES, + STANDARD_REQUIREMENT_GATES, + clarification_candidates, + inspect_core_wrapper_contract, + inspect_requirement_gate_bundle, + preflight_requirement_gate, + rebuild_requirement_gate, + reconcile_requirement_gate, + select_authoritative_requirement_gate, + validate_id_lifecycle, +) from validators.speckit_spec_contract import ( ADAPTATION_DIMENSIONS, CONFLICT_PRECEDENCE, @@ -46,6 +63,7 @@ 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" +REQUIREMENT_GATE_FIXTURES = ROOT / "tests" / "fixtures" / "requirement_gates" def read(path: Path) -> str: @@ -69,6 +87,110 @@ def replace_string_values(value, old: str, new: str): return value +def requirement_gate_bundle( + *, + revision: str = f"sha256:{'c' * 64}", + blocked_gates: tuple[str, ...] = (), + blocker_class: str = "product-decision", +) -> dict: + gate_contract = { + "requirements": ("REQ", "DOM-SCOPE"), + "behavior": ("BEH", "BEH-OBSERVABLE"), + "ux": ("UX", "UX-JOURNEY"), + "security": ("SEC", "DOM-ACTOR-STATE"), + "nfr": ("NFR", "NFR-MEASURE"), + "visual": ("VIS", "UI-EVIDENCE"), + } + blocker_id = "BLK-FR-017-01" + checks = [] + for gate in STANDARD_REQUIREMENT_GATES: + gate_code, rule_key = gate_contract[gate] + check_id = f"CHK-{gate_code}-001" + blocked = gate in blocked_gates + checks.append( + { + "id": check_id, + "rule_key": rule_key, + "gate": gate, + "concern": f"{gate} atomic quality", + "spec_refs": ["FR-017"], + "status": "BLOCKED" if blocked else "PASS", + "evidence_refs": [] if blocked else ["spec.md#FR-017"], + "blocker_ref": blocker_id if blocked else None, + } + ) + affected = sorted( + check["id"] for check in checks if check["status"] == "BLOCKED" + ) + blockers = [] + if affected: + blockers.append( + { + "id": blocker_id, + "primary_spec_ref": "FR-017", + "semantic_key": "authorization-subject", + "gap": "The actor allowed to authorize the action is unresolved.", + "affected_check_ids": affected, + "class": blocker_class, + "owner": ( + "clarify" if blocker_class == "product-decision" else "source-owner" + ), + "status": "OPEN", + "replacement_refs": [], + } + ) + summary = [] + for gate in STANDARD_REQUIREMENT_GATES: + gate_checks = [check for check in checks if check["gate"] == gate] + gate_blockers = sorted( + { + check["blocker_ref"] + for check in gate_checks + if check["blocker_ref"] is not None + } + ) + summary.append( + { + "gate": gate, + "applicability": "APPLICABLE", + "applicability_reason": None, + "status": ( + "PASS" + if all(check["status"] == "PASS" for check in gate_checks) + else "BLOCKED" + ), + "check_refs": sorted(check["id"] for check in gate_checks), + "blocker_refs": gate_blockers, + "check_count": len(gate_checks), + "blocker_count": len(gate_blockers), + } + ) + readiness = "BLOCKED" if affected else "PASS" + return { + "path": CANONICAL_REQUIREMENT_GATE_PATH, + "metadata": { + "stage": "requirements", + "contract": REQUIREMENT_GATE_CONTRACT, + "spec_revision": revision, + "planning_readiness": readiness, + }, + "semantic_groups": [ + { + "spec_ref": "FR-017", + "checks": checks, + "blockers": blockers, + "manual_notes": "preserve me", + } + ], + "gate_summary": summary, + "planning_readiness": { + "status": readiness, + "spec_revision": revision, + "blocker_refs": [blocker_id] if affected else [], + }, + } + + def minimal_test_conditions(*, technique: str = "contract_testing") -> dict: return { "contract_type": "speckit.test.conditions.v1", @@ -573,6 +695,17 @@ def test_manifest_strategies_enforce_negative_ownership(self) -> None: ): self.assertEqual("wrap", entries[wrapped]["strategy"]) + self.assertIn("stable-ID", entries["spec-template"]["description"]) + self.assertIn( + "one semantic-grouped requirements.md", + entries["speckit.checklist"]["description"], + ) + self.assertIn("synchronize", entries["speckit.clarify"]["description"]) + self.assertIn( + "Hard-gate the canonical requirements.md", + entries["speckit.plan"]["description"], + ) + def test_governance_uses_one_authority_and_gate_vocabulary(self) -> None: documents = (read(GOVERNANCE), read(AGENTS), read(CROSS_AGENT)) for document in documents: @@ -582,11 +715,17 @@ def test_governance_uses_one_authority_and_gate_vocabulary(self) -> None: governance = documents[0] self.assertIn("Authority And Gate Ownership", governance) - self.assertIn("Requirement Command Independence", governance) + self.assertIn("Requirement Command Ownership", governance) self.assertIn("X0–X4 Planning Artifact Boundaries", governance) self.assertIn("Tasks As A Pure Plan Mapper", governance) self.assertIn("Analyze Cross-Command Audit", governance) self.assertIn("Source Reference Contract", governance) + self.assertIn("Clarify -> Checklist -> Clarify", governance) + self.assertIn("speckit_requirement_gate_contract.py", governance) + self.assertIn( + "“Zero Blocker” in Planning Readiness means no `OPEN`", + governance, + ) self.assertIn( "SRC-* + UI/VIS/RST/PXR/PXT/PEX/ADP refs", governance, @@ -704,15 +843,30 @@ def test_full_spectrum_spec_carrier_is_optional_and_stable(self) -> None: "Dependencies and Boundaries", "Assumptions", "Exclusions", + "Semantic ID Lifecycle", "Source References", "Unresolved Product Decisions", "Source Evidence Blockers", "Clarifications", ): self.assertIn(heading, template) - for prefix in ("FR-", "NFR-", "UX-", "UI-", "VIS-"): + for prefix in ( + "FR-", + "NFR-", + "UX-", + "UI-", + "VIS-", + "SEC-", + "DAT-", + "DEP-", + "BND-", + "ASM-", + "EXC-", + ): self.assertIn(prefix, template) self.assertIn("content carrier, not a completeness checklist", template) + self.assertIn("Every item names its current stable semantic refs", template) + self.assertIn("affected stable\n local refs", template) def test_source_reference_template_has_one_source_neutral_shape(self) -> None: template = read(TEMPLATES / "spec-template.md") @@ -747,6 +901,8 @@ def test_specify_has_no_core_checklist_side_effect(self) -> None: "Bounded Supplied Input Contract", "Full-Spectrum Projection", "feature-local WHAT/WHY SSOT", + "stable semantic ref", + "preserve an ID for meaning-preserving edits", "Do not compute completeness", ): self.assertIn(term, command) @@ -824,32 +980,1090 @@ def test_visual_checklist_covers_evidence_pixel_and_adaptation_quality(self) -> ): self.assertIn(checklist_id, checklist) - def test_clarify_writes_only_spec_and_uses_cross_domain_priority(self) -> None: + def test_clarify_reconciles_one_gate_by_shared_root_cause(self) -> None: command = read(COMMANDS / "speckit.clarify.md") for term in ( "strategy: replace", "Run `{SCRIPT}` once", - "Read and write only `FEATURE_SPEC`", + "Two-File Ownership", "impact × uncertainty", "exactly one at a time", "## Clarifications", - "Local Validation After Every Write", + "Local Validation After Every Spec Write", + "Mandatory Closeout Reconciliation", + "speckit.requirement-gate.v1", + "Three Gate Checks referencing one Blocker produce one question", + "REQUIREMENT_GATE_LEGACY_LAYOUT", + "stale/unsynchronized", ): self.assertIn(term, command) self.assertNotIn("{CORE_TEMPLATE}", command) - self.assertNotIn("checklists/requirements.md", command) + self.assertIn("existing `FEATURE_DIR/checklists/requirements.md`", command) + self.assertIn("does not create a missing Gate", command) - def test_checklist_generates_unanswered_questions_only(self) -> None: + def test_checklist_generates_one_semantic_grouped_gate(self) -> None: command = read(COMMANDS / "speckit.checklist.md") self.assertIn("{CORE_TEMPLATE}", command) - self.assertIn("question-form", command) - self.assertIn("Generated items remain unchecked questions", command) + self.assertIn("exactly FEATURE_DIR/checklists/requirements.md", command) + self.assertIn("Semantic Requirement Groups", command) + self.assertIn("shared root-cause Blocker", command) + self.assertIn("Six-Gate Summary", command) + self.assertIn("Planning Readiness", command) + self.assertIn("REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE", command) + self.assertIn("REQUIREMENT_GATE_LEGACY_LAYOUT", command) + self.assertIn("never selects a filename", command) self.assertIn("MUST NOT modify `spec.md`", command) + self.assertIn("never become runtime files", command) + self.assertIn("do not execute any\nembedded Core step", command) + self.assertIn("Rule key", command) + self.assertIn("spec.md#", command) for path in (TEMPLATES / "requirements").glob("*.md"): template = read(path) - self.assertRegex(template, r"- \[ \] CHK-") - self.assertNotIn("PASS | BLOCKED", template) - self.assertNotIn("Readiness Matrix", template) + self.assertIn("fragment", template.casefold()) + self.assertNotIn("**Stage**:", template) + self.assertNotIn("**Spec Revision**:", template) + + def test_requirement_fragments_have_unique_rule_keys_and_cover_six_gates( + self, + ) -> None: + rule_keys: list[str] = [] + covered_gates: set[str] = set() + parsed_rule_gates: dict[str, set[str]] = {} + for path in sorted((TEMPLATES / "requirements").glob("*.md")): + for line in read(path).splitlines(): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if ( + len(cells) != 3 + or not re.fullmatch(r"[A-Z]+(?:-[A-Z]+)+", cells[0]) + ): + continue + rule_keys.append(cells[0]) + gates = { + gate.strip() for gate in cells[1].split("/") if gate.strip() + } + parsed_rule_gates[cells[0]] = gates + covered_gates.update(gates) + + self.assertEqual(len(rule_keys), len(set(rule_keys))) + self.assertEqual(set(STANDARD_REQUIREMENT_GATES), covered_gates) + self.assertEqual(set(REQUIREMENT_RULE_GATES), set(rule_keys)) + self.assertEqual(REQUIREMENT_RULE_GATES, parsed_rule_gates) + + def test_plan_preflights_one_gate_before_any_write(self) -> None: + command = read(COMMANDS / "speckit.plan.md") + for term in ( + "Canonical Requirement Gate Preflight", + "--json --paths-only", + "Read the resolved current `spec.md` and only", + "`checklists/requirements.md`", + "speckit.requirement-gate.v1", + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + "zero Plan writes", + "REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE", + "path-only resolution -> canonical preflight -> Core pre-execution hooks", + ): + self.assertIn(term, command) + self.assertIn("must not\nexecute a second scan", command) + + +class RequirementGateReconciliationTests(unittest.TestCase): + CURRENT_REVISION = f"sha256:{'c' * 64}" + + def inspect(self, bundle: dict, *, require_ready: bool = False) -> dict: + return inspect_requirement_gate_bundle( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + require_ready=require_ready, + ) + + def test_canonical_ready_bundle_is_valid_and_strictly_derived(self) -> None: + result = self.inspect(requirement_gate_bundle(), require_ready=True) + self.assertEqual("PASS", result["status"]) + self.assertEqual([], result["findings"]) + self.assertEqual(6, len(result["derived_gate_summary"])) + self.assertEqual( + "PASS", result["derived_planning_readiness"]["status"] + ) + + def test_revision_rule_key_and_current_spec_evidence_are_structural(self) -> None: + invalid_revision = requirement_gate_bundle(revision="sha256:not-a-digest") + codes = { + finding["code"] for finding in self.inspect(invalid_revision)["findings"] + } + self.assertIn("REQUIREMENT_GATE_METADATA_MALFORMED", codes) + + missing_rule = requirement_gate_bundle() + missing_rule["semantic_groups"][0]["checks"][0].pop("rule_key") + codes = { + finding["code"] for finding in self.inspect(missing_rule)["findings"] + } + self.assertIn("REQUIREMENT_GATE_CHECK_MALFORMED", codes) + + wrong_rule_gate = requirement_gate_bundle() + wrong_rule_gate["semantic_groups"][0]["checks"][0][ + "rule_key" + ] = "UI-EVIDENCE" + codes = { + finding["code"] + for finding in self.inspect(wrong_rule_gate)["findings"] + } + self.assertIn("REQUIREMENT_GATE_CHECK_MALFORMED", codes) + + foreign_evidence = requirement_gate_bundle() + foreign_evidence["semantic_groups"][0]["checks"][0]["evidence_refs"] = [ + "README.md#FR-017" + ] + codes = { + finding["code"] for finding in self.inspect(foreign_evidence)["findings"] + } + self.assertIn("REQUIREMENT_GATE_CHECK_EVIDENCE_INVALID", codes) + + wrong_ref = requirement_gate_bundle() + wrong_ref["semantic_groups"][0]["checks"][0]["evidence_refs"] = [ + "spec.md#FR-404" + ] + codes = {finding["code"] for finding in self.inspect(wrong_ref)["findings"]} + self.assertIn("REQUIREMENT_GATE_CHECK_EVIDENCE_INVALID", codes) + + duplicated_spec_ref = requirement_gate_bundle() + duplicated_spec_ref["semantic_groups"][0]["checks"][0]["spec_refs"].append( + "FR-017" + ) + codes = { + finding["code"] + for finding in self.inspect(duplicated_spec_ref)["findings"] + } + self.assertIn("REQUIREMENT_GATE_CHECK_MALFORMED", codes) + + def test_canonical_layout_rejects_answer_copies_and_missing_semantic_groups( + self, + ) -> None: + copied_answer = requirement_gate_bundle() + copied_answer["semantic_groups"][0]["checks"][0][ + "accepted_answer" + ] = "Only administrators may authorize deletion." + codes = { + finding["code"] for finding in self.inspect(copied_answer)["findings"] + } + self.assertIn("REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", codes) + + missing_group = inspect_requirement_gate_bundle( + requirement_gate_bundle(), + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017", "SEC-001"}, + ) + self.assertIn( + "REQUIREMENT_GATE_SPEC_REF_MISSING", + {finding["code"] for finding in missing_group["findings"]}, + ) + + def test_checklist_first_focus_and_rerun_build_one_idempotent_bundle(self) -> None: + source = requirement_gate_bundle(blocked_gates=("requirements",)) + applicability = { + record["gate"]: ( + record["applicability"], + record.get("applicability_reason"), + ) + for record in source["gate_summary"] + } + groups = deepcopy(source["semantic_groups"]) + groups[0]["manual_notes"] = "manual\nnotes\nstay byte-for-byte" + + first = rebuild_requirement_gate( + spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + semantic_groups=groups, + applicability=applicability, + ) + focused = rebuild_requirement_gate( + spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + semantic_groups=groups, + applicability=applicability, + focus="security", + ) + rerun_groups = deepcopy(groups) + rerun_groups[0].pop("manual_notes") + rerun = rebuild_requirement_gate( + spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + semantic_groups=rerun_groups, + applicability=applicability, + previous_bundle=first, + ) + + self.assertEqual(CANONICAL_REQUIREMENT_GATE_PATH, first["path"]) + self.assertEqual(first, focused) + self.assertEqual(first, rerun) + self.assertEqual( + "manual\nnotes\nstay byte-for-byte", + rerun["semantic_groups"][0]["manual_notes"], + ) + self.assertEqual( + [check["id"] for check in first["semantic_groups"][0]["checks"]], + [check["id"] for check in rerun["semantic_groups"][0]["checks"]], + ) + + wording_only = deepcopy(groups) + wording_only[0]["checks"][0]["concern"] = ( + "requirements atomic quality, wording clarified" + ) + rewritten = rebuild_requirement_gate( + spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + semantic_groups=wording_only, + applicability=applicability, + previous_bundle=first, + ) + self.assertEqual( + first["semantic_groups"][0]["checks"][0]["id"], + rewritten["semantic_groups"][0]["checks"][0]["id"], + ) + + def test_one_root_cause_is_shared_across_three_gates_and_one_question(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("requirements", "behavior", "security") + ) + self.assertEqual("PASS", self.inspect(bundle)["status"]) + candidates = clarification_candidates(bundle) + self.assertEqual(1, len(candidates)) + self.assertEqual( + ["CHK-BEH-001", "CHK-REQ-001", "CHK-SEC-001"], + candidates[0]["affected_check_ids"], + ) + + def test_two_distinct_root_causes_under_one_spec_ref_do_not_merge(self) -> None: + bundle = requirement_gate_bundle(blocked_gates=("requirements",)) + group = bundle["semantic_groups"][0] + ux_check = next(check for check in group["checks"] if check["gate"] == "ux") + ux_check.update( + status="BLOCKED", + evidence_refs=[], + blocker_ref="BLK-FR-017-02", + ) + group["blockers"].append( + { + "id": "BLK-FR-017-02", + "primary_spec_ref": "FR-017", + "semantic_key": "deletion-scope", + "gap": "Deletion scope is unresolved.", + "affected_check_ids": ["CHK-UX-001"], + "class": "product-decision", + "owner": "clarify", + "status": "OPEN", + "replacement_refs": [], + } + ) + ux_summary = next( + item for item in bundle["gate_summary"] if item["gate"] == "ux" + ) + ux_summary.update( + status="BLOCKED", + blocker_refs=["BLK-FR-017-02"], + blocker_count=1, + ) + bundle["planning_readiness"]["blocker_refs"] = [ + "BLK-FR-017-01", + "BLK-FR-017-02", + ] + self.assertEqual("PASS", self.inspect(bundle)["status"]) + self.assertEqual(2, len(clarification_candidates(bundle))) + + def test_same_topic_under_different_spec_refs_does_not_merge(self) -> None: + first = requirement_gate_bundle(blocked_gates=("requirements",)) + second_group = deepcopy(first["semantic_groups"][0]) + second_group["spec_ref"] = "FR-018" + second_group["checks"] = [deepcopy(second_group["checks"][0])] + second_group["checks"][0].update( + id="CHK-REQ-018", + spec_refs=["FR-018"], + blocker_ref="BLK-FR-018-01", + ) + second_group["blockers"][0].update( + id="BLK-FR-018-01", + primary_spec_ref="FR-018", + affected_check_ids=["CHK-REQ-018"], + ) + first["semantic_groups"].append(second_group) + candidates = clarification_candidates(first) + self.assertEqual( + ["BLK-FR-017-01", "BLK-FR-018-01"], + [candidate["blocker_id"] for candidate in candidates], + ) + + invalid_merge = deepcopy(first) + second = invalid_merge["semantic_groups"][1] + second["checks"][0]["blocker_ref"] = "BLK-FR-017-01" + second["blockers"] = [] + invalid_merge["semantic_groups"][0]["blockers"][0][ + "affected_check_ids" + ].append("CHK-REQ-018") + result = inspect_requirement_gate_bundle( + invalid_merge, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017", "FR-018"}, + ) + self.assertIn( + "REQUIREMENT_GATE_BLOCKER_CROSS_GROUP", + {finding["code"] for finding in result["findings"]}, + ) + + def test_clarify_candidate_queue_is_capped_at_five_root_causes(self) -> None: + groups = [] + for index in range(1, 7): + spec_ref = f"FR-{index:03d}" + groups.append( + { + "spec_ref": spec_ref, + "blockers": [ + { + "id": f"BLK-{index:03d}", + "primary_spec_ref": spec_ref, + "semantic_key": f"root-{index}", + "gap": f"Decision {index} is unresolved.", + "affected_check_ids": [f"CHK-{index:03d}"], + "class": "product-decision", + "status": "OPEN", + } + ], + } + ) + candidates = clarification_candidates( + {"semantic_groups": groups}, + priority_by_blocker={ + "BLK-006": (5, 5), + "BLK-005": (4, 4), + "BLK-004": (3, 3), + "BLK-003": (2, 2), + "BLK-002": (1, 1), + "BLK-001": (0, 0), + }, + ) + self.assertEqual(5, len(candidates)) + self.assertEqual( + ["BLK-006", "BLK-005", "BLK-004", "BLK-003", "BLK-002"], + [candidate["blocker_id"] for candidate in candidates], + ) + + def test_pass_evidence_and_blocker_are_mutually_exclusive(self) -> None: + bundle = requirement_gate_bundle() + bundle["semantic_groups"][0]["checks"][0][ + "blocker_ref" + ] = "BLK-FR-017-01" + codes = {finding["code"] for finding in self.inspect(bundle)["findings"]} + self.assertIn("REQUIREMENT_GATE_CHECK_RESULT_INVALID", codes) + + def test_duplicate_semantic_root_cause_is_rejected(self) -> None: + bundle = requirement_gate_bundle(blocked_gates=("requirements",)) + duplicate = deepcopy(bundle["semantic_groups"][0]["blockers"][0]) + duplicate["id"] = "BLK-FR-017-DUP" + bundle["semantic_groups"][0]["blockers"].append(duplicate) + codes = {finding["code"] for finding in self.inspect(bundle)["findings"]} + self.assertIn("REQUIREMENT_GATE_BLOCKER_ROOT_DUPLICATE", codes) + + def test_shared_blocker_affected_refs_must_match_all_inbound_checks(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("requirements", "behavior", "security") + ) + bundle["semantic_groups"][0]["blockers"][0]["affected_check_ids"] = [ + "CHK-REQ-001" + ] + codes = {finding["code"] for finding in self.inspect(bundle)["findings"]} + self.assertIn("REQUIREMENT_GATE_BLOCKER_AFFECTED_CHECK_MISMATCH", codes) + + def test_duplicate_or_orphan_refs_are_stably_rejected(self) -> None: + duplicate = requirement_gate_bundle() + duplicate["semantic_groups"][0]["checks"].append( + deepcopy(duplicate["semantic_groups"][0]["checks"][0]) + ) + codes = {finding["code"] for finding in self.inspect(duplicate)["findings"]} + self.assertIn("REQUIREMENT_GATE_CHECK_DUPLICATE", codes) + + orphan = requirement_gate_bundle(blocked_gates=("requirements",)) + orphan["semantic_groups"][0]["checks"][0]["blocker_ref"] = "BLK-UNKNOWN" + codes = {finding["code"] for finding in self.inspect(orphan)["findings"]} + self.assertIn("REQUIREMENT_GATE_BLOCKER_UNKNOWN", codes) + + duplicate_group = requirement_gate_bundle() + duplicate_group["semantic_groups"].append( + deepcopy(duplicate_group["semantic_groups"][0]) + ) + codes = { + finding["code"] for finding in self.inspect(duplicate_group)["findings"] + } + self.assertIn("REQUIREMENT_GATE_SPEC_REF_DUPLICATE", codes) + + duplicate_blocker = requirement_gate_bundle( + blocked_gates=("requirements",) + ) + duplicate_blocker["semantic_groups"][0]["blockers"].append( + deepcopy(duplicate_blocker["semantic_groups"][0]["blockers"][0]) + ) + codes = { + finding["code"] + for finding in self.inspect(duplicate_blocker)["findings"] + } + self.assertIn("REQUIREMENT_GATE_BLOCKER_DUPLICATE", codes) + + unknown_spec = requirement_gate_bundle() + unknown_spec["semantic_groups"][0]["spec_ref"] = "FR-404" + codes = { + finding["code"] for finding in self.inspect(unknown_spec)["findings"] + } + self.assertIn("REQUIREMENT_GATE_SPEC_REF_UNKNOWN", codes) + + wrong_class = requirement_gate_bundle(blocked_gates=("security",)) + wrong_class["semantic_groups"][0]["blockers"][0]["class"] = ( + "provider-evidence" + ) + codes = { + finding["code"] for finding in self.inspect(wrong_class)["findings"] + } + self.assertIn("REQUIREMENT_GATE_BLOCKER_CLASS_INVALID", codes) + + wrong_owner = requirement_gate_bundle(blocked_gates=("security",)) + wrong_owner["semantic_groups"][0]["blockers"][0]["owner"] = "checklist" + codes = { + finding["code"] for finding in self.inspect(wrong_owner)["findings"] + } + self.assertIn("REQUIREMENT_GATE_BLOCKER_OWNER_INVALID", codes) + + def test_summary_missing_duplicate_and_drift_are_rejected(self) -> None: + missing = requirement_gate_bundle() + missing["gate_summary"].pop() + codes = {finding["code"] for finding in self.inspect(missing)["findings"]} + self.assertIn("REQUIREMENT_GATE_SUMMARY_MISSING", codes) + + duplicate = requirement_gate_bundle() + duplicate["gate_summary"].append(deepcopy(duplicate["gate_summary"][0])) + codes = {finding["code"] for finding in self.inspect(duplicate)["findings"]} + self.assertIn("REQUIREMENT_GATE_SUMMARY_DUPLICATE", codes) + + drift = requirement_gate_bundle() + drift["gate_summary"][0]["check_count"] = 99 + codes = {finding["code"] for finding in self.inspect(drift)["findings"]} + self.assertIn("REQUIREMENT_GATE_SUMMARY_DRIFT", codes) + + def test_not_applicable_gate_requires_current_reason(self) -> None: + bundle = requirement_gate_bundle() + group = bundle["semantic_groups"][0] + group["checks"] = [ + check for check in group["checks"] if check["gate"] != "visual" + ] + visual = next( + item for item in bundle["gate_summary"] if item["gate"] == "visual" + ) + visual.update( + applicability="NOT_APPLICABLE", + applicability_reason=None, + status="PASS", + check_refs=[], + blocker_refs=[], + check_count=0, + blocker_count=0, + ) + codes = {finding["code"] for finding in self.inspect(bundle)["findings"]} + self.assertIn("REQUIREMENT_GATE_APPLICABILITY_REASON_MISSING", codes) + visual["applicability_reason"] = "This feature has no visible surface." + codes = {finding["code"] for finding in self.inspect(bundle)["findings"]} + self.assertIn("REQUIREMENT_GATE_APPLICABILITY_REASON_MISSING", codes) + visual["applicability_reason"] = ( + "FR-017 explicitly has no user-visible surface." + ) + self.assertEqual("PASS", self.inspect(bundle)["status"]) + + def test_partial_clarification_preserves_one_shared_blocker(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("requirements", "behavior", "security") + ) + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in bundle["semantic_groups"][0]["checks"] + if check["gate"] != "security" + } + result = reconcile_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + ) + self.assertEqual("BLOCKED", result["status"]) + self.assertEqual(1, len(result["candidates"])) + blocker = result["updated"]["semantic_groups"][0]["blockers"][0] + self.assertEqual(["CHK-SEC-001"], blocker["affected_check_ids"]) + requirements_summary = next( + item + for item in result["updated"]["gate_summary"] + if item["gate"] == "requirements" + ) + self.assertEqual("PASS", requirements_summary["status"]) + + def test_one_answer_closes_multiple_checks_and_all_gates(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("requirements", "behavior", "security") + ) + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in bundle["semantic_groups"][0]["checks"] + } + result = reconcile_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + ) + self.assertEqual("PASS", result["status"]) + self.assertEqual([], result["candidates"]) + blocker = result["updated"]["semantic_groups"][0]["blockers"][0] + self.assertEqual("RESOLVED", blocker["status"]) + self.assertEqual([], blocker["affected_check_ids"]) + + def test_zero_question_closeout_refreshes_stale_revision(self) -> None: + bundle = requirement_gate_bundle(revision=f"sha256:{'5' * 64}") + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in bundle["semantic_groups"][0]["checks"] + } + result = reconcile_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + ) + self.assertEqual("PASS", result["status"]) + self.assertEqual( + self.CURRENT_REVISION, + result["updated"]["metadata"]["spec_revision"], + ) + self.assertEqual( + self.CURRENT_REVISION, + result["updated"]["planning_readiness"]["spec_revision"], + ) + + def test_reconciliation_rerun_is_idempotent(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("requirements", "behavior", "security") + ) + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in bundle["semantic_groups"][0]["checks"] + } + first = reconcile_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + )["updated"] + second = reconcile_requirement_gate( + first, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + )["updated"] + self.assertEqual(first, second) + + def test_missing_or_malformed_gate_is_not_created_or_rewritten(self) -> None: + missing = reconcile_requirement_gate( + None, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check={}, + ) + self.assertIsNone(missing["updated"]) + self.assertEqual("REQUIREMENT_GATE_MISSING", missing["findings"][0]["code"]) + + malformed = reconcile_requirement_gate( + {"path": CANONICAL_REQUIREMENT_GATE_PATH}, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check={}, + ) + self.assertIsNone(malformed["updated"]) + self.assertEqual( + "REQUIREMENT_GATE_MALFORMED", malformed["findings"][0]["code"] + ) + + duplicate = requirement_gate_bundle() + duplicate["semantic_groups"][0]["checks"].append( + deepcopy(duplicate["semantic_groups"][0]["checks"][0]) + ) + before = deepcopy(duplicate) + malformed_duplicate = reconcile_requirement_gate( + duplicate, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check={}, + ) + self.assertIsNone(malformed_duplicate["updated"]) + self.assertEqual(before, duplicate) + self.assertIn( + "REQUIREMENT_GATE_CHECK_DUPLICATE", + {finding["code"] for finding in malformed_duplicate["findings"]}, + ) + + def test_reconciliation_never_hides_new_missing_evidence(self) -> None: + original = requirement_gate_bundle() + result = reconcile_requirement_gate( + original, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check={}, + ) + self.assertEqual("BLOCKED", result["status"]) + self.assertIsNone(result["updated"]) + self.assertEqual("PASS", original["planning_readiness"]["status"]) + self.assertIn( + "REQUIREMENT_GATE_RECONCILIATION_BLOCKER_REQUIRED", + {finding["code"] for finding in result["findings"]}, + ) + + def test_source_evidence_blocker_never_becomes_product_question(self) -> None: + bundle = requirement_gate_bundle( + blocked_gates=("visual",), + blocker_class="source-evidence", + ) + self.assertEqual([], clarification_candidates(bundle)) + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in bundle["semantic_groups"][0]["checks"] + if check["gate"] != "visual" + } + reconciled = reconcile_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + ) + blocker = reconciled["updated"]["semantic_groups"][0]["blockers"][0] + self.assertEqual("source-evidence", blocker["class"]) + self.assertEqual("source-owner", blocker["owner"]) + self.assertEqual([], reconciled["candidates"]) + + def test_interrupted_second_write_recovers_from_stale_gate_only(self) -> None: + stale_gate = requirement_gate_bundle( + revision=f"sha256:{'5' * 64}", + blocked_gates=("requirements", "behavior", "security"), + ) + stale_snapshot = deepcopy(stale_gate) + evidence = { + check["id"]: ["spec.md#FR-017"] + for check in stale_gate["semantic_groups"][0]["checks"] + } + failed_sync_state = preflight_requirement_gate( + stale_gate, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("BLOCKED", failed_sync_state["status"]) + self.assertEqual(0, failed_sync_state["write_count"]) + self.assertIn( + "REQUIREMENT_GATE_SPEC_REVISION_STALE", + {finding["code"] for finding in failed_sync_state["findings"]}, + ) + recovered = reconcile_requirement_gate( + stale_gate, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=evidence, + ) + self.assertEqual("PASS", recovered["status"]) + self.assertEqual(stale_snapshot, stale_gate) + self.assertEqual( + self.CURRENT_REVISION, + recovered["updated"]["metadata"]["spec_revision"], + ) + self.assertEqual([], recovered["candidates"]) + + def test_legacy_and_advisory_files_are_ignored_byte_for_byte(self) -> None: + canonical = requirement_gate_bundle() + advisory = { + "path": "checklists/copy.md", + "bytes": "manual advisory content", + } + legacy = { + "path": "checklists/behavior.md", + "bytes": "legacy product answer must not be imported", + } + result = select_authoritative_requirement_gate( + [canonical, advisory, legacy] + ) + self.assertIs(canonical, result["authoritative"]) + self.assertEqual([advisory, legacy], result["ignored"]) + self.assertEqual( + ["REQUIREMENT_GATE_LEGACY_LAYOUT"], + [finding["code"] for finding in result["findings"]], + ) + + def test_plan_preflight_is_read_only_and_blocks_stale_or_open_gate(self) -> None: + stale = preflight_requirement_gate( + requirement_gate_bundle(revision=f"sha256:{'5' * 64}"), + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("BLOCKED", stale["status"]) + self.assertEqual(0, stale["write_count"]) + self.assertFalse(stale["hooks_started"]) + self.assertFalse(stale["core_setup_started"]) + self.assertIsNone(stale["next_step"]) + self.assertIn( + "REQUIREMENT_GATE_SPEC_REVISION_STALE", + {finding["code"] for finding in stale["findings"]}, + ) + self.assertIn( + "PLANNING_READINESS_DERIVATION_INVALID", + {finding["code"] for finding in stale["findings"]}, + ) + + open_gate = preflight_requirement_gate( + requirement_gate_bundle(blocked_gates=("behavior",)), + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("BLOCKED", open_gate["status"]) + self.assertEqual(0, open_gate["write_count"]) + self.assertIn( + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + {finding["code"] for finding in open_gate["findings"]}, + ) + + def test_plan_preflight_zero_writes_for_every_named_failure_class(self) -> None: + cases: list[dict] = [] + + n_a_without_reason = requirement_gate_bundle() + group = n_a_without_reason["semantic_groups"][0] + group["checks"] = [ + check for check in group["checks"] if check["gate"] != "visual" + ] + visual = next( + row for row in n_a_without_reason["gate_summary"] + if row["gate"] == "visual" + ) + visual.update( + applicability="NOT_APPLICABLE", + applicability_reason=None, + status="PASS", + check_refs=[], + blocker_refs=[], + check_count=0, + blocker_count=0, + ) + cases.append(n_a_without_reason) + + malformed_ref = requirement_gate_bundle() + malformed_ref["semantic_groups"][0]["checks"][0]["spec_refs"] = ["FR-404"] + cases.append(malformed_ref) + + placeholder = requirement_gate_bundle() + placeholder["semantic_groups"][0]["checks"][0]["concern"] = ( + "TODO define authorization quality" + ) + cases.append(placeholder) + + residual_open_blocker = requirement_gate_bundle() + residual_open_blocker["semantic_groups"][0]["blockers"].append( + { + "id": "BLK-FR-017-RESIDUAL", + "primary_spec_ref": "FR-017", + "semantic_key": "residual-open-root", + "gap": "An OPEN root cause remains after PASS text.", + "affected_check_ids": ["CHK-REQ-001"], + "class": "product-decision", + "owner": "clarify", + "status": "OPEN", + "replacement_refs": [], + } + ) + cases.append(residual_open_blocker) + + old_plan = { + "plan.md": "old plan bytes", + "research.md": "old research bytes", + } + old_plan_snapshot = deepcopy(old_plan) + for bundle in cases: + bundle_snapshot = deepcopy(bundle) + result = preflight_requirement_gate( + bundle, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("BLOCKED", result["status"]) + self.assertEqual(0, result["write_count"]) + self.assertFalse(result["hooks_started"]) + self.assertFalse(result["core_setup_started"]) + self.assertIsNone(result["next_step"]) + self.assertIn( + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + {finding["code"] for finding in result["findings"]}, + ) + self.assertEqual(bundle_snapshot, bundle) + self.assertEqual(old_plan_snapshot, old_plan) + + def test_issue_50_revision_gap_fixture_closes_only_after_full_sync(self) -> None: + fixture = load_json( + REQUIREMENT_GATE_FIXTURES / "issue_50_revision_gap.json" + ) + self.assertEqual(2, len(clarification_candidates(fixture))) + + partial_evidence = { + "CHK-REQ-017": ["spec.md#FR-017"], + "CHK-BEH-017": ["spec.md#FR-017"], + "CHK-SEC-017": ["spec.md#FR-017"], + "CHK-NFR-017": ["spec.md#FR-017"], + "CHK-VIS-017": ["spec.md#FR-017"], + } + partial = reconcile_requirement_gate( + fixture, + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=partial_evidence, + ) + self.assertEqual("BLOCKED", partial["status"]) + self.assertEqual(1, len(partial["candidates"])) + blocked_preflight = preflight_requirement_gate( + partial["updated"], + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("BLOCKED", blocked_preflight["status"]) + self.assertEqual(0, blocked_preflight["write_count"]) + + full_evidence = dict(partial_evidence) + full_evidence["CHK-UX-017"] = ["spec.md#FR-017"] + full = reconcile_requirement_gate( + partial["updated"], + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + current_evidence_by_check=full_evidence, + ) + self.assertEqual("PASS", full["status"]) + ready_preflight = preflight_requirement_gate( + full["updated"], + current_spec_revision=self.CURRENT_REVISION, + all_spec_refs={"FR-017"}, + ) + self.assertEqual("PASS", ready_preflight["status"]) + self.assertEqual(0, ready_preflight["write_count"]) + self.assertEqual("CORE_PRE_EXECUTION_HOOKS", ready_preflight["next_step"]) + self.assertFalse(ready_preflight["hooks_started"]) + self.assertFalse(ready_preflight["core_setup_started"]) + + def test_spec_and_blocker_id_split_merge_retire_are_traceable(self) -> None: + spec_records = [ + { + "id": "FR-001", + "status": "REPLACED", + "replacement_refs": ["FR-002", "FR-003"], + "reason": "Split two meanings.", + }, + { + "id": "FR-002", + "status": "ACTIVE", + "replacement_refs": [], + }, + { + "id": "FR-003", + "status": "RETIRED", + "replacement_refs": [], + "reason": "No longer in scope.", + }, + { + "id": "FR-004", + "status": "REPLACED", + "replacement_refs": ["FR-002"], + "reason": "Merged into the current authorization rule.", + }, + ] + validate_id_lifecycle(spec_records, kind="Spec") + + blocker_records = [ + { + "id": "BLK-001", + "status": "SUPERSEDED", + "replacement_refs": ["BLK-003"], + }, + { + "id": "BLK-002", + "status": "SUPERSEDED", + "replacement_refs": ["BLK-003"], + }, + { + "id": "BLK-003", + "status": "OPEN", + "replacement_refs": [], + }, + { + "id": "BLK-004", + "status": "RETIRED", + "replacement_refs": [], + "reason": "The referenced product scope was retired.", + }, + ] + validate_id_lifecycle(blocker_records, kind="Blocker") + + with self.assertRaisesRegex(ValueError, "unknown successor"): + validate_id_lifecycle( + [ + { + "id": "BLK-001", + "status": "SUPERSEDED", + "replacement_refs": ["BLK-404"], + } + ], + kind="Blocker", + ) + + with self.assertRaisesRegex(ValueError, "replacement cycle"): + validate_id_lifecycle( + [ + { + "id": "FR-010", + "status": "REPLACED", + "replacement_refs": ["FR-011"], + }, + { + "id": "FR-011", + "status": "REPLACED", + "replacement_refs": ["FR-010"], + }, + ], + kind="Spec", + ) + + def test_wrapper_capability_failure_is_an_explicit_blocker(self) -> None: + compatible = inspect_core_wrapper_contract( + checklist_output_paths=[CANONICAL_REQUIREMENT_GATE_PATH], + plan_events=[ + {"name": "path-resolution", "writes": False}, + {"name": "canonical-preflight", "writes": False}, + {"name": "before-plan-hook", "hook": True, "writes": False}, + {"name": "core-setup", "writes": True}, + ], + ) + self.assertEqual("PASS", compatible["status"]) + + extra_output = inspect_core_wrapper_contract( + checklist_output_paths=[ + CANONICAL_REQUIREMENT_GATE_PATH, + "checklists/security.md", + ], + plan_events=[ + {"name": "path-resolution", "writes": False}, + {"name": "canonical-preflight", "writes": False}, + {"name": "core-setup", "writes": True}, + ], + ) + write_first = inspect_core_wrapper_contract( + checklist_output_paths=[CANONICAL_REQUIREMENT_GATE_PATH], + plan_events=[ + {"name": "path-resolution", "writes": False}, + {"name": "core-setup", "writes": True}, + {"name": "canonical-preflight", "writes": False}, + ], + ) + for result in (extra_output, write_first): + self.assertEqual("BLOCKED", result["status"]) + self.assertEqual( + {"REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE"}, + {finding["code"] for finding in result["findings"]}, + ) + + +class CoreWrapperInstallationTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("specify"), "specify CLI is not on PATH") + def test_installed_composition_enforces_single_output_and_write_first_gate( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + temp_root = Path(temp_dir) + source = temp_root / "source" + project = temp_root / "project" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns(".git", ".venv", "__pycache__"), + ) + subprocess.run( + [ + "specify", + "init", + str(project), + "--integration", + "codex", + "--ignore-agent-tools", + ], + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["specify", "preset", "remove", "workflow-preset"], + cwd=project, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["specify", "preset", "add", "--dev", str(source)], + cwd=project, + check=True, + capture_output=True, + text=True, + ) + + composed = ( + project + / ".specify" + / "presets" + / "workflow-preset" + / ".composed" + ) + checklist = read(composed / "speckit.checklist.md") + plan = read(composed / "speckit.plan.md") + + self.assertNotIn("{CORE_TEMPLATE}", checklist) + self.assertLess( + checklist.index("## Preset Checklist Ownership"), + checklist.index("## User Input"), + ) + self.assertLess( + checklist.index("Create or recompute `FEATURE_DIR/checklists/.md`"), + checklist.index("## Authoritative Core Conflict Resolution"), + ) + self.assertIn( + "do not execute any\nembedded Core step that creates " + "Domain/advisory/focus checklist files", + checklist, + ) + self.assertIn( + "any $ARGUMENTS focus\n -> exactly " + "FEATURE_DIR/checklists/requirements.md", + checklist, + ) + + canonical_preflight = plan.index( + "## Canonical Requirement Gate Preflight" + ) + inherited_preflight = plan.index("## Requirement Gate Preflight") + pre_execution_hooks = plan.index("## Pre-Execution Checks") + first_write = plan.index("1. **Materialize plan**") + self.assertLess(canonical_preflight, inherited_preflight) + self.assertLess(inherited_preflight, pre_execution_hooks) + self.assertLess(pre_execution_hooks, first_write) + for core_marker in ( + "### Phase 0: Outline & Research", + "### Phase 1: Design & Contracts", + "Re-evaluate Constitution Check post-design", + "## Mandatory Post-Execution Hooks", + "## Completion Report", + ): + self.assertIn(core_marker, plan) + self.assertIn( + "path-only resolution -> canonical preflight " + "-> Core pre-execution hooks\n -> Core write-bearing setup", + plan, + ) class UISpecContractTests(unittest.TestCase): diff --git a/presets/workflow-preset/validators/speckit_requirement_gate_contract.py b/presets/workflow-preset/validators/speckit_requirement_gate_contract.py new file mode 100644 index 0000000000..dd13447737 --- /dev/null +++ b/presets/workflow-preset/validators/speckit_requirement_gate_contract.py @@ -0,0 +1,1525 @@ +"""Pure in-memory contract helpers for the canonical Requirement Gate. + +The helpers deliberately do not parse or write Markdown. Tests pass an +in-memory projection of ``checklists/requirements.md`` so the preset can prove +cross-field rules without introducing a runtime, manifest, or transaction +protocol. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from copy import deepcopy +import re +from typing import Any, Iterable, Mapping + + +REQUIREMENT_GATE_CONTRACT = "speckit.requirement-gate.v1" +CANONICAL_REQUIREMENT_GATE_PATH = "checklists/requirements.md" +STANDARD_REQUIREMENT_GATES = ( + "requirements", + "behavior", + "ux", + "security", + "nfr", + "visual", +) +REQUIREMENT_RULE_GATES = { + "BEH-CASES": {"behavior"}, + "BEH-OBSERVABLE": {"behavior"}, + "BEH-LIFECYCLE": {"behavior"}, + "BEH-SCOPE": {"behavior"}, + "DOM-SCOPE": {"requirements", "ux", "security"}, + "DOM-ACTOR-STATE": {"requirements", "ux", "security"}, + "DOM-MEASURE": {"requirements", "ux", "security"}, + "DOM-COVERAGE": {"requirements", "ux", "security"}, + "NFR-MEASURE": {"nfr"}, + "NFR-COVERAGE": {"nfr"}, + "NFR-CONTEXT": {"nfr"}, + "NFR-ABSTRACTION": {"nfr"}, + "UI-STATES": {"ux"}, + "UX-JOURNEY": {"ux"}, + "VIS-TRACE": {"visual"}, + "VIS-SOURCE": {"visual"}, + "UI-EVIDENCE": {"visual"}, + "UI-INFERENCE": {"visual"}, + "RST-COVERAGE": {"visual"}, + "PXR-PROFILE": {"visual"}, + "PXR-EXCEPTION": {"visual"}, + "ADP-COVERAGE": {"visual"}, + "ADP-TRACE": {"visual"}, + "UI-BOUNDARY": {"requirements"}, +} +LEGACY_DOMAIN_PATHS = { + "checklists/behavior.md", + "checklists/ux.md", + "checklists/security.md", + "checklists/nfr.md", + "checklists/visual.md", +} +BLOCKER_CLASSES = { + "product-decision", + "source-evidence", + "template-structure", + "legacy-layout", +} +BLOCKER_OWNERS = { + "product-decision": "clarify", + "source-evidence": "source-owner", + "template-structure": "checklist", + "legacy-layout": "checklist", +} +SHA256_REVISION_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") +SPEC_EVIDENCE_PATTERN = re.compile( + r"^spec\.md#(?P[A-Z][A-Z0-9]*-\d+)(?::.+)?$" +) +PLACEHOLDER_PATTERN = re.compile( + r"(?:\bTODO\b|\bTBD\b|NEEDS CLARIFICATION|<[^>]+>|\[(?:TODO|TBD)\])", + flags=re.IGNORECASE, +) +BUNDLE_KEYS = { + "path", + "metadata", + "semantic_groups", + "gate_summary", + "planning_readiness", +} +METADATA_KEYS = {"stage", "contract", "spec_revision", "planning_readiness"} +GROUP_KEYS = {"spec_ref", "checks", "blockers", "manual_notes"} +CHECK_KEYS = { + "id", + "rule_key", + "gate", + "concern", + "spec_refs", + "status", + "evidence_refs", + "blocker_ref", +} +BLOCKER_KEYS = { + "id", + "primary_spec_ref", + "semantic_key", + "gap", + "affected_check_ids", + "class", + "owner", + "status", + "replacement_refs", + "reason", +} +SUMMARY_KEYS = { + "gate", + "applicability", + "applicability_reason", + "status", + "check_refs", + "blocker_refs", + "check_count", + "blocker_count", +} +READINESS_KEYS = {"status", "spec_revision", "blocker_refs"} + + +def _finding(code: str, evidence: str, *, path: str = "") -> dict[str, str]: + return {"code": code, "path": path, "evidence": evidence} + + +def _non_empty_strings(value: Any) -> bool: + return ( + isinstance(value, list) + and bool(value) + and all(isinstance(item, str) and item.strip() for item in value) + ) + + +def _spec_refs_from_evidence(value: Any) -> set[str]: + if not _non_empty_strings(value): + return set() + refs: set[str] = set() + for item in value: + match = SPEC_EVIDENCE_PATTERN.fullmatch(item) + if match is None: + return set() + refs.add(match.group("spec_ref")) + return refs + + +def _reason_has_current_spec_ref(reason: Any, known_spec_refs: set[str]) -> bool: + if not isinstance(reason, str) or not reason.strip(): + return False + return any( + re.search(rf"(? list[str]: + return sorted(str(key) for key in set(value) - allowed) + + +def _has_placeholder(value: Any) -> bool: + return isinstance(value, str) and PLACEHOLDER_PATTERN.search(value) is not None + + +def _open_blockers(groups: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + blocker + for group in groups + for blocker in group.get("blockers", []) + if isinstance(blocker, dict) and blocker.get("status") == "OPEN" + ] + + +def select_authoritative_requirement_gate( + candidates: Iterable[dict[str, Any]], +) -> dict[str, Any]: + """Select the one authoritative bundle without consuming legacy files.""" + + canonical: list[dict[str, Any]] = [] + ignored: list[dict[str, Any]] = [] + findings: list[dict[str, str]] = [] + + for candidate in candidates: + path = str(candidate.get("path", "")) + if path == CANONICAL_REQUIREMENT_GATE_PATH: + canonical.append(candidate) + continue + ignored.append(candidate) + if path in LEGACY_DOMAIN_PATHS: + findings.append( + _finding( + "REQUIREMENT_GATE_LEGACY_LAYOUT", + "legacy domain file is preserved but never consumed", + path=path, + ) + ) + + if not canonical: + findings.append( + _finding( + "REQUIREMENT_GATE_MISSING", + "canonical checklists/requirements.md is missing", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + elif len(canonical) > 1: + findings.append( + _finding( + "REQUIREMENT_GATE_DUPLICATE", + "more than one canonical Requirement Gate candidate", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + + return { + "authoritative": canonical[0] if len(canonical) == 1 else None, + "ignored": ignored, + "findings": findings, + } + + +def _derive_gate_summary( + groups: list[dict[str, Any]], + applicability: Mapping[str, tuple[str, str | None]], +) -> list[dict[str, Any]]: + checks_by_gate: dict[str, list[dict[str, Any]]] = defaultdict(list) + for group in groups: + for check in group.get("checks", []): + if isinstance(check, dict): + checks_by_gate[str(check.get("gate", ""))].append(check) + + summary: list[dict[str, Any]] = [] + for gate in STANDARD_REQUIREMENT_GATES: + gate_applicability, reason = applicability[gate] + checks = checks_by_gate[gate] + blocker_refs = sorted( + { + str(check["blocker_ref"]) + for check in checks + if check.get("status") == "BLOCKED" and check.get("blocker_ref") + } + ) + if gate_applicability == "NOT_APPLICABLE": + status = "PASS" + else: + status = ( + "PASS" + if checks and all(check.get("status") == "PASS" for check in checks) + else "BLOCKED" + ) + summary.append( + { + "gate": gate, + "applicability": gate_applicability, + "applicability_reason": reason, + "status": status, + "check_refs": sorted( + str(check.get("id", "")) for check in checks if check.get("id") + ), + "blocker_refs": blocker_refs, + "check_count": len(checks), + "blocker_count": len(blocker_refs), + } + ) + return summary + + +def _derive_planning_readiness( + *, + spec_revision: str, + current_spec_revision: str, + gate_summary: list[dict[str, Any]], + groups: list[dict[str, Any]], +) -> dict[str, Any]: + blocker_refs = sorted( + str(blocker["id"]) + for blocker in _open_blockers(groups) + if blocker.get("id") + ) + ready = ( + spec_revision == current_spec_revision + and len(gate_summary) == len(STANDARD_REQUIREMENT_GATES) + and all(item.get("status") == "PASS" for item in gate_summary) + and not blocker_refs + ) + return { + "status": "PASS" if ready else "BLOCKED", + "spec_revision": spec_revision, + "blocker_refs": blocker_refs, + } + + +def rebuild_requirement_gate( + *, + spec_revision: str, + all_spec_refs: Iterable[str], + semantic_groups: Iterable[dict[str, Any]], + applicability: Mapping[str, tuple[str, str | None]], + previous_bundle: dict[str, Any] | None = None, + focus: str | None = None, +) -> dict[str, Any]: + """Assemble Checklist's one canonical in-memory bundle. + + ``focus`` intentionally cannot select a path or omit a Gate. Stable IDs and + lifecycle rows come from the supplied semantic records. Clearly delimited + manual notes are carried forward by stable Spec ref but never participate + in derivation. + """ + + del focus + if SHA256_REVISION_PATTERN.fullmatch(spec_revision) is None: + raise ValueError("Spec Revision must be an exact lowercase SHA-256") + + groups = deepcopy(list(semantic_groups)) + previous_notes: dict[str, Any] = {} + if isinstance(previous_bundle, dict): + for old_group in previous_bundle.get("semantic_groups", []): + if ( + isinstance(old_group, dict) + and isinstance(old_group.get("spec_ref"), str) + and "manual_notes" in old_group + ): + previous_notes.setdefault( + old_group["spec_ref"], + deepcopy(old_group["manual_notes"]), + ) + for group in groups: + if ( + isinstance(group, dict) + and "manual_notes" not in group + and group.get("spec_ref") in previous_notes + ): + group["manual_notes"] = previous_notes[group["spec_ref"]] + + missing_gates = set(STANDARD_REQUIREMENT_GATES) - set(applicability) + extra_gates = set(applicability) - set(STANDARD_REQUIREMENT_GATES) + if missing_gates or extra_gates: + raise ValueError("applicability must define exactly the six standard Gates") + + summary = _derive_gate_summary(groups, applicability) + readiness = _derive_planning_readiness( + spec_revision=spec_revision, + current_spec_revision=spec_revision, + gate_summary=summary, + groups=groups, + ) + bundle = { + "path": CANONICAL_REQUIREMENT_GATE_PATH, + "metadata": { + "stage": "requirements", + "contract": REQUIREMENT_GATE_CONTRACT, + "spec_revision": spec_revision, + "planning_readiness": readiness["status"], + }, + "semantic_groups": groups, + "gate_summary": summary, + "planning_readiness": readiness, + } + result = inspect_requirement_gate_bundle( + bundle, + current_spec_revision=spec_revision, + all_spec_refs=all_spec_refs, + ) + if result["status"] != "PASS": + codes = ", ".join(finding["code"] for finding in result["findings"]) + raise ValueError(f"assembled Requirement Gate is invalid: {codes}") + return bundle + + +def inspect_core_wrapper_contract( + *, + checklist_output_paths: Iterable[str], + plan_events: Iterable[dict[str, Any]], +) -> dict[str, Any]: + """Validate wrapper capabilities without parsing command Markdown.""" + + findings: list[dict[str, str]] = [] + outputs = list(checklist_output_paths) + if outputs != [CANONICAL_REQUIREMENT_GATE_PATH]: + findings.append( + _finding( + "REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE", + f"Checklist outputs must be exactly [{CANONICAL_REQUIREMENT_GATE_PATH}]", + ) + ) + + events = list(plan_events) + names = [str(event.get("name", "")) for event in events] + required = ("path-resolution", "canonical-preflight", "core-setup") + if any(name not in names for name in required): + findings.append( + _finding( + "REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE", + "Plan wrapper is missing path resolution, canonical preflight, or Core setup", + ) + ) + else: + path_index = names.index("path-resolution") + preflight_index = names.index("canonical-preflight") + setup_index = names.index("core-setup") + first_write_index = next( + ( + index + for index, event in enumerate(events) + if event.get("writes") is True + ), + len(events), + ) + first_hook_index = next( + ( + index + for index, event in enumerate(events) + if event.get("hook") is True + ), + len(events), + ) + if not ( + path_index < preflight_index < setup_index + and preflight_index < first_write_index + and preflight_index < first_hook_index + ): + findings.append( + _finding( + "REQUIREMENT_GATE_CORE_WRAPPER_INCOMPATIBLE", + "canonical preflight must precede every hook and write-bearing Core step", + ) + ) + + return { + "status": "PASS" if not findings else "BLOCKED", + "findings": findings, + } + + +def inspect_requirement_gate_bundle( + bundle: Any, + *, + current_spec_revision: str, + all_spec_refs: Iterable[str], + require_ready: bool = False, +) -> dict[str, Any]: + """Validate structure, references, and strictly derived state. + + Findings are stable records rather than exceptions so one test can assert + several independent contract failures. + """ + + findings: list[dict[str, str]] = [] + if not isinstance(bundle, dict): + return { + "status": "BLOCKED", + "findings": [ + _finding( + "REQUIREMENT_GATE_MALFORMED", + "bundle must be an object", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ], + "derived_gate_summary": [], + "derived_planning_readiness": None, + } + extra_bundle_keys = _unexpected_keys(bundle, BUNDLE_KEYS) + if extra_bundle_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"unexpected document fields: {extra_bundle_keys}", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + + path = str(bundle.get("path", "")) + if path != CANONICAL_REQUIREMENT_GATE_PATH: + findings.append( + _finding( + "REQUIREMENT_GATE_PATH_INVALID", + path or "", + path=path, + ) + ) + + metadata = bundle.get("metadata") + if not isinstance(metadata, dict): + findings.append( + _finding( + "REQUIREMENT_GATE_METADATA_MALFORMED", + "File Metadata is missing", + path=path, + ) + ) + metadata = {} + else: + extra_metadata_keys = _unexpected_keys(metadata, METADATA_KEYS) + if extra_metadata_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"unexpected File Metadata fields: {extra_metadata_keys}", + path=path, + ) + ) + if metadata.get("stage") != "requirements": + findings.append( + _finding( + "REQUIREMENT_GATE_METADATA_MALFORMED", + "Stage must be requirements", + path=path, + ) + ) + if metadata.get("contract") != REQUIREMENT_GATE_CONTRACT: + findings.append( + _finding( + "REQUIREMENT_GATE_METADATA_MALFORMED", + f"contract must be {REQUIREMENT_GATE_CONTRACT}", + path=path, + ) + ) + spec_revision = metadata.get("spec_revision") + if ( + not isinstance(spec_revision, str) + or SHA256_REVISION_PATTERN.fullmatch(spec_revision) is None + ): + findings.append( + _finding( + "REQUIREMENT_GATE_METADATA_MALFORMED", + "Spec Revision must use sha256:<64 lowercase hex characters>", + path=path, + ) + ) + spec_revision = "" + elif spec_revision != current_spec_revision: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REVISION_STALE", + f"{spec_revision} != {current_spec_revision}", + path=path, + ) + ) + + groups = bundle.get("semantic_groups") + if not isinstance(groups, list) or not groups: + findings.append( + _finding( + "REQUIREMENT_GATE_GROUPS_MALFORMED", + "Semantic Requirement Groups must be a non-empty list", + path=path, + ) + ) + groups = [] + + known_spec_refs = set(all_spec_refs) + seen_group_refs: set[str] = set() + seen_check_ids: set[str] = set() + seen_blocker_ids: set[str] = set() + seen_open_root_keys: set[tuple[str, str, str]] = set() + checks_by_id: dict[str, dict[str, Any]] = {} + check_group_by_id: dict[str, str] = {} + blockers_by_id: dict[str, dict[str, Any]] = {} + applicability: dict[str, tuple[str, str | None]] = {} + + raw_summary = bundle.get("gate_summary") + if isinstance(raw_summary, list): + for record in raw_summary: + if not isinstance(record, dict): + continue + gate = str(record.get("gate", "")) + if gate in STANDARD_REQUIREMENT_GATES and gate not in applicability: + applicability[gate] = ( + str(record.get("applicability", "")), + record.get("applicability_reason"), + ) + else: + raw_summary = [] + for gate in STANDARD_REQUIREMENT_GATES: + applicability.setdefault(gate, ("", None)) + + for group in groups: + if not isinstance(group, dict): + findings.append( + _finding( + "REQUIREMENT_GATE_GROUPS_MALFORMED", + "every semantic group must be an object", + path=path, + ) + ) + continue + spec_ref = str(group.get("spec_ref", "")) + extra_group_keys = _unexpected_keys(group, GROUP_KEYS) + if extra_group_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"{spec_ref or ''} unexpected group fields: {extra_group_keys}", + path=path, + ) + ) + if not spec_ref: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REF_UNKNOWN", + "semantic group has no Spec ref", + path=path, + ) + ) + elif spec_ref in seen_group_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REF_DUPLICATE", + spec_ref, + path=path, + ) + ) + elif spec_ref not in known_spec_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REF_UNKNOWN", + spec_ref, + path=path, + ) + ) + seen_group_refs.add(spec_ref) + + checks = group.get("checks") + blockers = group.get("blockers") + if not isinstance(checks, list) or not isinstance(blockers, list): + findings.append( + _finding( + "REQUIREMENT_GATE_GROUPS_MALFORMED", + f"{spec_ref} needs Check and Blocker lists", + path=path, + ) + ) + continue + + for check in checks: + if not isinstance(check, dict): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_MALFORMED", + f"{spec_ref} contains a non-object Check", + path=path, + ) + ) + continue + check_id = str(check.get("id", "")) + extra_check_keys = _unexpected_keys(check, CHECK_KEYS) + if extra_check_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"{check_id or ''} unexpected Check fields: {extra_check_keys}", + path=path, + ) + ) + if not check_id: + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_MALFORMED", + f"{spec_ref} Check has no ID", + path=path, + ) + ) + continue + if check_id in seen_check_ids: + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_DUPLICATE", + check_id, + path=path, + ) + ) + seen_check_ids.add(check_id) + checks_by_id[check_id] = check + check_group_by_id[check_id] = spec_ref + + gate = check.get("gate") + rule_key = str(check.get("rule_key", "")) + if ( + gate not in STANDARD_REQUIREMENT_GATES + or rule_key not in REQUIREMENT_RULE_GATES + or gate not in REQUIREMENT_RULE_GATES.get(rule_key, set()) + or not str(check.get("concern", "")).strip() + ): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_MALFORMED", + f"{check_id} needs a mapped Rule key, one allowed Gate, and one atomic concern", + path=path, + ) + ) + if _has_placeholder(check.get("concern")): + findings.append( + _finding( + "REQUIREMENT_GATE_PLACEHOLDER", + f"{check_id} concern contains a placeholder", + path=path, + ) + ) + spec_refs = check.get("spec_refs") + if not _non_empty_strings(spec_refs): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_MALFORMED", + f"{check_id} needs resolvable Spec refs", + path=path, + ) + ) + else: + if len(set(spec_refs)) != len(spec_refs): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_MALFORMED", + f"{check_id} contains duplicate Spec refs", + path=path, + ) + ) + if spec_ref not in spec_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_GROUP_MISMATCH", + f"{check_id} does not reference owning group {spec_ref}", + path=path, + ) + ) + for check_spec_ref in spec_refs: + if check_spec_ref not in known_spec_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REF_UNKNOWN", + f"{check_id} -> {check_spec_ref}", + path=path, + ) + ) + + status = check.get("status") + evidence_refs = check.get("evidence_refs") + blocker_ref = check.get("blocker_ref") + if status == "PASS": + if not _non_empty_strings(evidence_refs) or blocker_ref is not None: + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_RESULT_INVALID", + f"{check_id} PASS needs evidence and no Blocker", + path=path, + ) + ) + else: + evidence_spec_refs = _spec_refs_from_evidence(evidence_refs) + if ( + not evidence_spec_refs + or not set(spec_refs).issubset(evidence_spec_refs) + or not evidence_spec_refs.issubset(known_spec_refs) + ): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_EVIDENCE_INVALID", + f"{check_id} evidence must resolve every Check Spec ref in current spec.md", + path=path, + ) + ) + elif status == "BLOCKED": + if evidence_refs not in ([], None) or not isinstance( + blocker_ref, str + ): + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_RESULT_INVALID", + f"{check_id} BLOCKED needs exactly one root-cause Blocker", + path=path, + ) + ) + else: + findings.append( + _finding( + "REQUIREMENT_GATE_CHECK_RESULT_INVALID", + f"{check_id} has invalid status", + path=path, + ) + ) + + for blocker in blockers: + if not isinstance(blocker, dict): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_MALFORMED", + f"{spec_ref} contains a non-object Blocker", + path=path, + ) + ) + continue + blocker_id = str(blocker.get("id", "")) + extra_blocker_keys = _unexpected_keys(blocker, BLOCKER_KEYS) + if extra_blocker_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"{blocker_id or ''} unexpected Blocker fields: {extra_blocker_keys}", + path=path, + ) + ) + if not blocker_id: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_MALFORMED", + f"{spec_ref} Blocker has no ID", + path=path, + ) + ) + continue + if blocker_id in seen_blocker_ids: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_DUPLICATE", + blocker_id, + path=path, + ) + ) + seen_blocker_ids.add(blocker_id) + blockers_by_id[blocker_id] = blocker + + blocker_class = blocker.get("class") + if blocker_class not in BLOCKER_CLASSES: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_CLASS_INVALID", + blocker_id, + path=path, + ) + ) + elif blocker.get("owner") != BLOCKER_OWNERS[blocker_class]: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_OWNER_INVALID", + blocker_id, + path=path, + ) + ) + if ( + blocker.get("primary_spec_ref") != spec_ref + or not str(blocker.get("semantic_key", "")).strip() + or not str(blocker.get("gap", "")).strip() + ): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_MALFORMED", + f"{blocker_id} needs primary ref, semantic key, and minimal gap", + path=path, + ) + ) + if _has_placeholder(blocker.get("gap")): + findings.append( + _finding( + "REQUIREMENT_GATE_PLACEHOLDER", + f"{blocker_id} gap contains a placeholder", + path=path, + ) + ) + if blocker.get("status") == "OPEN": + root_key = ( + spec_ref, + str(blocker.get("semantic_key", "")), + str(blocker_class), + ) + if root_key in seen_open_root_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_ROOT_DUPLICATE", + f"{spec_ref}:{root_key[1]}", + path=path, + ) + ) + seen_open_root_keys.add(root_key) + if blocker.get("status") not in { + "OPEN", + "RESOLVED", + "RETIRED", + "SUPERSEDED", + }: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_MALFORMED", + f"{blocker_id} has invalid lifecycle status", + path=path, + ) + ) + replacement_refs = blocker.get("replacement_refs") + if not isinstance(replacement_refs, list) or not all( + isinstance(item, str) and item.strip() + for item in replacement_refs + ): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} replacement refs must be a list of IDs", + path=path, + ) + ) + elif len(set(replacement_refs)) != len(replacement_refs): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} contains duplicate successor refs", + path=path, + ) + ) + if blocker.get("status") == "SUPERSEDED" and not replacement_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} SUPERSEDED needs successor refs", + path=path, + ) + ) + if blocker.get("status") in {"OPEN", "RESOLVED", "RETIRED"} and ( + replacement_refs + ): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} current/resolved/retired state cannot have successors", + path=path, + ) + ) + if blocker.get("status") == "RETIRED" and not str( + blocker.get("reason", "") + ).strip(): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} RETIRED needs a reason", + path=path, + ) + ) + + inbound_by_blocker: dict[str, set[str]] = defaultdict(set) + for check_id, check in checks_by_id.items(): + blocker_ref = check.get("blocker_ref") + if isinstance(blocker_ref, str): + inbound_by_blocker[blocker_ref].add(check_id) + if blocker_ref not in blockers_by_id: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_UNKNOWN", + f"{check_id} -> {blocker_ref}", + path=path, + ) + ) + + for blocker_id, blocker in blockers_by_id.items(): + affected = blocker.get("affected_check_ids") + affected_set = set(affected) if _non_empty_strings(affected) else set() + if _non_empty_strings(affected) and len(affected_set) != len(affected): + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_AFFECTED_CHECK_MISMATCH", + f"{blocker_id} contains duplicate affected Check refs", + path=path, + ) + ) + inbound = inbound_by_blocker.get(blocker_id, set()) + if blocker.get("status") == "OPEN": + if affected_set != inbound or not inbound: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_AFFECTED_CHECK_MISMATCH", + f"{blocker_id}: declared={sorted(affected_set)} actual={sorted(inbound)}", + path=path, + ) + ) + cross_group_checks = sorted( + check_id + for check_id in inbound + if check_group_by_id.get(check_id) + != blocker.get("primary_spec_ref") + ) + if cross_group_checks: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_CROSS_GROUP", + f"{blocker_id} crosses Semantic Groups: {cross_group_checks}", + path=path, + ) + ) + elif inbound: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} is not OPEN but still blocks current Checks", + path=path, + ) + ) + for successor in blocker.get("replacement_refs", []): + if successor == blocker_id or successor not in blockers_by_id: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + f"{blocker_id} has invalid successor {successor}", + path=path, + ) + ) + + if blockers_by_id: + try: + validate_id_lifecycle(blockers_by_id.values(), kind="Blocker") + except ValueError as error: + findings.append( + _finding( + "REQUIREMENT_GATE_BLOCKER_LIFECYCLE_INVALID", + str(error), + path=path, + ) + ) + + missing_group_refs = known_spec_refs - seen_group_refs + if missing_group_refs: + findings.append( + _finding( + "REQUIREMENT_GATE_SPEC_REF_MISSING", + f"missing Semantic Groups: {sorted(missing_group_refs)}", + path=path, + ) + ) + + summary_counts = Counter( + str(record.get("gate", "")) + for record in raw_summary + if isinstance(record, dict) + ) + for gate in STANDARD_REQUIREMENT_GATES: + if summary_counts[gate] == 0: + findings.append( + _finding( + "REQUIREMENT_GATE_SUMMARY_MISSING", + gate, + path=path, + ) + ) + elif summary_counts[gate] > 1: + findings.append( + _finding( + "REQUIREMENT_GATE_SUMMARY_DUPLICATE", + gate, + path=path, + ) + ) + app, reason = applicability[gate] + matching_summary_records = [ + record + for record in raw_summary + if isinstance(record, dict) and record.get("gate") == gate + ] + for record in matching_summary_records: + extra_summary_keys = _unexpected_keys(record, SUMMARY_KEYS) + if extra_summary_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"{gate} unexpected Summary fields: {extra_summary_keys}", + path=path, + ) + ) + if _has_placeholder(reason): + findings.append( + _finding( + "REQUIREMENT_GATE_PLACEHOLDER", + f"{gate} applicability reason contains a placeholder", + path=path, + ) + ) + if app not in {"APPLICABLE", "NOT_APPLICABLE"}: + findings.append( + _finding( + "REQUIREMENT_GATE_APPLICABILITY_INVALID", + gate, + path=path, + ) + ) + elif app == "NOT_APPLICABLE" and not _reason_has_current_spec_ref( + reason, known_spec_refs + ): + findings.append( + _finding( + "REQUIREMENT_GATE_APPLICABILITY_REASON_MISSING", + f"{gate} needs a concrete current Spec ref", + path=path, + ) + ) + if app == "NOT_APPLICABLE" and any( + check.get("gate") == gate for check in checks_by_id.values() + ): + findings.append( + _finding( + "REQUIREMENT_GATE_NOT_APPLICABLE_HAS_CHECKS", + gate, + path=path, + ) + ) + + derived_summary = _derive_gate_summary(groups, applicability) + if raw_summary != derived_summary: + findings.append( + _finding( + "REQUIREMENT_GATE_SUMMARY_DRIFT", + "Six-Gate Summary is not strictly derived from current Checks", + path=path, + ) + ) + + derived_readiness = _derive_planning_readiness( + spec_revision=spec_revision, + current_spec_revision=current_spec_revision, + gate_summary=derived_summary, + groups=groups, + ) + raw_readiness = bundle.get("planning_readiness") + if isinstance(raw_readiness, dict): + extra_readiness_keys = _unexpected_keys(raw_readiness, READINESS_KEYS) + if extra_readiness_keys: + findings.append( + _finding( + "REQUIREMENT_GATE_LAYOUT_EXTRA_FIELD", + f"unexpected Planning Readiness fields: {extra_readiness_keys}", + path=path, + ) + ) + if raw_readiness != derived_readiness: + findings.append( + _finding( + "PLANNING_READINESS_DERIVATION_INVALID", + "Planning Readiness is not strictly derived", + path=path, + ) + ) + if metadata.get("planning_readiness") != derived_readiness["status"]: + findings.append( + _finding( + "PLANNING_READINESS_METADATA_DRIFT", + "File Metadata Planning Readiness disagrees with derived state", + path=path, + ) + ) + if require_ready and derived_readiness["status"] != "PASS": + findings.append( + _finding( + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + "all applicable Gates must PASS with zero open Blockers", + path=path, + ) + ) + + return { + "status": "PASS" if not findings else "BLOCKED", + "findings": findings, + "derived_gate_summary": derived_summary, + "derived_planning_readiness": derived_readiness, + } + + +def clarification_candidates( + bundle: dict[str, Any], + *, + limit: int = 5, + priority_by_blocker: Mapping[str, tuple[int, int]] | None = None, +) -> list[dict[str, Any]]: + """Return at most five product questions, one per shared root cause.""" + + if not isinstance(limit, int) or limit < 1 or limit > 5: + raise ValueError("Clarify candidate limit must be between 1 and 5") + if priority_by_blocker is not None and any( + not isinstance(score, tuple) + or len(score) != 2 + or not all(isinstance(value, int) and value >= 0 for value in score) + for score in priority_by_blocker.values() + ): + raise ValueError("Clarify priority must be non-negative impact/uncertainty") + + candidates: list[dict[str, Any]] = [] + for group in bundle.get("semantic_groups", []): + for blocker in group.get("blockers", []): + if ( + blocker.get("status") == "OPEN" + and blocker.get("class") == "product-decision" + ): + candidates.append( + { + "blocker_id": blocker["id"], + "spec_ref": blocker["primary_spec_ref"], + "semantic_key": blocker["semantic_key"], + "gap": blocker["gap"], + "affected_check_ids": list(blocker["affected_check_ids"]), + } + ) + priorities = priority_by_blocker or {} + candidates.sort( + key=lambda candidate: ( + -( + priorities.get(candidate["blocker_id"], (0, 0))[0] + * priorities.get(candidate["blocker_id"], (0, 0))[1] + ), + candidate["blocker_id"], + ) + ) + return candidates[:limit] + + +def reconcile_requirement_gate( + bundle: dict[str, Any] | None, + *, + current_spec_revision: str, + all_spec_refs: Iterable[str], + current_evidence_by_check: Mapping[str, list[str]], +) -> dict[str, Any]: + """Refresh all Check results and derived state after a Clarify Spec write. + + The caller supplies evidence from the current Spec. This makes a stale + Revision or zero-question closeout re-evaluate every Check instead of + trusting prior PASS text. + """ + + if bundle is None: + return { + "updated": None, + "status": "BLOCKED", + "findings": [ + _finding( + "REQUIREMENT_GATE_MISSING", + "Clarify must not create the missing Gate", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ], + "candidates": [], + } + + updated = deepcopy(bundle) + raw_gate_summary = updated.get("gate_summary") + summary_gates = ( + [ + record.get("gate") + for record in raw_gate_summary + if isinstance(record, dict) + ] + if isinstance(raw_gate_summary, list) + else [] + ) + if ( + updated.get("path") != CANONICAL_REQUIREMENT_GATE_PATH + or not isinstance(updated.get("metadata"), dict) + or updated.get("metadata", {}).get("stage") != "requirements" + or updated.get("metadata", {}).get("contract") + != REQUIREMENT_GATE_CONTRACT + or not isinstance(updated.get("semantic_groups"), list) + or not isinstance(updated.get("gate_summary"), list) + or Counter(summary_gates) + != Counter({gate: 1 for gate in STANDARD_REQUIREMENT_GATES}) + or any( + not isinstance(group, dict) + or not group.get("spec_ref") + or not isinstance(group.get("checks"), list) + or not isinstance(group.get("blockers"), list) + for group in updated.get("semantic_groups", []) + ) + ): + return { + "updated": None, + "status": "BLOCKED", + "findings": [ + _finding( + "REQUIREMENT_GATE_MALFORMED", + "Clarify preserves malformed Canonical Layout unchanged", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ], + "candidates": [], + } + + stored_revision = updated["metadata"].get("spec_revision") + structural_result = inspect_requirement_gate_bundle( + updated, + current_spec_revision=( + stored_revision if isinstance(stored_revision, str) else "" + ), + all_spec_refs=all_spec_refs, + ) + repairable_derived_codes = { + "REQUIREMENT_GATE_SUMMARY_DRIFT", + "PLANNING_READINESS_DERIVATION_INVALID", + "PLANNING_READINESS_METADATA_DRIFT", + } + structural_findings = [ + finding + for finding in structural_result["findings"] + if finding["code"] not in repairable_derived_codes + ] + if structural_findings: + return { + "updated": None, + "status": "BLOCKED", + "findings": [ + _finding( + "REQUIREMENT_GATE_MALFORMED", + "Clarify preserves malformed Canonical Layout unchanged", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ), + *structural_findings, + ], + "candidates": [], + } + + reconciliation_findings: list[dict[str, str]] = [] + for group in updated["semantic_groups"]: + for check in group.get("checks", []): + check_id = str(check.get("id", "")) + evidence = current_evidence_by_check.get(check_id, []) + if _non_empty_strings(evidence): + check["status"] = "PASS" + check["evidence_refs"] = list(evidence) + check["blocker_ref"] = None + else: + check["status"] = "BLOCKED" + check["evidence_refs"] = [] + if not check.get("blocker_ref"): + reconciliation_findings.append( + _finding( + "REQUIREMENT_GATE_RECONCILIATION_BLOCKER_REQUIRED", + f"{check_id} has no current evidence or root-cause Blocker", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + + if reconciliation_findings: + return { + "updated": None, + "status": "BLOCKED", + "findings": reconciliation_findings, + "candidates": clarification_candidates(bundle), + } + + for group in updated["semantic_groups"]: + blockers_by_id = { + blocker.get("id"): blocker for blocker in group.get("blockers", []) + } + inbound: dict[str, list[str]] = defaultdict(list) + for check in group.get("checks", []): + if check.get("status") == "BLOCKED" and check.get("blocker_ref"): + inbound[str(check["blocker_ref"])].append(str(check["id"])) + for blocker_id, blocker in blockers_by_id.items(): + affected = sorted(inbound.get(str(blocker_id), [])) + if affected: + blocker["status"] = "OPEN" + blocker["affected_check_ids"] = affected + elif blocker.get("status") == "OPEN": + blocker["status"] = "RESOLVED" + blocker["affected_check_ids"] = [] + + updated["metadata"]["spec_revision"] = current_spec_revision + applicability = { + record["gate"]: ( + record["applicability"], + record.get("applicability_reason"), + ) + for record in updated["gate_summary"] + if isinstance(record, dict) + and record.get("gate") in STANDARD_REQUIREMENT_GATES + } + for gate in STANDARD_REQUIREMENT_GATES: + applicability.setdefault(gate, ("", None)) + updated["gate_summary"] = _derive_gate_summary( + updated["semantic_groups"], + applicability, + ) + updated["planning_readiness"] = _derive_planning_readiness( + spec_revision=current_spec_revision, + current_spec_revision=current_spec_revision, + gate_summary=updated["gate_summary"], + groups=updated["semantic_groups"], + ) + updated["metadata"]["planning_readiness"] = updated["planning_readiness"]["status"] + + unresolved_checks = sorted( + check_id + for group in updated["semantic_groups"] + for check in group.get("checks", []) + if check.get("status") == "BLOCKED" + for check_id in [str(check.get("id", ""))] + ) + findings = list(reconciliation_findings) + if unresolved_checks: + findings.append( + _finding( + "PLANNING_READINESS_BLOCKED", + ", ".join(unresolved_checks), + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + return { + "updated": updated, + "status": updated["planning_readiness"]["status"], + "findings": findings, + "candidates": clarification_candidates(updated), + } + + +def preflight_requirement_gate( + bundle: dict[str, Any] | None, + *, + current_spec_revision: str, + all_spec_refs: Iterable[str], +) -> dict[str, Any]: + """Read-only Plan preflight over the single authoritative bundle.""" + + if bundle is None: + return { + "status": "BLOCKED", + "write_count": 0, + "hooks_started": False, + "core_setup_started": False, + "next_step": None, + "findings": [ + _finding( + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + "canonical Requirement Gate is missing", + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ], + } + result = inspect_requirement_gate_bundle( + bundle, + current_spec_revision=current_spec_revision, + all_spec_refs=all_spec_refs, + require_ready=True, + ) + findings = list(result["findings"]) + if result["status"] == "BLOCKED" and not any( + finding["code"] == "REQUIREMENT_GATE_PREFLIGHT_BLOCKED" + for finding in findings + ): + smallest_reason = ( + findings[0]["code"] if findings else "unknown Requirement Gate failure" + ) + findings.append( + _finding( + "REQUIREMENT_GATE_PREFLIGHT_BLOCKED", + smallest_reason, + path=CANONICAL_REQUIREMENT_GATE_PATH, + ) + ) + return { + "status": result["status"], + "write_count": 0, + "hooks_started": False, + "core_setup_started": False, + "next_step": ( + "CORE_PRE_EXECUTION_HOOKS" if result["status"] == "PASS" else None + ), + "findings": findings, + } + + +def validate_id_lifecycle( + records: Iterable[dict[str, Any]], + *, + kind: str, +) -> None: + """Validate stable Spec or Blocker split/merge/retirement records.""" + + rows = list(records) + by_id = {str(row.get("id", "")): row for row in rows} + if "" in by_id or len(by_id) != len(rows): + raise ValueError(f"{kind} lifecycle IDs must be present and unique") + + normalized_kind = kind.casefold() + if normalized_kind == "spec": + allowed = {"ACTIVE", "REPLACED", "RETIRED", "NOT_APPLICABLE"} + replacement_status = "REPLACED" + no_successor_statuses = {"ACTIVE", "RETIRED", "NOT_APPLICABLE"} + reason_statuses = {"RETIRED", "NOT_APPLICABLE"} + elif normalized_kind == "blocker": + allowed = {"OPEN", "RESOLVED", "RETIRED", "SUPERSEDED"} + replacement_status = "SUPERSEDED" + no_successor_statuses = {"OPEN", "RESOLVED", "RETIRED"} + reason_statuses = {"RETIRED"} + else: + raise ValueError("kind must be Spec or Blocker") + + for item_id, row in by_id.items(): + status = row.get("status") + if status not in allowed: + raise ValueError(f"{kind} {item_id} has invalid lifecycle status") + replacements = row.get("replacement_refs", []) + if not isinstance(replacements, list): + raise ValueError(f"{kind} {item_id} replacement refs must be a list") + if status in no_successor_statuses and replacements: + raise ValueError(f"{kind} {item_id} {status} cannot have replacements") + if status == replacement_status and not replacements: + raise ValueError( + f"{kind} {item_id} {replacement_status} needs successor refs" + ) + if status in reason_statuses and not str( + row.get("reason", "") + ).strip(): + raise ValueError(f"{kind} {item_id} retirement/N/A needs a reason") + for replacement in replacements: + if replacement not in by_id: + raise ValueError( + f"{kind} {item_id} has unknown successor {replacement}" + ) + if replacement == item_id: + raise ValueError(f"{kind} {item_id} cannot replace itself") + + visiting: set[str] = set() + visited: set[str] = set() + + def visit(item_id: str) -> None: + if item_id in visiting: + raise ValueError(f"{kind} lifecycle contains a replacement cycle") + if item_id in visited: + return + visiting.add(item_id) + for successor in by_id[item_id].get("replacement_refs", []): + visit(successor) + visiting.remove(item_id) + visited.add(item_id) + + for item_id in by_id: + visit(item_id) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 71c905ed41..73b26efa3f 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1120,7 +1120,7 @@ def test_workflow_preset_registers_commands_and_composes_wrappers(self, tmp_path assert "Change Scope Granularity" in constitution_skill.read_text(encoding="utf-8") assert "Full-Spectrum Projection" in specify_skill.read_text(encoding="utf-8") - assert "Cross-Domain Ambiguity Map" in clarify_skill.read_text(encoding="utf-8") + assert "Shared-Root Ambiguity Map" in clarify_skill.read_text(encoding="utf-8") assert "Cross-Command Consistency Gates" in analyze_skill.read_text(encoding="utf-8") assert "X0 — Feature Plan Control" in plan_skill.read_text(encoding="utf-8") assert "PLAN_OUTPUT_READY" in tasks_skill.read_text(encoding="utf-8") diff --git a/tests/test_presets.py b/tests/test_presets.py index 40754b148e..74ed31c096 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.2.0" + assert entry["version"] == "3.2.1" assert entry["version"] == manifest["preset"]["version"] assert entry["repository"] == manifest["preset"]["repository"] assert entry["requires"]["speckit_version"] == manifest["requires"]["speckit_version"] From d47718901ff0259909440fa1f5dff75b8da112a1 Mon Sep 17 00:00:00 2001 From: bigsmartben <245982990@qq.com> Date: Fri, 31 Jul 2026 11:29:44 +0800 Subject: [PATCH 2/2] Fix community smoke clarify heading Assisted-by: Codex --- .github/workflows/community-smoke.yml | 2 +- tests/test_presets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/community-smoke.yml b/.github/workflows/community-smoke.yml index 1b0dc74758..6a0e8b5ef7 100644 --- a/.github/workflows/community-smoke.yml +++ b/.github/workflows/community-smoke.yml @@ -122,7 +122,7 @@ jobs: grep -q "Change Scope Granularity" .claude/skills/speckit-constitution/SKILL.md grep -q "Full-Spectrum Projection" .claude/skills/speckit-specify/SKILL.md - grep -q "Cross-Domain Ambiguity Map" .claude/skills/speckit-clarify/SKILL.md + grep -q "Shared-Root Ambiguity Map" .claude/skills/speckit-clarify/SKILL.md grep -q "Cross-Command Consistency Gates" .claude/skills/speckit-analyze/SKILL.md grep -q "X0 — Feature Plan Control" .claude/skills/speckit-plan/SKILL.md grep -q "PLAN_OUTPUT_READY" .claude/skills/speckit-tasks/SKILL.md diff --git a/tests/test_presets.py b/tests/test_presets.py index 74ed31c096..24a6d613c8 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4873,7 +4873,7 @@ def test_community_smoke_checks_wheel_assets_and_extension_dev_reinstall(self): ) in verify_run for marker in ( "Full-Spectrum Projection", - "Cross-Domain Ambiguity Map", + "Shared-Root Ambiguity Map", "Cross-Command Consistency Gates", "X0 — Feature Plan Control", "PLAN_OUTPUT_READY",