From 8fb2e652c87912decc825ffc6e60fe81b773e911 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:09:13 +0000 Subject: [PATCH] Update workflow-preset to v3.2.0 Assisted-by: GitHub Actions (autonomous) --- presets/catalog.community.json | 8 +- presets/catalog.json | 6 +- presets/workflow-preset.release.json | 54 +- presets/workflow-preset/CHANGELOG.md | 12 + presets/workflow-preset/README.md | 52 +- .../commands/speckit.analyze.md | 15 +- .../workflow-preset/commands/speckit.plan.md | 93 +- .../commands/speckit.specify.md | 96 +- .../workflow-preset/commands/speckit.tasks.md | 124 +- .../docs/extension-governance.md | 163 ++- presets/workflow-preset/preset.yml | 2 +- .../templates/plan-template.md | 18 +- .../templates/requirements/visual-gate.md | 13 +- .../templates/spec-template.md | 169 ++- .../templates/ui-ux-design-template.md | 75 +- .../async_retry_compensation.json | 13 + .../plan_bundles/minimal_repository.json | 13 + .../plan_bundles/non_ui_single_interface.json | 13 + .../tests/fixtures/plan_bundles/ui_only.json | 101 +- .../tests/test_preset_contract.py | 1263 ++++++++++++++++- .../validators/speckit_analyze_contract.py | 73 +- .../validators/speckit_plan_contract.py | 607 +++++++- .../validators/speckit_spec_contract.py | 738 ++++++++++ .../validators/speckit_tasks_contract.py | 875 ++++++++++++ tests/test_presets.py | 2 +- 25 files changed, 4400 insertions(+), 198 deletions(-) create mode 100644 presets/workflow-preset/validators/speckit_spec_contract.py create mode 100644 presets/workflow-preset/validators/speckit_tasks_contract.py diff --git a/presets/catalog.community.json b/presets/catalog.community.json index ba9bafb0b0..bdf168d4ed 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -670,11 +670,11 @@ "workflow-preset": { "name": "Workflow Preset", "id": "workflow-preset", - "version": "3.1.1", + "version": "3.2.0", "description": "Constitution-managed architecture, behavior-first specification, design artifacts, and execution-ready task mapping", "author": "bigsmartben", "repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", - "download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.1/spec-kit-workflow-preset-v3.1.1.zip", + "download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.2.0/spec-kit-workflow-preset-v3.2.0.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": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", - "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" + "source_commit": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", + "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" } } } diff --git a/presets/catalog.json b/presets/catalog.json index 713e92fcf6..1828b087e4 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -29,7 +29,7 @@ "workflow-preset": { "name": "Workflow Preset", "id": "workflow-preset", - "version": "3.1.1", + "version": "3.2.0", "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": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", - "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" + "source_commit": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", + "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" } } } diff --git a/presets/workflow-preset.release.json b/presets/workflow-preset.release.json index 4f67fd12f9..019abddefb 100644 --- a/presets/workflow-preset.release.json +++ b/presets/workflow-preset.release.json @@ -1,7 +1,7 @@ { "artifact": { - "name": "spec-kit-workflow-preset-v3.1.1.zip", - "sha256": "418b28ddca87a1e105df700c9063262d3a77d815cd6ac6fa6dd864e7b13d6768" + "name": "spec-kit-workflow-preset-v3.2.0.zip", + "sha256": "4215f409a81a017d9ecb3050623007c5d1d881920fc42a7777f94777ff1fb279" }, "files": [ { @@ -10,7 +10,7 @@ }, { "path": "CHANGELOG.md", - "sha256": "59a583460843504156209338956f24334cbe90933bab8736d9b864dd5b0d11fc" + "sha256": "096623162acd4a80f087359bb9616e576b44fae45c6c014006bddf26084b8ea2" }, { "path": "LICENSE", @@ -18,11 +18,11 @@ }, { "path": "README.md", - "sha256": "69a358dd0fb5ccfefd49429797bbad8d77b43783bb3263fc0ba7ed289c714b2e" + "sha256": "ddd3fd57ab57e98e0ac3d0c7eed0143218d831db9dda5ef627e32c93cc1fc3a0" }, { "path": "commands/speckit.analyze.md", - "sha256": "5fbd1e926038c6d587fd120851af4fd4c4153a678780113eeeaccc76e90cf0e0" + "sha256": "632bea1039cdeb2c0dabe8ce7a11dbba448a6d2f28e7e703c12392f488dbe7d1" }, { "path": "commands/speckit.checklist.md", @@ -38,23 +38,23 @@ }, { "path": "commands/speckit.plan.md", - "sha256": "bd9a75332655abbaa48f9d7a2673aa084090cd7069e240893d8926a460adc3a0" + "sha256": "e82cc984ab6d898ea4358cc1cc7a80d4b32c43fbd89bd408bb8ab41c20acead1" }, { "path": "commands/speckit.specify.md", - "sha256": "8c5c744f42265223ce807b38e0a0626b59125bd88f253fb9449c670e198962c6" + "sha256": "19bc99aa51d826721d49c0a4fe406d15d5f20bb6b82038465986824bdd6ae093" }, { "path": "commands/speckit.tasks.md", - "sha256": "a26f2b305cc99505bfbeb80a50e1b812c949b634e1a12780c0d5247374876339" + "sha256": "31ddb13915bc447e8bd471e501bd85bad1f939d54063f872ba1a65280e816cb8" }, { "path": "docs/extension-governance.md", - "sha256": "feddb04f5a80eed12d0439a6c56eb7590df9b20f9cb7079ef9e612e2f0a5cab2" + "sha256": "7a19c10f824371b27e9233f4699ffe8f1bf04326ed93b1061cf04f9d378e0232" }, { "path": "preset.yml", - "sha256": "9722b3121cba934f2ebd5ad77ed1c4d43eaa64c4c7e5f08fed43b1e94786f4a4" + "sha256": "97387ed796ade8df30f2385b8864c4b3affd325e30c15554f24f51df1442b94e" }, { "path": "requirements-dev.txt", @@ -126,7 +126,7 @@ }, { "path": "templates/plan-template.md", - "sha256": "10b9da0be3591bac4272efd424e2019e79e828297a4907939bf9d734ee00011c" + "sha256": "68069b9e8395951209c7afc3fc7ded4d51b8e1f31cb56de2752d6d5824a733dc" }, { "path": "templates/quickstart-template.md", @@ -146,7 +146,7 @@ }, { "path": "templates/requirements/visual-gate.md", - "sha256": "3c25de98d60f7f5f4e5352c1c8d0054b73b6c1c3ceeeb0d56c3f1d364221a828" + "sha256": "f5d4993d015f726d369de8ede3b0065fe8f5c6412a6b306cacffdc8fb43f41a4" }, { "path": "templates/sequences-template.md", @@ -154,7 +154,7 @@ }, { "path": "templates/spec-template.md", - "sha256": "400116470fc3c4dc9222eaf16b48a9f03b383158cfc271f915151cfc599dbe6a" + "sha256": "db0b7fe137cd48d7c97f037592704629b1b9d60aee8e57692bea6701d6540930" }, { "path": "templates/test-readiness-template.md", @@ -166,7 +166,7 @@ }, { "path": "templates/ui-ux-design-template.md", - "sha256": "deda68c43af32410e529b4addde3d86fabdcce37a7251ae6975a04eaefef53f5" + "sha256": "a0a86e5a31df93c295e31880bb2af6d65ce3a08949eca7f79bb144645b9b5ef0" }, { "path": "tests/contracts/speckit-cross-agent-protocol.md", @@ -174,23 +174,23 @@ }, { "path": "tests/fixtures/plan_bundles/async_retry_compensation.json", - "sha256": "4d875ff6e343aea2c518c974ca6c8d659361edb768ad24d436f24cf1d36d5864" + "sha256": "9ec8e69848b0cf20bcfc365e444ebf00d94459cbd8032547ab7d70ec0b78464c" }, { "path": "tests/fixtures/plan_bundles/minimal_repository.json", - "sha256": "86349398d9168146a9c6c79f1980eef2cd27e14c3565aa7621da9f2483df765e" + "sha256": "893c4b50845ad2d62312446c0c6461785f8c55ee869fa8a0eb8c63ebb3656bb3" }, { "path": "tests/fixtures/plan_bundles/non_ui_single_interface.json", - "sha256": "fe62e206f1167071064e840bde44c3848203953db3af7b381364d10e6347d34d" + "sha256": "4ada23a46878a14f0788315b652e0613bbe306aeac988eca012487c7f71941fa" }, { "path": "tests/fixtures/plan_bundles/ui_only.json", - "sha256": "cb653d0d759e156641e6d46943c51b889730790ecd8ce293edf98447c5e90d01" + "sha256": "3d3f47c1b5d1064456d6f9c02345cdf045af07066ab627217ef7ca1131576246" }, { "path": "tests/test_preset_contract.py", - "sha256": "f5382ee7bd11720781057abecf2a5a39ed18e069a3adaedf2fb38ad2a1e0027a" + "sha256": "d4182caa33afc838ac9296144d30e1298a7b3b67b7f24c6aca4fc6c3b6a742ea" }, { "path": "validators/__init__.py", @@ -198,7 +198,7 @@ }, { "path": "validators/speckit_analyze_contract.py", - "sha256": "8f108da51daf1f582781bcf9833946ec8bf4e99131bb0561513cd24ea1f44394" + "sha256": "5cc89e5987b139622dd6544b4e60ca04e88ccb52a3ddfa3221cfcfc28411a4f1" }, { "path": "validators/speckit_behavior_contract.py", @@ -206,7 +206,15 @@ }, { "path": "validators/speckit_plan_contract.py", - "sha256": "e724eede9933107c2a627d49a8b0178ebe6b6433fd7e0dc89ee4f6ae7852bb71" + "sha256": "db08c3417d86a58a16dffc1fe6fed77318752d03c8df07502c538df55db21045" + }, + { + "path": "validators/speckit_spec_contract.py", + "sha256": "072845c892da168a263c3790df2da3363d03db33effec28b8a2956633e46f3b5" + }, + { + "path": "validators/speckit_tasks_contract.py", + "sha256": "96f9813d5e4038af001375b62bb5779ebcf87a6353b62c1510452c4690122cdf" }, { "path": "validators/speckit_test_contract.py", @@ -215,7 +223,7 @@ ], "preset_id": "workflow-preset", "schema_version": "1.0", - "source_commit": "393db34fb4e6a2a20e03038b774594a0b2edd6bd", + "source_commit": "bf99bc24cbbf4d1462fabed90ac47bc6101e8ebb", "source_repository": "https://github.com/bigsmartben/spec-kit-workflow-preset", - "version": "3.1.1" + "version": "3.2.0" } diff --git a/presets/workflow-preset/CHANGELOG.md b/presets/workflow-preset/CHANGELOG.md index af3bf29208..3a6325639c 100644 --- a/presets/workflow-preset/CHANGELOG.md +++ b/presets/workflow-preset/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 3.2.0 - 2026-07-30 + +- Added one canonical `UI-*` specification model with deterministic source, + requirement, route, state, interaction, responsive, and accessibility + coverage. +- Projected UI requirements through X2-B delivery and readiness without moving + product truth out of `spec.md` or adding external-source validation. +- Required Tasks to map X2-B visual implementation and acceptance work while + preserving dependency ordering and Final Code Review as the last phase. +- Added validator and fixture coverage for missing, duplicate, orphaned, and + incomplete UI specification paths. + ## 3.1.1 - 2026-07-27 - Hardened Plan bundle validation so blocked Test Readiness rows and blocked diff --git a/presets/workflow-preset/README.md b/presets/workflow-preset/README.md index 92c19afe42..5d868182c1 100644 --- a/presets/workflow-preset/README.md +++ b/presets/workflow-preset/README.md @@ -36,7 +36,7 @@ Constitution + Architecture ### 需求层 -授权来源投影完成后,`spec.md` 是当前功能的产品需求唯一事实源(SSOT, +有界输入投影完成后,`spec.md` 是当前功能的产品需求唯一事实源(SSOT, Single Source of Truth)。功能需求、非功能需求、UX/UI、视觉、安全隐私、 数据、集成、依赖、边界、假设和排除项都使用可选载体;不适用时明确写 N/A。 @@ -45,7 +45,7 @@ N/A。 `/speckit.checklist` 只提出可回答的问题,不把实现方案写回需求。 例如:退款需求可以同时声明 `FR-001`(退款规则)、`NFR-001`(响应时间)和 -`UI-001`(加载/成功/失败状态);若已授权的设计说明缺少结账失败态证据, +`UI-001`(加载/成功/失败状态);若已供应的设计说明缺少结账失败态证据, 就在对应 `SRC-*` 行记录本地阻塞,不把它混成产品澄清问题。 ### 来源中立契约 @@ -55,20 +55,21 @@ Source Reference Contract(来源引用契约): ```text SRC ref | role | opaque locator/description | revision/identity -| authorized scope/facts | projected requirement refs | status/blocker +| bounded feature scope | supplied content/facts +| projected requirement refs | status/blocker ``` | 角色 | 含义 | 例子 | |---|---|---| -| `requirement-input` | 可投影已确认、已切片的 WHAT/WHY | 当前对话中的退款规则 | +| `requirement-input` | 可投影已供应、已切片的 WHAT/WHY | 当前对话中的退款规则 | | `visual-input` | 只可投影 `UI-*` / `VIS-*` | 结账错误态的可执行页面引用 | | `technical-evidence` | 可引用,但不升级成产品需求 | 性能测量报告 | | `context-only` | 只作背景,不授权规范性事实 | 竞品介绍 | -定位符(locator)、路径、版本、摘要或文字描述都按不透明来源信息保存。 -预设不会因为看到一个引用就打开、执行或验证它,也不会推断相邻目录或要求 -上游工具。宽泛来源必须先确定当前功能切片;无法安全切片时只记录本地阻塞 -或待澄清项,不默认整份导入。 +`/speckit.specify` 从内容或来源事实已经供应完毕的位置开始。定位符 +(locator)、路径、版本、摘要或文字描述只是不透明来源信息;如果没有随附 +内容/事实,则记录 `SRC_EVIDENCE_MISSING`,不投影本地需求。宽泛来源必须先 +确定当前功能切片;无法安全切片时只记录本地阻塞或待澄清项,不默认整份导入。 本地链路是: @@ -82,6 +83,27 @@ X2-B: ui-ux-design.md → UIF Tasks 只做实现映射;Analyze 只做本地引用审计 ``` +### UI 证据与还原契约 + +`spec.md` 内的 `UI-*` / `VIS-*` 行会记录需求种类、可观察结果、`SRC-*`、 +输入内证据定位、界面/状态/视口、推导分类、可测验收条件和阻塞状态。推导分类 +固定为 `observed`(直接观察)、`derived`(确定性推导)、`assumed`(低影响 +假设)、`unresolved`(证据不足)和 `conflicting`(证据冲突)。 + +还原需求会显式覆盖内容、信息结构、外观、交互反馈、UI 状态、响应式视口、 +无障碍和资产。像素还原使用稳定的 `PXR-*` 配置、`PXT-*` 界面 × 状态 × +视口目标和 `PEX-*` 例外;缺少基线、渲染上下文、保真模式、可测容差或例外 +策略时保持阻塞。跨平台还原还必须声明具体目标平台、适配模式、目标上下文和 +逐维度的 `preserve/adapt/add/omit/clarify/blocked` 决策。具体组件与实现 +映射仍归 X2-B,像素范围不进入 Test Conditions 或 Test Readiness。 + +Plan 在 X0 和 `ui-ux-design.md` 记录同一个本地 `spec.md` SHA-256,用于 +发现 Clarify 造成的同 ID 语义变化。X2-B 只建立引用式 `X2B-*` 交付映射: +通用 UI 映射、像素目标映射和平台适配映射。它不会复制 Spec 拥有的需求语句、 +基线、视口/状态、保真模式、验收容差、例外边界或适配决策;摘要变化或任何 +`UI/VIS/RST/PXR/PXT/PEX/ADP` 映射缺失都会阻止 +`X2B_UIUX_READY` 和 `PLAN_OUTPUT_READY`。 + ### Plan:X0–X4 X0–X4 是嵌套在原有 Core Plan 流程中的内部里程碑,不替换 Core 的 setup、 @@ -101,15 +123,19 @@ Phase 0、Phase 1 或 Constitution re-check。 (测试数据)和 assertion(断言)只有在技术适用时才生成;非 UI 功能和 无 fixture 场景可以记录明确理由。 -UI/UX 像素级交付准备可以在 Plan 中完成,但 Tasks 不得生成像素还原、 -截图对比、visual diff(视觉差异)、baseline(基线)、视觉恢复或渲染审查 -任务。 +UI/UX 像素级交付准备在 Plan 中完成。Tasks 可以把 `X2B-PX-*` 映射成间距、 +排版、颜色、资产适配、层叠、溢出和裁剪等实现任务;但不得生成截图/基线、 +pixel/perceptual comparison(像素/感知比较)、visual diff(视觉差异)、 +视觉验收或最终渲染保真度审查任务。 ### Tasks 与 Analyze `/speckit.tasks` 只消费 `PLAN_OUTPUT_READY`,按 T0–T5 映射已存在的设计对象、 路径、依赖、测试条件和 `VAL-*`。必需的 `TC-*` 会覆盖 Core 模板中“测试可选” -的默认提示;最后一个强制阶段始终是 **Final Code Review**。 +的默认提示。`X2B-UI-*`、`X2B-PX-*`、`X2B-ADP-*` 分别生成通用 UI、视觉 +实现和平台适配任务;每个 Required 映射必须落到具体路径,或保留 Plan 明确 +允许的“仅评审方法、无任务”理由。最后一个强制阶段始终是 +**Final Code Review**。 `/speckit.analyze` 一次读取 Constitution、Architecture、Spec、Plan 与 Tasks, 输出稳定 finding ID、严重级别、证据和第一个阻塞点。它负责检查 @@ -128,7 +154,7 @@ specify preset add --dev /path/to/spec-kit-workflow-preset 已发布版本: ```bash -specify preset add --from https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v3.1.1/spec-kit-workflow-preset-v3.1.1.zip +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 ``` 安装后可检查预设信息: diff --git a/presets/workflow-preset/commands/speckit.analyze.md b/presets/workflow-preset/commands/speckit.analyze.md index 3585940742..76cdb3fb6a 100644 --- a/presets/workflow-preset/commands/speckit.analyze.md +++ b/presets/workflow-preset/commands/speckit.analyze.md @@ -42,11 +42,13 @@ Audit only the local Source Reference Contract and its local projections: - every referenced `SRC-*` exists exactly once in the Spec carrier; - every source has exactly one allowed role, opaque locator/description, - explicit authorized scope/facts, projection refs or a reason for none, and a - status/blocker; + explicit bounded feature scope, supplied content/facts, projection refs or a + reason for none, and a status/blocker; +- a locator-only row has no projected refs and records + `SRC_EVIDENCE_MISSING`; - every projected requirement ref exists locally and is compatible with the role: `requirement-input` may project WHAT/WHY refs, `visual-input` only - `UI-*`/`VIS-*`, while `technical-evidence` and `context-only` authorize no + `UI-*`/`VIS-*`, while `technical-evidence` and `context-only` support no normative requirement; - a broad source without a safe feature slice remains blocked or needs clarification instead of projecting unrelated facts; @@ -58,9 +60,10 @@ Audit only the local Source Reference Contract and its local projections: Use stable codes including `SRC_REF_MISSING`, `SRC_REF_DUPLICATE`, `SRC_FIELD_INVALID`, `SRC_ROLE_INVALID`, `SRC_ROLE_PROJECTION_INVALID`, -`SRC_PROJECTED_REF_MISSING`, `SRC_FEATURE_SLICE_MISSING`, `SRC_ORPHAN`, -`SRC_STATUS_CONTRADICTORY`, `SRC_UIUX_MAPPING_MISSING`, and -`SRC_UIF_MAPPING_MISSING`. +`SRC_BOUNDED_SCOPE_MISSING`, `SRC_SUPPLIED_FACTS_INVALID`, +`SRC_EVIDENCE_MISSING`, `SRC_BLOCKER_MISSING`, `SRC_PROJECTED_REF_MISSING`, +`SRC_FEATURE_SLICE_MISSING`, `SRC_ORPHAN`, `SRC_STATUS_CONTRADICTORY`, +`SRC_UIUX_MAPPING_MISSING`, and `SRC_UIF_MAPPING_MISSING`. Do not open, run, inspect, compare, fetch, or otherwise dereference an external locator. Do not decide source authenticity, availability, revision/digest diff --git a/presets/workflow-preset/commands/speckit.plan.md b/presets/workflow-preset/commands/speckit.plan.md index 853d8d1a9a..7f6121b5ea 100644 --- a/presets/workflow-preset/commands/speckit.plan.md +++ b/presets/workflow-preset/commands/speckit.plan.md @@ -86,6 +86,15 @@ authenticity, revision, digest, freshness, publication state, or availability. Unavailable evidence remains the blocker already projected into Spec or Architecture. +At Plan start and every resume, compute the SHA-256 of the current local +`spec.md`. Record `Consumed Spec SHA-256: sha256:` in X0 and, when X2-B +is active, in `ui-ux-design.md`. This digest is local freshness evidence only; +it certifies no external source, revision, locator, or baseline. Compare the +current digest with both recorded values before preserving prior evidence. A +mismatch emits `PLAN_SPEC_INPUT_STALE`, invalidates affected X2-B, +reconciliation, X4, and `PLAN_OUTPUT_READY` evidence, and requires rebuilding +them even when the consumed stable IDs are unchanged. + Apply Change Scope Granularity: lock planned `M + U`; `plan.md` may record repository/module directory topology required by Core, but no task IDs, per-task paths, operation-level changes, or implementation order. @@ -146,7 +155,8 @@ Populate the control sections supplied by `plan-template`: - feature goal and exclusions; - repository-grounded planned `M + U`; -- Spec and applicable Architecture revision/ID refs; +- Spec path/revision, current local SHA-256, and applicable Architecture + revision/ID refs; - X2-A, X2-B, X2-C applicability (`Required`, `Not Applicable: `, or `Blocked: `); - declared independent artifact outputs and internal gates; @@ -206,10 +216,40 @@ presented as a decision. ### X2-B UI/UX Delivery When UI/UX or visual delivery applies, create `ui-ux-design.md` from its stable -template. It owns surfaces, components, composition, state, navigation/events, -viewports/responsive behavior, tokens/themes/variants, assets/fallbacks, -accessibility implementation, opaque accepted-source provenance, local delivery -method, and UI/UX Delivery Readiness. +template. It owns reference-based delivery mappings for surfaces, components, +composition, state, navigation/events, viewports/responsive behavior, +tokens/themes/variants, assets/fallbacks, accessibility implementation, target +platform delivery, opaque accepted-source provenance, local delivery method, +and UI/UX Delivery Readiness. + +First build the template's reference-only Spec UI Input Inventory. Inventory +every applicable `UI-*`, `VIS-*`, `RST-*` scope/dimension, `PXR-*`, `PXT-*`, +`PEX-*`, and `ADP-*` policy/dimension ref exactly once with its Spec status and +one `X2B-*` mapping ref or the same stable upstream blocker. Do not copy +requirement statements, baseline identities/locators, state/viewport values, +rendering contexts, fidelity modes, acceptance envelopes, exception bounds, or +adaptation decisions. + +Then produce only Plan-owned delivery mappings: + +- general UI mappings (`general-ui`) use `X2B-UI-*` IDs and bind + `UI/VIS/RST` refs and `SRC-*` refs to regions, components, + composition/state ownership, navigation, input, responsive, and + accessibility delivery plus `DEC-UI-*` and applicable UIF/interface/asset + refs; +- pixel-target mappings use `X2B-PX-*` IDs and bind one applicable `PXT-*` + and its `PXR-*`, `UI/VIS`, `PEX-*`, and `SRC-*` refs to the exact target + region, component/style/token/asset/layering/overflow delivery, and a local + X2-B delivery/review method; +- platform-adaptation mappings use `X2B-ADP-*` IDs and bind one `ADP-*` + policy/dimension ref plus `UI/VIS` and `SRC-*` refs to target contexts and + target component, navigation, input, layout, and accessibility delivery + design. + +The mapping implements referenced observable requirements and adaptation +decisions; it never repairs, weakens, reclassifies, or duplicates them. A +product decision returns to Clarify and a later Plan run rebuilds affected +mappings from the current Spec. `contracts/uif/*.expected.json` is a UI/UX interaction contract: start view, events, routes, observable states/feedback, API call refs, and transitions. It @@ -221,13 +261,23 @@ does not own pixel comparison, styling, or API payload schemas. Do not produce `behavior/uif.intent.json` as a mandatory parent or second SSOT. Formal UIF derives from accepted Spec refs plus `ui-ux-design.md`. -`X2B_UIUX_READY` requires each applicable `SRC-* + UI/VIS-*` pair to map to -surface/component/state, viewport/responsive/accessibility, asset/variant/ -fallback, accepted-source/delivery-method, and UIF records or a stable local -blocker. X2-B preserves opaque provenance but MUST NOT open, run, inspect, -compare, or certify an external source or its fidelity/state as part of the -local gate. Local pixel delivery ownership stays in UI/UX, never Test -Conditions. +`X2B_UIUX_READY` requires a current Spec digest; one inventory row per +applicable Spec UI contract ref; exactly one required `X2B-*` mapping or the +same upstream blocker per ref; resolved Spec, `SRC-*`, `DEC-UI-*`, UIF, +interface, asset, and exception refs; every applicable `PXT-*` and `ADP-*` +dimension closed; one UI/UX Delivery Readiness row per required mapping; zero +Spec-owned duplicated fields; and no unresolved X2-B/reconciliation finding. +Blocked Spec applicability remains blocked and is never relabeled N/A. X2-B +preserves opaque provenance but MUST NOT open, run, inspect, compare, or +certify an external source or its fidelity/state as part of the local gate. +Local pixel delivery ownership stays in UI/UX, never Test Conditions. + +Structure-first X2-B validation uses stable failure codes: +`PLAN_SPEC_INPUT_STALE`, `X2B_SPEC_REF_UNMAPPED`, +`X2B_SPEC_REF_DUPLICATE`, `X2B_SPEC_REF_UNKNOWN`, +`X2B_PIXEL_TARGET_UNMAPPED`, `X2B_PIXEL_EXCEPTION_UNRESOLVED`, +`X2B_ADAPTATION_UNMAPPED`, `X2B_BLOCKER_SUPPRESSED`, +`X2B_SPEC_OWNERSHIP_LEAK`, and `X2B_DELIVERY_DECISION_INCOMPLETE`. ### X2-C Test & Acceptance @@ -265,7 +315,7 @@ judgment. Inventory each declared `DEC-*`, design/interface/sequence ID, UIF, name; report missing, duplicate, renamed, and stale refs; 2. every material `DEC-*` reaches each affected active-lane output; 3. every UIF API/design ref resolves in X2-A and every applicable UI/VIS source - pair resolves in X2-B; + pair plus every `RST/PXR/PXT/PEX/ADP` inventory ref resolves in X2-B; 4. every `TC-*` resolves its requirement, design/UIF/interface refs and every technique-triggered child; 5. every condition needing execution declares the expected `VAL-*` mapping or @@ -303,14 +353,17 @@ map to a complete `VAL-*` or stable runtime blocker. Treat the `plan.md` Internal Gate Summary and its evidence as the resume index: -1. validate the recorded evidence rather than trusting the status text; -2. preserve an already-verified artifact when its bounded inputs and stable refs +1. compute the current local Spec SHA-256 and compare it with recorded X0 and + UI/UX values; a mismatch starts at affected X2-B with + `PLAN_SPEC_INPUT_STALE`; +2. validate the recorded evidence rather than trusting the status text; +3. preserve an already-verified artifact when its bounded inputs, Spec digest, and stable refs are unchanged; -3. resume at the first Gate whose evidence is absent, invalid, `BLOCKED`, or +4. resume at the first Gate whose evidence is absent, invalid, `BLOCKED`, or affected by changed input; -4. re-run that Gate and every downstream reconciliation/Gate that consumes the +5. re-run that Gate and every downstream reconciliation/Gate that consumes the changed artifact or ref; -5. never unconditionally overwrite a verified upstream artifact, and never +6. never unconditionally overwrite a verified upstream artifact, and never preserve a downstream `READY` status after an input/ref it depends on changed. No `PENDING` state is introduced. A Gate without closed evidence is simply not @@ -336,7 +389,9 @@ Derive `PLAN_OUTPUT_READY`; never set it independently. It is `READY` if and only if X0 + X1 + `X2_RECONCILIATION_READY` + every applicable X2 Gate + applicable X3 + complete Design/UI/UX/Test readiness are evidenced, every conditional artifact is Required/READY or has a valid N/A reason, all -Plan-internal refs resolve, all blockers are lane-owned, and no placeholder is +Plan-internal refs resolve, the current Spec digest matches recorded X0 and +UI/UX evidence, all applicable Spec UI refs have closed X2-B mappings or +propagated blockers, all blockers are lane-owned, and no placeholder is presented as a decision. Otherwise it is `BLOCKED` with the failed Gate and blocker evidence. It validates Plan outputs only. diff --git a/presets/workflow-preset/commands/speckit.specify.md b/presets/workflow-preset/commands/speckit.specify.md index 7421a8e993..bc6d290fda 100644 --- a/presets/workflow-preset/commands/speckit.specify.md +++ b/presets/workflow-preset/commands/speckit.specify.md @@ -39,43 +39,43 @@ and `spec.md`. It MUST NOT create, read, evaluate, or modify `checklists/requirements.md` or any other checklist, Plan, Tasks, Architecture, contract, test-design, or implementation artifact. -## Authorized Source Input Contract +## Bounded Supplied Input Contract -Treat natural-language direction and every user-provided or explicitly -authorized external reference through the same local Source Reference Contract: +This command starts when bounded content or source-backed facts have already +been supplied. Treat the current feature direction and each supplied evidence +packet through one local Source Reference Contract: ```text SRC ref | role | opaque locator/description | revision/identity -| authorized scope/facts | projected requirement refs | status/blocker +| bounded feature scope | supplied content/facts +| projected requirement refs | status/blocker ``` -1. Read only the current conversation and sources the user explicitly - authorizes. A locator, directory, repository, provider, or neighboring file - does not expand that scope. +1. Use only content and facts present in the bounded supplied input. An opaque + locator without supplied content or source-backed facts is provenance only. + Record `SRC_EVIDENCE_MISSING` and do not project a requirement from it. 2. Record every used source as one unique `SRC-*` row with exactly one role: `requirement-input`, `visual-input`, `technical-evidence`, or `context-only`. 3. Establish the current feature slice before projecting a broad source. - Project only facts inside the explicit slice. When no safe slice exists, - record `[NEEDS CLARIFICATION: ...]` or a stable local source blocker and do - not import the complete source. -4. Keep confirmed facts, assumptions, clarification needs, unavailable - evidence, and informative context distinguishable. -5. `requirement-input` may authorize applicable WHAT/WHY requirement carriers. - `visual-input` may authorize only `UI-*` and `VIS-*`. + Project only facts inside that explicit slice. When no safe slice exists, + record `[NEEDS CLARIFICATION: ...]` or `SRC_FEATURE_SLICE_MISSING` and do not + import the complete source. +4. Keep observed, derived, assumed, unresolved, and conflicting statements + distinguishable. An unresolved or conflicting statement carries a stable + blocker and is not presented as a requirement ready for downstream use. +5. `requirement-input` may support applicable WHAT/WHY requirement carriers. + `visual-input` may support only `UI-*` and `VIS-*`. `technical-evidence` may be cited as evidence but does not become a product - requirement. `context-only` authorizes no normative requirement. + requirement. `context-only` supports no normative requirement. Opaque identity is optional provenance. Preserve a supplied URI, path, -revision, digest, conversation reference, or description, but do not interpret -or validate its external meaning. The presence of a reference MUST NOT cause -this command to invoke a provider tool, dereference or execute a locator, -inspect adjacent source scope, validate authenticity/freshness/publication -state, or create an import manifest, handoff package, adapter, provider-specific -schema, or external synchronization record. Intake is not an SDD stage. +revision, digest, conversation reference, or description as written. The +source row's supplied content/facts and evidence locators are the only basis +for local projection. ## Full-Spectrum Projection -Project confirmed facts from the Authorized Source Input Contract into the +Project supported facts from the Bounded Supplied Input Contract into the resolved template. Keep the result stakeholder-readable, technology-agnostic, and focused on WHAT users need and WHY. @@ -85,8 +85,9 @@ Populate applicable carriers for: - functional requirements (`FR-*`); - non-functional outcomes (`NFR-*`); - UX journeys and interaction expectations (`UX-*`); -- UI surfaces, states, feedback, and responsive behavior (`UI-*`); -- visual requirements and confirmed source refs (`VIS-*`); +- UI surfaces, states, feedback, responsive behavior, and target-platform + outcomes (`UI-*`); +- observable visual and restoration-equivalence requirements (`VIS-*`); - security/privacy, data/integration, dependencies, boundaries; - assumptions, exclusions, measurable success criteria; - source references, unresolved product decisions, source-evidence blockers, and @@ -96,16 +97,37 @@ 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. -Make informed, documented assumptions for low-impact gaps. Use at most three -`[NEEDS CLARIFICATION: ...]` markers for high-impact product decisions with no -safe default. Missing external evidence is a stable source blocker, not a -product decision. +Apply the UI Evidence Projection Rules and the stable UI Specification +structures in the resolved template whenever supplied HTML, CSS, rendered +state, interaction, asset, responsive, accessibility, or restoration evidence +applies. Every applicable `UI-*`/`VIS-*` row records its kind, observable +statement, `SRC-*` refs, evidence locator, surface, state, viewport, derivation +classification, measurable acceptance condition, and status/blocker. + +For UI restoration, classify every applicable equivalence dimension and every +required `surface x state x viewport` target. A pixel-restoration request is +actionable only when its profile and complete target matrix identify baseline +evidence, rendering context, fidelity mode, measurable acceptance envelope, +and stable accepted-exception policy. Otherwise retain the request with +`PIXEL_PROFILE_INCOMPLETE` or a more specific stable blocker. + +For cross-platform restoration, record the source platform, concrete target +platform, one allowed adaptation mode, target contexts, and one allowed +per-dimension decision. Apply the precedence declared by the UI Specification +contract. `Swift` alone is not a target platform. Mixed policies are expressed +per dimension, never as a free-form mode. + +Make informed, documented `assumed` classifications only for low-impact gaps. +Use at most three `[NEEDS CLARIFICATION: ...]` markers for high-impact product +decisions with no safe default. Missing evidence is a stable source blocker, +not a product decision. Visual, HTML, structured IR, executable, document, and technical-evidence -inputs use the same `SRC-*` row shape. Preserve only their authorized opaque -provenance and projected local refs. Do not execute or certify them, or invent -DOM/CSS structure, framework components, code props, local asset paths, hashes, -or implementation strategies. +inputs use the same `SRC-*` row shape. Preserve their opaque provenance, +bounded supplied facts, evidence locators, and projected local refs. Do not +invent unobserved DOM/CSS structures, UI states, responsive behavior, assets, +product intent, framework components, code properties, local asset paths, +hashes, capture/comparison procedures, or implementation strategies. After projection, `spec.md` is the feature-local WHAT/WHY SSOT. Downstream commands consume its local requirements and blockers, not the external source @@ -118,8 +140,14 @@ Before finishing, check only the artifact this command owns: - the resolved template headings remain structurally valid; - the feature description was projected into user scenarios, applicable requirement carriers, and measurable outcomes; -- every used source has one allowed role, an explicit authorized scope, local - projection refs or a reason for none, and a local status/blocker; +- every used source has one allowed role, an explicit bounded feature scope, + supplied content/facts or `SRC_EVIDENCE_MISSING`, local projection refs or a + reason for none, and a local status/blocker; +- every projected `UI-*`/`VIS-*` row resolves its source and evidence locators, + uses one derivation classification, and contains an observable acceptance + condition or stable blocker; +- 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; - no implementation design or foreign-stage artifact was written. diff --git a/presets/workflow-preset/commands/speckit.tasks.md b/presets/workflow-preset/commands/speckit.tasks.md index e286a95373..c92e1c2c17 100644 --- a/presets/workflow-preset/commands/speckit.tasks.md +++ b/presets/workflow-preset/commands/speckit.tasks.md @@ -45,9 +45,15 @@ PLAN_OUTPUT_READY Verify only immediate handoff completeness and Tasks' own output contract: - `PLAN_OUTPUT_READY: READY`; -- current Plan/artifact revisions; +- current Plan/artifact revisions, including the revision named by the Tasks + handoff; - Design, UI/UX, and Test readiness independently present or explicitly N/A; -- every Required mapping resolves to declared Plan refs; +- the complete X2-B mapping inventory has only `Required`, `N/A`, or `Blocked` + rows and uses current `X2B-UI-*`, `X2B-PX-*`, and `X2B-ADP-*` IDs; +- every Required mapping resolves to declared `UI-*`, `VIS-*`, `PXT-*`, + `PEX-*`, or `ADP-*` Plan refs; +- every Required mapping is classified as an implementation mapping or as a + review-method-only mapping with an explicit no-task rationale; - stable Plan blockers and runtime prerequisites remain distinguishable. Missing information produces `PLAN_OUTPUT_INCOMPLETE`; stop before writing @@ -55,6 +61,10 @@ complete-looking tasks. Do not recover by treating Spec/Checklist as direct strategy inputs, reconstructing missing Plan decisions, or performing Analyze-owned cross-command conformance. +A `Blocked` mapping keeps its blocker visible and produces no normal task. +Stale mappings, missing UI/UX Delivery Readiness rows, or suppressed blockers +also make the handoff incomplete. + ## T1 — Concrete Path Binding Bind each populated Plan record to the smallest relevant set of concrete source, @@ -73,6 +83,27 @@ TC-PAYMENT-DECLINED -> tests/contract/checkout/test_payment_declined.py Changing object ownership, test level, or sandbox to mock is re-planning and is forbidden. +For active X2-B delivery, route every Required implementation mapping without +duplicate path/dimension coverage: + +| Mapping class | Concrete task responsibility | +|---|---| +| `X2B-UI-*` | component, state, interaction, navigation, responsive, accessibility | +| `X2B-PX-*` | geometry, sizing, spacing, alignment, flow, overflow, clipping, typography, text metrics, color, gradient, border, radius, shadow, opacity, effects, asset preparation/binding/crop/aspect/fitting, layering/stacking/fixed-sticky/occlusion | +| `X2B-ADP-*` | target component, navigation, presentation, input modality, gestures, system UI, safe regions, adaptive layout, accessibility scaling, localization, layout direction | + +Each emitted task names its mapping ID, covered implementation dimensions, +declared traceability refs, and exact file/configuration/asset paths. A +review-method-only row emits no task and retains its Plan-authored rationale. + +Example: + +```text +X2B-PX-004 asset-preparation -> T041 prepare assets/refund-panel.svg +X2B-PX-004 asset-binding -> T042 bind it in src/ui/RefundPanel.tsx +T042 depends on T041 +``` + ## T2 — Dependency Graph Build dependencies from Plan refs, shared paths, fixtures, contracts, and @@ -80,6 +111,15 @@ execution boundaries. X2-A/X2-B/X2-C are parallel; never impose a fixed `data -> UI -> test` lane order. Add `[P]` only when tasks touch independent paths and have no unresolved dependency. +For X2-B, preserve mapping-to-mapping dependencies and derive task edges from: + +- shared source/configuration/asset paths; +- `X2B-*` mapping dependencies and declared `DEC-UI-*` decision refs; +- asset preparation before asset binding; +- component availability before state/interaction binding; +- target-component availability before platform-specific behavior; +- accessibility or responsive prerequisites recorded by the Plan mapping. + Do not emit normal tasks for N/A, intentionally minimal, placeholder, or blocked records. @@ -96,8 +136,9 @@ populated Plan products: | data model | domain/persistence/migration/invariant work | | interface contracts | contract tests and interface implementation | | sequences | orchestration/retry/rollback/compensation dependencies | -| UI/UX design | component/state/responsive/interaction/accessibility implementation | -| asset mapping | asset preparation, variants, binding, fallback | +| `X2B-UI-*` | component/state/interaction/navigation/responsive/accessibility implementation | +| `X2B-PX-*` | geometry/typography/appearance/asset/layering/overflow/clipping implementation | +| `X2B-ADP-*` | platform component/navigation/input/system/safe-region/adaptive/accessibility/localization implementation | | Test Readiness | fixture, test-first, validation, evidence work | | `VAL-*` paths | runnable integration/e2e/evidence work | @@ -126,20 +167,35 @@ UI/UX Delivery Readiness is implementation readiness only. It may generate: - view/component and state implementation; - loading/empty/error/success/permission/disabled/focus behavior; - functional responsive, navigation, interaction, and accessibility work; -- asset preparation, variants, binding, authorization refs, and fallback. - -Local `SRC-* + UI/VIS-*` mappings may guide component, state, responsive, -accessibility, asset, variant, and fallback implementation. The external -locator remains opaque and does not create acceptance/verification work. +- geometry, sizing, spacing, alignment, flow, overflow, and clipping; +- typography and text-metric implementation; +- color, gradient, border, radius, shadow, opacity, and effects; +- asset preparation, variants, binding, crop, aspect, fitting, authorization + refs, and fallback; +- layering, stacking, fixed/sticky positioning, and occlusion; +- target-platform component, navigation, presentation, input, gesture, system + UI, safe-region, adaptive-layout, accessibility-scaling, localization, and + layout-direction implementation. + +Local `UI-*`, `VIS-*`, `PXT-*`, `PEX-*`, and `ADP-*` refs guide only the +implementation dimensions already mapped in X2-B. `PXT-*` and `PEX-*` are +traceability inputs, not authority to copy the Spec-owned baseline locator, +fidelity mode, acceptance envelope, exception bound, or adaptation decision. +The external locator remains opaque and does not create +acceptance/verification work. ### Forbidden task scope Never generate: - `visual_acceptance` or `pixel_fidelity_review`; -- screenshot comparison, visual diff, or baseline capture; -- visual restoration or final visual review; -- pixel-level layout/style assertions; +- screenshot capture, screenshot comparison, screenshot diff, visual diff, or + baseline production or baseline capture; +- pixel comparison, perceptual comparison, threshold evaluation, visual + acceptance, or acceptance-envelope/fidelity-mode evaluation; +- visual restoration, rendered-fidelity judgment, final visual review, or + final rendered visual review; +- pixel-level layout/style assertions or perceptual assertions; - screenshot-based evidence requirements; - source dereference, execution, authenticity/freshness/revision/publication checks, or external-source validation; @@ -148,9 +204,32 @@ Never generate: - an automatic UI acceptance phase; - a Final Code Review scope that judges rendered fidelity. -Pixel delivery/review may remain a Plan-owned UI/UX design record, but Tasks -does not execute it. This #37 boundary overrides older Plan language that could -be read as authorizing pixel tasks. +Tasks MAY implement pixel-target dimensions such as spacing, typography, +effects, asset fitting, layering, overflow, and clipping. It MUST NOT execute +the visual method used to judge those dimensions. For example, “set the mapped +8 px spacing in `RefundPanel.tsx`” is implementation; “capture a screenshot and +compare it to the baseline” is forbidden visual execution. + +Functional UI tests remain derived only from Required `TC-*` rows in Test +Readiness. An `X2B-PX-*` or `X2B-ADP-*` mapping never invents a test condition. + +## Stable Tasks Contract Errors + +The pure in-memory Tasks contract validator uses these stable error codes: + +- `TASK_X2B_MAPPING_UNMAPPED`; +- `TASK_X2B_MAPPING_DUPLICATE`; +- `TASK_X2B_REF_UNKNOWN`; +- `TASK_X2B_BLOCKER_SUPPRESSED`; +- `TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED`; +- `TASK_X2B_ADAPTATION_UNCOVERED`; +- `TASK_SPEC_OWNERSHIP_LEAK`; +- `TASK_VISUAL_EXECUTION_LEAK`; +- `TASK_FINAL_REVIEW_MAPPING_MISSING`. + +The validator supports tests and static conformance only. It is not a runtime, +execution manifest, transfer protocol, worker result format, dispatcher, or +replacement for Core `/speckit.implement`. ## Task-Derivation Delegation @@ -182,14 +261,19 @@ Applicable review scopes and sources: | behavior/test contract | Test Readiness and optional technique contracts | | data side effect | data model, sequences, invariants, oracles | | sequence consistency | sequence contracts | -| UI component/state contract | UI/UX design and UIF | -| responsive/accessibility behavior | UI/UX design + Required TC | -| asset binding | asset/variant/fallback records | +| X2-B component/state/interaction | `X2B-UI-*`, UI/UX design, and UIF | +| X2-B geometry/typography/appearance | `X2B-PX-*` implementation mappings | +| X2-B asset/layering/overflow/clipping | `X2B-PX-*` implementation mappings | +| X2-B platform navigation/input/system/safe-region/adaptive/accessibility/localization | `X2B-ADP-*` implementation mappings | | evidence completeness | Test Readiness and `VAL-*` paths | Each review task names concrete source artifacts, implementation surfaces, and -functional evidence. UI review is code/design-contract review only; it MUST NOT -judge rendered visual fidelity or require screenshot/pixel evidence. +functional evidence. It covers every Required X2-B implementation mapping and +checks mapping-to-code conformance, unresolved blockers, and Plan revision +drift. Review-method-only rows remain no-task rationales. UI review is +code/design-contract review only; it MUST NOT +judge rendered visual fidelity, evaluate an acceptance envelope, or require +screenshot/pixel evidence. Review may authorize bounded implementation repair and affected-test reruns. If repair requires changing Spec, Architecture, Plan, research, contracts, diff --git a/presets/workflow-preset/docs/extension-governance.md b/presets/workflow-preset/docs/extension-governance.md index 3415c085c2..8ab9467d94 100644 --- a/presets/workflow-preset/docs/extension-governance.md +++ b/presets/workflow-preset/docs/extension-governance.md @@ -140,31 +140,35 @@ Examples: ## Source Reference Contract -Authorized external material is an input to existing commands, not an SDD stage -or runtime dependency. `spec.md` carries one canonical, source-neutral shape: +Bounded supplied material is an input to existing commands, not an SDD stage or +runtime dependency. `/speckit.specify` begins only after applicable content or +source-backed facts have been supplied. It owns no source-access, +authentication, provider, locator-resolution, adapter, or synchronization +responsibility. `spec.md` carries one canonical, source-neutral shape: ```text SRC ref | role | opaque locator/description | revision/identity -| authorized scope/facts | projected requirement refs | status/blocker +| bounded feature scope | supplied content/facts +| projected requirement refs | status/blocker ``` The allowed roles are exactly: | Role | Local authority | |---|---| -| `requirement-input` | confirmed, feature-scoped WHAT/WHY facts | -| `visual-input` | feature-scoped `UI-*` and `VIS-*` facts | +| `requirement-input` | supplied, feature-scoped WHAT/WHY facts | +| `visual-input` | supplied, feature-scoped `UI-*` and `VIS-*` evidence/facts | | `technical-evidence` | citable evidence that does not become a product requirement | | `context-only` | informative context with no normative projection authority | Every used source has one role and a feature slice. A broad source without a safe slice remains blocked or needs clarification instead of being imported in -full. Supplied URI/path/revision/digest/description values are opaque -provenance; the preset does not infer adjacent scope or validate their external -meaning, authenticity, freshness, publication state, availability, or -fidelity. +full. A URI/path/revision/digest/description is opaque provenance; without +supplied content/facts it records `SRC_EVIDENCE_MISSING` and projects no local +requirement. The preset does not infer adjacent scope or validate external +meaning, authenticity, freshness, publication state, availability, or fidelity. -After authorized projection, `spec.md` is the feature-local WHAT/WHY SSOT. +After source-backed projection, `spec.md` is the feature-local WHAT/WHY SSOT. Clarify may make a user-accepted local decision current while retaining the originating `SRC-*` and clarification history; no external write-back or synchronization is required. @@ -179,7 +183,10 @@ facts. `SRC-*` locators are provenance, not read or execution targets. Applicable visual projection follows: ```text -SRC-* + UI/VIS-* -> ui-ux-design.md -> UIF source_refs + requirement_refs +SRC-* + UI/VIS/RST/PXR/PXT/PEX/ADP refs + -> ui-ux-design.md Spec UI Input Inventory + -> X2B-* delivery mappings + UIF source_refs/requirement_refs + -> UI/UX Delivery Readiness ``` Tasks uses that local mapping for implementation guidance only. Analyze checks @@ -187,6 +194,91 @@ local source existence, uniqueness, role compatibility, projection targets, orphans, contradictions, and X2-B/UIF mappings. Neither command acquires, dereferences, executes, compares, or certifies an external source. +## Feature-Local UI Specification Contract + +`spec.md` remains the only product-requirement SSOT. Its stable UI +Specification structure makes every applicable `UI-*`/`VIS-*` row identify: + +- stable identity and kind; +- observable statement; +- `SRC-*` refs and locators inside the bounded supplied input; +- surface/region, state/preconditions, and viewport/target context; +- one of `observed`, `derived`, `assumed`, `unresolved`, or `conflicting`; +- measurable acceptance condition; +- `specified` or a stable blocker. + +HTML, CSS, rendered-state, interaction, asset, responsive, and accessibility +evidence projects only outcomes directly supported by cited observations or +deterministically derived from them. For example, one desktop rendering may +support the cited desktop geometry but cannot support an unevidenced mobile +layout. An `assumed` low-impact default is labeled as such; `unresolved` and +`conflicting` rows remain blocked. + +Restoration additionally classifies content, information structure, visual +appearance, interaction/feedback, UI states, responsive viewports, +accessibility, and asset identity/substitution. Pixel restoration uses stable +`PXR-*` profiles, `PXT-*` surface × state × viewport targets, and `PEX-*` +accepted exceptions. Each unblocked target identifies one baseline, +rendering-context constraints, one of `pixel-exact`, `pixel-tolerant`, +`perceptual-equivalent`, or `structural-only`, and a measurable acceptance +envelope. Missing/conflicting inputs remain blocked; Specify owns no baseline +production, comparison execution, implementation method, or review result. + +Cross-platform restoration records one policy per applicable scope: + +```text +UI ref | source platform | concrete target platform | adaptation mode +| preserve/adapt/add/omit decisions | target contexts | SRC refs +| status/blocker +``` + +Allowed modes are `framework-equivalent`, `native-adaptive`, +`brand-preserving-native`, and `visual-equivalent-native`; `hybrid` is invalid. +Each applicable dimension resolves to `preserve`, `adapt`, `add`, `omit`, +`clarify`, or `blocked`. `Swift` is an implementation language rather than a +concrete target platform. + +Conflicts use this precedence: + +```text +target-platform hard constraints and accessibility requirements + > explicit product requirements + > declared adaptation policy and per-dimension decisions + > source-backed observable UI evidence + > target-platform defaults + > implementation preference +``` + +Specify owns target outcomes. X2-B owns concrete components, unit conversions, +navigation/presentation mechanisms, safe-region implementation, accessibility +mapping, adaptive-layout strategy, and delivery/review methods. Pixel scope +must not enter Test Conditions or Test Readiness. + +### Plan-Internal UI Contract Consumption + +Plan consumes the UI Specification through reference-only, rebuildable X2-B +delivery mappings. X0 and `ui-ux-design.md` record the same current local +`spec.md` SHA-256. The digest detects local same-ID semantic changes; it does +not certify any external source, revision, locator, or baseline. A mismatch +invalidates affected X2-B, reconciliation, X4, and `PLAN_OUTPUT_READY` +evidence. + +`ui-ux-design.md` inventories every applicable `UI/VIS/RST/PXR/PXT/PEX/ADP` +ref exactly once, then maps it through stable Plan-owned `X2B-*` records: + +- general UI delivery maps UI/visual/restoration refs to regions, components, + state, navigation, input, responsive, and accessibility design; +- pixel-target delivery maps profile/target/exception refs to target-region, + style/token/asset/layering/overflow responsibilities and a local X2-B method; +- platform-adaptation delivery maps one policy/dimension ref to target-context + component, navigation, input, layout, and accessibility design. + +The inventory and mappings reference but never copy Spec-owned statements, +baseline identity/locator, state/viewport, rendering context, fidelity mode, +acceptance envelope, exception bound, or adaptation decision. Every required +mapping has one UI/UX Delivery Readiness row. A Spec blocker propagates with +the same ID and cannot become `READY` or `N/A`. + ## Final Code Review Gate `/speckit.tasks` MUST append Final Code Review as the last mandatory phase of @@ -208,10 +300,12 @@ Completion requires the review tasks themselves to pass. No separate worker result file or orchestration layer is required. UI review is code/design-contract review. Tasks and Final Code Review never -create or evaluate visual acceptance, pixel fidelity, screenshot comparison, -visual diff, baseline capture, visual restoration, or final rendered-visual -review. Visual/IR/source refs may guide implementation but do not create a -validation task. +create or evaluate visual acceptance, screenshot/baseline production, +pixel/perceptual comparison, visual diff, acceptance-envelope thresholds, or +final rendered-visual review. `X2B-PX-*` may still produce geometry, +typography, appearance, asset, layering, overflow, and clipping implementation +work. Visual/IR/source refs remain traceability and do not create a validation +task. ## Tasks As A Pure Plan Mapper @@ -231,11 +325,36 @@ Tasks owns exact paths, task IDs, dependency order, `[P]`, and checklist shape. It preserves Plan-selected design/test/UI decisions. Missing mappings produce `PLAN_OUTPUT_INCOMPLETE`; Tasks does not reconstruct them. +A current, closed X2-B handoff routes mappings as follows: + +| Mapping | Tasks responsibility | +|---|---| +| `X2B-UI-*` | component, state, interaction, navigation, responsive, accessibility | +| `X2B-PX-*` | geometry, typography, color/effects, assets, layering, overflow, clipping | +| `X2B-ADP-*` | target-platform system, navigation, input, layout, accessibility, localization | + +Every Required implementation mapping names concrete paths. A +review-method-only mapping may omit tasks only with its explicit Plan rationale. +Blocked mappings emit no normal tasks and keep `PLAN_OUTPUT_INCOMPLETE`. +Dependencies come from mapping/`DEC-UI-*` refs, shared paths, interface/UIF +boundaries, asset preparation/binding, and accessibility/adaptive constraints; +independent lanes retain parallel eligibility. + A Required `TC-*` overrides Core's generic optional-test wording. UI, accessibility, responsive, and journey tests exist only when Test Readiness requires them. UI/UX Delivery Readiness otherwise maps only to component, state, -interaction, accessibility, responsive, asset, variant, and fallback -implementation work. +interaction, accessibility, responsive, geometry/style, asset/layering, and +platform-adaptation implementation work. + +`validators/speckit_tasks_contract.py` is a pure in-memory test helper. Its +stable surfaces include `TASK_X2B_MAPPING_UNMAPPED`, +`TASK_X2B_MAPPING_DUPLICATE`, `TASK_X2B_REF_UNKNOWN`, +`TASK_X2B_BLOCKER_SUPPRESSED`, +`TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED`, +`TASK_X2B_ADAPTATION_UNCOVERED`, `TASK_SPEC_OWNERSHIP_LEAK`, +`TASK_VISUAL_EXECUTION_LEAK`, and `TASK_FINAL_REVIEW_MAPPING_MISSING`. It is +not an execution runtime, manifest, transfer protocol, worker format, or +Implement override. ## Structured Artifact Rules @@ -246,6 +365,16 @@ behavior JSON artifacts require schemas in `schemas/` and focused coverage in Every packaged schema and validator must be covered by `tests/test_preset_contract.py`. +The UI Specification validator's in-memory bundle keeps +`all_spec_requirement_refs` separate from its `UI-*`/`VIS-*` requirement +records. This lets one canonical `SRC-*` row project both general product refs +and UI refs without moving `FR-*`/`NFR-*` content into the UI contract. + +The Tasks validator's in-memory candidates use transient `action_classes` to +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. + ## 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 a623af08d4..0ec44feebd 100644 --- a/presets/workflow-preset/preset.yml +++ b/presets/workflow-preset/preset.yml @@ -2,7 +2,7 @@ schema_version: '1.0' preset: id: workflow-preset name: Workflow Preset - version: 3.1.1 + version: 3.2.0 description: Separated SDD governance, source-neutral specification, X0-X4 design, test-first contracts, and execution-ready task mapping author: bigsmartben diff --git a/presets/workflow-preset/templates/plan-template.md b/presets/workflow-preset/templates/plan-template.md index 5fd364062d..c598ae111b 100644 --- a/presets/workflow-preset/templates/plan-template.md +++ b/presets/workflow-preset/templates/plan-template.md @@ -12,18 +12,26 @@ ### Upstream References - **Spec**: [path + revision] +- **Consumed Spec SHA-256**: [sha256:<64 lowercase hexadecimal characters>] - **Spec Source Contract**: [Applicable local SRC refs and blockers; locators remain opaque.] - **Architecture Revision**: [revision] - **Applicable Architecture IDs**: [BND/CON/DEC/CST/GAP refs] +The Spec digest is local freshness evidence. It does not certify an external +source, revision, locator, or baseline. The same digest is recorded in +`ui-ux-design.md` when X2-B is active. + ### Active Lane Matrix | Lane | Applicability | Source refs | Declared outputs | Dependencies | Internal gate | |---|---|---|---|---|---| | X2-A Domain/Object/Interface | Required / N/A: reason / Blocked: ID | [refs] | [paths] | [lane refs] | X2A_DESIGN_READY | -| X2-B UI/UX Delivery | Required / N/A: reason / Blocked: ID | [SRC + UI/VIS refs] | [paths] | [lane refs] | X2B_UIUX_READY | +| X2-B UI/UX Delivery | Required / N/A: reason / Blocked: ID | [SRC + UI/VIS/RST/PXR/PXT/PEX/ADP refs] | [paths] | [lane refs] | X2B_UIUX_READY | | X2-C Test & Acceptance | Required / N/A: reason / Blocked: ID | [refs] | [paths] | [lane refs] | X2C_TEST_DESIGN_READY | +An N/A X2-B decision records both the scoped `spec.md` section/ref and the +concrete reason that no applicable UI contract ref exists. + ### Cross-Lane Dependency Register | ID | Producer | Consumer | Required contract/decision | Status/blocker | @@ -48,15 +56,21 @@ evidence. A missing or invalid evidence cell means the Gate is not ready. | Ref / mapping | Producer / owner | Consumers | Resolved target | Drift / ownership finding | Status / blocker | |---|---|---|---|---|---| -| [DEC/UIF/TC/VAL/design ref] | [lane + artifact] | [lane artifacts] | [stable ID/path] | [none or finding] | [READY/BLOCKED: ID] | +| [DEC/X2B/UIF/TC/VAL/design ref] | [lane + artifact] | [lane artifacts] | [stable ID/path] | [none or finding] | [READY/BLOCKED: ID] | ### Resume Checkpoint +- **Current local Spec SHA-256**: [sha256:] +- **Recorded X0 / UI-UX Spec SHA-256**: [digests or N/A for inactive X2-B] - **First unclosed/affected Gate**: [Gate ID] - **Verified artifacts preserved**: [paths + evidence refs] - **Changed inputs/refs**: [refs or none] - **Downstream Gates/reconciliation to rerun**: [Gate IDs] +If the current digest differs from either recorded digest, treat the Spec input +as changed even when stable IDs are unchanged. Invalidate affected X2-B, +reconciliation, X4, and `PLAN_OUTPUT_READY` evidence before rebuilding. + ## Artifact Navigation - Shared decisions: `./research.md` diff --git a/presets/workflow-preset/templates/requirements/visual-gate.md b/presets/workflow-preset/templates/requirements/visual-gate.md index 322623f7e1..efa093c667 100644 --- a/presets/workflow-preset/templates/requirements/visual-gate.md +++ b/presets/workflow-preset/templates/requirements/visual-gate.md @@ -2,9 +2,16 @@ - [ ] 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 Are visual adjectives grounded in applicable `SRC-*` plus `UI/VIS-*` refs or observable criteria? [Measurability] -- [ ] CHK-VIS-002 Does each visual source have the `visual-input` role, an authorized feature slice, and explicit local projection refs? [Consistency] -- [ ] CHK-UI-002 Are viewport, long-copy, safe-area, asset-variant, and fallback expectations stated when applicable? [Coverage] +- [ ] 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] Use citations or `[Gap]`. Do not dereference or validate external sources, acquire evidence, answer these questions, or modify `spec.md`. diff --git a/presets/workflow-preset/templates/spec-template.md b/presets/workflow-preset/templates/spec-template.md index a622eeecb8..1c11a04749 100644 --- a/presets/workflow-preset/templates/spec-template.md +++ b/presets/workflow-preset/templates/spec-template.md @@ -18,13 +18,158 @@ non-applicability is confirmed. - **UX-001**: [Actor goal, journey, feedback, recovery, and accessibility expectation.] -### UI Surfaces and States +### UI Specification Contract + +This is the feature-local UI requirement source of truth inside `spec.md`. +Rows specify observable outcomes, not delivery components, capture/comparison +procedures, or implementation choices. + +| Requirement ref | Kind | Observable statement | SRC refs | Evidence locator(s) within supplied input | Evidence support | Surface / region | State / preconditions | Viewport / target context | Derivation | Measurable acceptance condition | Status / blocker | +|---|---|---|---|---|---|---|---|---|---|---|---| +| UI-001 | interaction | [Observable surface, state, feedback, responsive, or accessibility outcome.] | [SRC-001] | [Supplied HTML fragment, rendered-state frame, fact ID, or `None` only when blocked.] | [Supported dimensions such as surface, state, viewport, interaction.] | [Named surface/region.] | [State and content/data preconditions.] | [Width × height, range, platform context, or explicit N/A reason.] | [observed / derived / assumed / unresolved / conflicting] | [Measurable observable result, or blocker.] | [specified / BLOCKED: stable ID] | +| VIS-001 | visual | [Observable geometry, typography, appearance, asset, layering, overflow, or clipping outcome.] | [SRC-001] | [Exact locator inside the supplied evidence.] | [Supported dimensions such as surface, state, viewport, visual.] | [Named surface/region.] | [State and content/data preconditions.] | [Applicable viewport/context.] | [observed / derived / assumed / unresolved / conflicting] | [Measurable observable result, or blocker.] | [specified / BLOCKED: stable ID] | + +Stable kinds are `content`, `structure`, `interaction`, `state`, `responsive`, +`accessibility`, `visual`, `asset`, and `restoration`. Every `UI-*`/`VIS-*` +row has exactly one kind and one derivation classification. + +### UI Evidence Projection Rules + +Apply these deterministic rules only to content/facts actually present in the +bounded supplied input: + +| Evidence class | Supported local projection | Unsupported projection example | +|---|---|---| +| HTML / semantic markup | Observed content, hierarchy, control role, attributes, and states explicitly present in the supplied fragment. | A hidden error state is not inferred because a form exists. | +| CSS / computed-style facts | Observed selectors/properties and derived visual outcomes whose cited values deterministically imply them. | A framework component name or design token is not inferred from a color value. | +| Rendered-state evidence | Visible content, geometry, typography, appearance, asset crop, layering, overflow, clipping, and evidenced state at the cited viewport/context. | DOM nesting and off-screen states are not inferred from one image. | +| Interaction evidence | Observed trigger, transition, feedback, focus, keyboard, pointer, or gesture outcome for the evidenced state/context. | Hover behavior is not inferred for a touch-only target. | +| Asset evidence | Observed asset identity/variant/crop/aspect/fitting facts that the supplied evidence identifies. | A local file path or substitution is not invented. | +| Responsive evidence | Only the states or rules supported by cited viewports, breakpoints, constraints, or supplied facts. | Mobile behavior is unresolved when only one desktop viewport is supplied. | +| Accessibility evidence | Only supplied semantic, focus, scaling, contrast, motion, input, or assistive-technology outcomes. | Compliance or screen-reader behavior is not claimed from appearance alone. | + +Derivation classifications have exact meanings: + +| Classification | Contract meaning | +|---|---| +| `observed` | The statement is directly present at the cited evidence locator. | +| `derived` | The statement follows deterministically from cited observations; record those observation refs/locators in the row. | +| `assumed` | A documented low-impact default; it is never presented as observed and its acceptance condition exposes the default. | +| `unresolved` | Required evidence is absent; the row remains `BLOCKED` with a stable blocker. | +| `conflicting` | Supplied evidence disagrees across sources, states, or viewports; the row remains `BLOCKED` and cites each conflicting locator. | + +A state, responsive, viewport, asset, or accessibility claim names that +dimension in Evidence support and cites corresponding evidence. A locator +without supplied content/facts records +`SRC_EVIDENCE_MISSING` and projects no `UI-*`/`VIS-*` requirement. + +### Restoration Equivalence + +When restoration applies, classify every dimension below for the applicable +scope. Use `required`, `not-applicable: `, or `BLOCKED: `; +absence is not a classification. + +| Restoration scope | Dimension | UI/VIS refs | SRC refs / evidence locators | Expected observable equivalence | Acceptance condition | Status / blocker | +|---|---|---|---|---|---|---| +| RST-001 | content | [UI/VIS refs] | [SRC refs + locators] | [Required content outcome.] | [Measurable condition.] | [required / N/A reason / BLOCKED] | + +Required dimensions are `content`, `information-structure`, +`visual-appearance`, `interaction-feedback`, `ui-states`, +`responsive-viewports`, `accessibility`, and `asset-identity-substitution`. + +### Pixel-Restoration Profiles + +Pixel-level restoration is an observable outcome contract. It does not define +how baselines are produced, comparisons are run, or delivery is implemented. + +| Profile ID | Scope | UI refs | VIS refs | SRC refs | Target matrix refs | Fidelity semantics | Accepted-exception policy | Status / blocker | +|---|---|---|---|---|---|---|---|---| +| PXR-001 | [Exact surface/region boundary.] | [UI-*] | [VIS-*] | [SRC-*] | [PXT-* refs covering every applicable surface × state × viewport.] | [pixel-exact / pixel-tolerant / perceptual-equivalent / structural-only] | [Stable PEX-* refs, or `None`.] | [specified / BLOCKED: stable ID] | -- **UI-001**: [Surface, loading/empty/error/success/disabled/focus states, feedback, and responsive behavior.] +A `BLOCKED` profile retains its requirement/source/target traceability and +stable blocker. Fidelity semantics or accepted-exception policy that constitute +the unresolved input may remain absent until clarification; they are mandatory +for `specified`. -### Visual Requirements and Sources +The profile's target refs are the declared applicable matrix; each ref resolves +to exactly one unique surface × state × viewport row: -- **VIS-001**: [Observable visual requirement, applicable SRC/UI refs, viewport/state refs, or source-evidence blocker.] +| Target ref | Profile ID | Surface / exact region | State and content/data preconditions | Viewport width × height | Device-pixel ratio | Baseline SRC ref + evidence locator | Rendering context | Required visual dimensions | Fidelity mode | Measurable acceptance envelope | Accepted-exception refs | Derivation | Status / blocker | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| PXT-001 | PXR-001 | [Surface and included/excluded region.] | [Deterministic state/data.] | [1280 × 720] | [Value or `N/A: immaterial`.] | [Exactly one SRC-* + supplied baseline locator.] | [Fonts/fallbacks, color mode, locale, scale, and material platform/browser constraints.] | [Geometry/spacing/alignment/flow/overflow/clipping; typography; colors/borders/radius/shadows/effects; assets/crop/fitting; layering/stacking/fixed/sticky/occlusion.] | [Allowed fidelity mode.] | [Exact equality boundary, explicit channel/aggregate tolerance, declared perceptual threshold, or structural metric.] | [PEX-* refs or `None`.] | [observed / derived / unresolved / conflicting] | [specified / BLOCKED: stable ID] | + +Accepted exceptions do not weaken unlisted regions: + +| Exception ref | Profile / target refs | Exact dynamic or divergent region | Reason | Allowed divergence and bound | Unaffected regions remain governed by | SRC / requirement refs | +|---|---|---|---|---|---|---| +| PEX-001 | [PXR/PXT refs] | [Exact region.] | [Why divergence is intentional/dynamic.] | [Explicit permitted difference and threshold.] | [Profile fidelity/envelope.] | [SRC-* + UI/VIS refs] | + +`pixel-exact` requires equality inside the declared envelope. +`pixel-tolerant` requires explicit per-pixel/channel or aggregate thresholds. +`perceptual-equivalent` requires a named metric and threshold. +`structural-only` requires geometry/content structure and explicitly excludes +pixel fidelity. Record acceptance envelopes structurally as `kind`, `metric` +when applicable, and `threshold`; for example, a pixel-tolerant target can use +`kind: per-channel, threshold: 1`. A “pixel-perfect” request without a baseline, complete target +matrix, rendering context, measurable envelope, and exception policy remains +`BLOCKED: PIXEL_PROFILE_INCOMPLETE`. + +### Cross-Platform Restoration Adaptation + +Every applicable cross-platform restoration scope has one policy row: + +| UI ref / scope | Source platform | Concrete target platform | Adaptation mode | Preserve dimensions | Adapt dimensions | Required additions | Permitted omissions | Prohibited divergences | Target contexts | SRC refs | Status / blocker | +|---|---|---|---|---|---|---|---|---|---|---|---| +| UI-001 | [HTML/Web.] | [HTML/Web, Android, iOS, iPadOS, or another concrete platform; `Swift` is invalid.] | [framework-equivalent / native-adaptive / brand-preserving-native / visual-equivalent-native] | [Dimension refs.] | [Dimension refs.] | [Dimension refs.] | [Dimension refs.] | [Explicit outcomes.] | [Window/device/input/accessibility/locale contexts.] | [SRC-*] | [specified / BLOCKED: stable ID] | + +Target contexts explicitly cover window/device class, input modalities, +accessibility/user scaling, and locale/layout direction, or carry a stable +blocker for the missing category. + +Resolve every applicable dimension through exactly one decision: + +| Policy / dimension ref | Dimension | Decision | Observable target outcome | Affected UI/VIS refs | SRC refs / target hard-constraint refs | Conflict / rationale | Acceptance condition | Status / blocker | +|---|---|---|---|---|---|---|---|---| +| ADP-001/content | content-and-information-hierarchy | [preserve / adapt / add / omit / clarify / blocked] | [Target outcome.] | [UI/VIS refs] | [SRC-* and/or hard-constraint refs.] | [Conflict and selected precedence, if any.] | [Measurable condition.] | [specified / BLOCKED: stable ID] | + +Apply decisions to at least: `content-and-information-hierarchy`, +`task-flow-and-navigation`, `surface-and-component-role`, +`ui-state-and-feedback`, `geometry-and-composition`, `typography`, +`color-effects-and-brand`, `assets-and-variants`, +`input-modality-and-gestures`, `responsive-adaptive-layout`, +`system-ui-and-safe-regions`, `accessibility-and-user-scaling`, and +`localization-and-layout-direction`. + +Allowed decisions are `preserve`, `adapt`, `add`, `omit`, `clarify`, and +`blocked`. Mixed behavior is expressed per dimension; `hybrid` is not a mode. +Every `adapt`, `add`, or `omit` cites affected `UI-*`/`VIS-*` plus `SRC-*` +evidence or a target hard constraint. + +HTML-to-Android, iOS, and iPadOS use `brand-preserving-native` unless the +policy records an explicit reason for another allowed native mode. + +Resolve conflicts in this exact order: + +```text +target-platform hard constraints and accessibility requirements + > explicit product requirements + > declared adaptation policy and per-dimension decisions + > source-backed observable UI evidence + > target-platform defaults + > implementation preference +``` + +For example, a source icon may preserve its visible size while an Android or +iOS target adds the larger interaction area required by accessibility. Record +that as `adapt`/`add`; do not silently change source geometry. + +`framework-equivalent` preserves declared rendered outcomes for every target +while allowing internal framework, DOM, style organization, and state +management differences. Native targets preserve product/state/brand semantics +and explicitly classify target-required system, accessibility, input, and +adaptive-layout differences. Concrete widgets, classes, code properties, +navigation containers, unit conversions, and resource paths belong to X2-B +UI/UX delivery design, not this specification. ### Security and Privacy @@ -49,20 +194,20 @@ non-applicability is confirmed. ## Source References This table is the feature-local Source Reference Contract. A source identity is -opaque provenance: retain a supplied URI, path, revision, digest, conversation -reference, or human description without interpreting or validating its external -meaning or state. +opaque provenance. Requirement projection depends on bounded supplied +content/facts and cited evidence locators, never on the locator alone. -| SRC ref | Role | Opaque locator / description | Revision / identity | Authorized scope / 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 and authorized WHAT/WHY facts.] | [FR/NFR/UX/UI/VIS refs, or `None`.] | [projected / retained / NEEDS CLARIFICATION / BLOCKED with reason.] | +| 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.] | Allowed roles are exactly `requirement-input`, `visual-input`, `technical-evidence`, and `context-only`. `context-only` and -`technical-evidence` do not authorize normative `FR/NFR/UX/UI/VIS` projection. +`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 safe feature slice stays blocked or needs clarification; it is not imported in -full. +full. A row with no supplied content/facts stays +`BLOCKED: SRC_EVIDENCE_MISSING` and has no projected requirement refs. ## Unresolved Product Decisions diff --git a/presets/workflow-preset/templates/ui-ux-design-template.md b/presets/workflow-preset/templates/ui-ux-design-template.md index f2432037ec..6dfaaf84fb 100644 --- a/presets/workflow-preset/templates/ui-ux-design-template.md +++ b/presets/workflow-preset/templates/ui-ux-design-template.md @@ -1,29 +1,78 @@ # UI/UX Delivery Design: [FEATURE] -## Surface, Component, And State Model +**Consumed Spec SHA-256**: [sha256:<64 lowercase hexadecimal characters>] -| SRC + UI/VIS refs | View / surface | Component responsibility | Composition | State owner / transitions | Events / navigation | Viewports / responsive | Accessibility | -|---|---|---|---|---|---|---|---| +The digest is local `spec.md` freshness evidence only. It does not certify an +external source, locator, revision, or baseline. -## Theme, Assets, And Delivery +## Spec UI Input Inventory -| SRC + UI/VIS refs | Tokens/theme/variant | Asset source | Required variants | Fallback | Opaque accepted-source provenance / local baseline | Local delivery/review method | Local evidence expectation | Blocker | +Inventory every applicable Spec-owned UI contract ref exactly once. This table +contains refs and statuses only; it does not copy requirement statements, +baseline locators, rendering contexts, fidelity modes, acceptance envelopes, +exception bounds, or adaptation decisions. + +| Spec ref | Contract class | Spec status | X2-B applicability | X2-B mapping ref or propagated blocker | +|---|---|---|---|---| +| UI-001 | UI | specified / BLOCKED | Required / Blocked | X2B-UI-001 / stable upstream blocker | +| RST-001/content | RST | specified / BLOCKED | Required / Blocked | X2B-UI-001 / stable upstream blocker | +| PXT-001 | PXT | specified / BLOCKED | Required / Blocked | X2B-PX-001 / stable upstream blocker | +| ADP-001/accessibility-and-user-scaling | ADP | specified / BLOCKED | Required / Blocked | X2B-ADP-001 / stable upstream blocker | + +Allowed contract classes are exactly `UI`, `VIS`, `RST`, `PXR`, `PXT`, `PEX`, +and `ADP`. Blocked Spec applicability remains blocked with the same stable +blocker; it is never relabeled `N/A` or replaced by a Plan decision. + +## General UI Delivery Mappings + +| X2B mapping ref | UI/VIS/RST refs | SRC refs | Surface / region binding | Component responsibility / composition / state ownership | Navigation / input / responsive / accessibility delivery | DEC-UI refs | UIF / interface / asset refs | Status / blocker | |---|---|---|---|---|---|---|---|---| +| X2B-UI-001 | [UI/VIS/RST refs] | [SRC refs] | [Exact delivery binding.] | [Plan-owned component and state delivery design.] | [Plan-owned navigation/input/layout/accessibility delivery.] | [DEC-UI refs] | [Resolved refs or explicit N/A.] | [READY / BLOCKED: stable ID] | + +## Pixel-Target Delivery Mappings + +| X2B mapping ref | PXR ref | PXT ref | UI/VIS refs | SRC refs | Exact target-region binding | Component / style / token / asset / layering / overflow delivery mapping | PEX refs | Local delivery/review method | DEC-UI / UIF / interface / asset refs | Status / blocker | +|---|---|---|---|---|---|---|---|---|---|---| +| X2B-PX-001 | [PXR-*] | [PXT-*] | [UI/VIS refs] | [SRC refs] | [Exact component/region ownership.] | [Plan-owned delivery design.] | [PEX refs or None.] | [Local X2-B method.] | [Resolved refs.] | [READY / BLOCKED: stable ID] | + +The pixel row references its Spec-owned baseline, target state/viewport, +rendering context, fidelity mode, acceptance envelope, and exception bounds. +It must not repeat or weaken those values. + +## Platform-Adaptation Delivery Mappings + +| X2B mapping ref | ADP policy/dimension ref | UI/VIS refs | SRC refs | Target-context binding | Target component / navigation / input / layout / accessibility delivery design | DEC-UI refs | UIF / interface / asset refs | Status / blocker | +|---|---|---|---|---|---|---|---|---|---| +| X2B-ADP-001 | [ADP policy/dimension ref] | [UI/VIS refs] | [SRC refs] | [Window/device/input/accessibility/locale context binding.] | [Plan-owned target delivery design.] | [DEC-UI refs] | [Resolved refs.] | [READY / BLOCKED: stable ID] | + +The mapping implements the referenced Spec decision; it does not copy, +reinterpret, or replace `preserve`, `adapt`, `add`, `omit`, `clarify`, or +`blocked`. ## UIF Contracts -| SRC + UI/VIS refs | UIF path | Start view / events / routes | Observable states/feedback | API/interface refs | Blocker | -|---|---|---|---|---|---| +| X2B mapping refs | SRC + UI/VIS refs | UIF path | Start view / events / routes | Observable states/feedback | API/interface refs | Blocker | +|---|---|---|---|---|---|---| +| [X2B refs] | [SRC + UI/VIS refs] | [contracts/uif/*.expected.json] | [UIF delivery path.] | [Referenced observable outcomes.] | [Resolved refs.] | [None / stable ID] | UIF references interface schemas; it does not duplicate API payloads. ## X4 UI/UX Delivery Readiness -| SRC + UI/VIS refs | View/state/viewport | Component | Asset/variant/fallback | UIF ref | Opaque source provenance / local baseline | Local delivery/review method | Local evidence requirement | Blocker | -|---|---|---|---|---|---|---|---|---| +Every required `X2B-*` mapping has exactly one closed row. + +| X2B mapping ref | Mapping class | Required Spec refs | Resolved DEC-UI / UIF / interface / asset / exception refs | Status | Local evidence | Blocker | +|---|---|---|---|---|---|---| +| X2B-UI-001 | general-ui | [refs] | [resolved refs] | READY / BLOCKED | [Local delivery evidence.] | [None / stable ID] | + +`X2B_UIUX_READY` is READY only when the recorded Spec digest is current, the +inventory and delivery mappings are complete and unique, every internal ref +resolves, every readiness row is closed, no Spec-owned value is duplicated, +and no X2-B/reconciliation finding remains. Pixel delivery/review is owned here, never by Test Conditions. This artifact -contains no BDD strategy, general test levels, task IDs, or implementation -results. Source locators are provenance only: X2-B does not dereference, -execute, inspect, compare against, or certify an external source, its fidelity, -freshness, revision, availability, or publication state. +contains no BDD strategy, general test levels, task IDs, implementation +results, pixel comparison execution, screenshot capture/diff, visual +acceptance, or final visual review. Source locators are provenance only: X2-B +does not dereference, execute, inspect, compare against, or certify an external +source, its fidelity, freshness, revision, availability, or publication state. diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json b/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json index a74a884035..93d8e6bd64 100644 --- a/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/async_retry_compensation.json @@ -6,6 +6,19 @@ "X2-B": {"status": "N/A", "reason": "The scoped worker flow has no user-visible surface."}, "X2-C": {"status": "Required"} }, + "spec_input": { + "current_sha256": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "recorded_x0_sha256": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "recorded_uiux_sha256": "N/A", + "ui_contract_refs": [], + "non_ui_evidence": { + "spec_scope_ref": "spec.md#worker-recovery-scope", + "reason": "The scoped Spec contains no UI, VIS, restoration, pixel, or adaptation contract ref." + } + }, + "x2b_input_inventory": [], + "x2b_delivery_mappings": [], + "uiux_readiness_rows": [], "artifacts": [ {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "Async worker scope, gates, reconciliation, and closeout are evidenced."}, {"path": "research.md", "decision": "Required", "owner": "X1", "content": "Retry, idempotency, and compensation decisions are recorded."}, diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json b/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json index ebe333d049..d72e487995 100644 --- a/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/minimal_repository.json @@ -6,6 +6,19 @@ "X2-B": {"status": "N/A", "reason": "The scoped library API has no user-visible surface."}, "X2-C": {"status": "Required"} }, + "spec_input": { + "current_sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "recorded_x0_sha256": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "recorded_uiux_sha256": "N/A", + "ui_contract_refs": [], + "non_ui_evidence": { + "spec_scope_ref": "spec.md#scoped-library-api", + "reason": "The scoped Spec contains no UI, VIS, restoration, pixel, or adaptation contract ref." + } + }, + "x2b_input_inventory": [], + "x2b_delivery_mappings": [], + "uiux_readiness_rows": [], "artifacts": [ {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "The empty observed topology and intended module boundary are explicit."}, {"path": "research.md", "decision": "Required", "owner": "X1", "content": "The runtime prerequisite and interface format decisions are recorded."}, diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json b/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json index e4843fc938..98c1cbe24b 100644 --- a/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/non_ui_single_interface.json @@ -6,6 +6,19 @@ "X2-B": {"status": "N/A", "reason": "The scoped Spec exposes no user-visible surface."}, "X2-C": {"status": "Required"} }, + "spec_input": { + "current_sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_x0_sha256": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_uiux_sha256": "N/A", + "ui_contract_refs": [], + "non_ui_evidence": { + "spec_scope_ref": "spec.md#command-status-scope", + "reason": "The scoped Spec contains no UI, VIS, restoration, pixel, or adaptation contract ref." + } + }, + "x2b_input_inventory": [], + "x2b_delivery_mappings": [], + "uiux_readiness_rows": [], "artifacts": [ {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "Scope, gates, reconciliation, and closeout are evidenced."}, {"path": "research.md", "decision": "Required", "owner": "X1", "content": "Interface and Test decisions are recorded.", "declared_ids": []}, diff --git a/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json b/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json index d4a090a028..c5d59981db 100644 --- a/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json +++ b/presets/workflow-preset/tests/fixtures/plan_bundles/ui_only.json @@ -6,6 +6,101 @@ "X2-B": {"status": "Required"}, "X2-C": {"status": "Required"} }, + "spec_input": { + "current_sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "recorded_x0_sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "recorded_uiux_sha256": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "ui_contract_refs": [ + {"ref": "UI-001", "contract_class": "UI", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "VIS-001", "contract_class": "VIS", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "RST-001/content", "contract_class": "RST", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "PXR-001", "contract_class": "PXR", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "PXT-001", "contract_class": "PXT", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "PEX-001", "contract_class": "PEX", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "ADP-001/content-and-information-hierarchy", "contract_class": "ADP", "status": "specified", "source_refs": ["SRC-UI-001"]}, + {"ref": "ADP-001/accessibility-and-user-scaling", "contract_class": "ADP", "status": "specified", "source_refs": ["SRC-UI-001"]} + ] + }, + "x2b_input_inventory": [ + {"spec_ref": "UI-001", "contract_class": "UI", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-UI-001"}, + {"spec_ref": "VIS-001", "contract_class": "VIS", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-UI-001"}, + {"spec_ref": "RST-001/content", "contract_class": "RST", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-UI-001"}, + {"spec_ref": "PXR-001", "contract_class": "PXR", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-PX-001"}, + {"spec_ref": "PXT-001", "contract_class": "PXT", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-PX-001"}, + {"spec_ref": "PEX-001", "contract_class": "PEX", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-PX-001"}, + {"spec_ref": "ADP-001/content-and-information-hierarchy", "contract_class": "ADP", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-ADP-001"}, + {"spec_ref": "ADP-001/accessibility-and-user-scaling", "contract_class": "ADP", "spec_status": "specified", "x2b_applicability": "Required", "mapping_ref": "X2B-ADP-002"} + ], + "x2b_delivery_mappings": [ + { + "id": "X2B-UI-001", + "kind": "general-ui", + "spec_refs": ["UI-001", "VIS-001", "RST-001/content"], + "source_refs": ["SRC-UI-001"], + "surface_region_binding": "Banner surface and message/action regions", + "component_delivery": "Banner component owns composition, visible/hidden state, and feedback", + "navigation_input_responsive_accessibility": "Dismiss input, responsive wrapping, focus continuity, and semantic announcement", + "decision_refs": ["DEC-UI-001"], + "uif_refs": ["UIF-001"], + "interface_refs": [], + "asset_refs": [], + "status": "READY" + }, + { + "id": "X2B-PX-001", + "kind": "pixel-target", + "spec_refs": ["PXR-001", "PXT-001", "PEX-001"], + "pxr_ref": "PXR-001", + "pxt_ref": "PXT-001", + "ui_vis_refs": ["UI-001", "VIS-001"], + "source_refs": ["SRC-UI-001"], + "target_region_binding": "Banner outer box and message/action subregions", + "delivery_mapping": "Component layout, typography token, asset, stacking, and clipping responsibilities", + "pex_refs": ["PEX-001"], + "local_delivery_review_method": "X2-B local rendered-delivery review against referenced Spec outcomes", + "decision_refs": ["DEC-UI-001"], + "uif_refs": ["UIF-001"], + "interface_refs": [], + "asset_refs": [], + "status": "READY" + }, + { + "id": "X2B-ADP-001", + "kind": "platform-adaptation", + "spec_refs": ["ADP-001/content-and-information-hierarchy"], + "adp_ref": "ADP-001/content-and-information-hierarchy", + "ui_vis_refs": ["UI-001", "VIS-001"], + "source_refs": ["SRC-UI-001"], + "target_context_binding": "Android compact and expanded window contexts", + "target_delivery_design": "Target component composition preserves information hierarchy", + "decision_refs": ["DEC-UI-001"], + "uif_refs": ["UIF-001"], + "interface_refs": [], + "asset_refs": [], + "status": "READY" + }, + { + "id": "X2B-ADP-002", + "kind": "platform-adaptation", + "spec_refs": ["ADP-001/accessibility-and-user-scaling"], + "adp_ref": "ADP-001/accessibility-and-user-scaling", + "ui_vis_refs": ["UI-001", "VIS-001"], + "source_refs": ["SRC-UI-001"], + "target_context_binding": "Android font-scale and assistive-technology contexts", + "target_delivery_design": "Target semantics, focus, interaction area, and scaling delivery", + "decision_refs": ["DEC-UI-001"], + "uif_refs": ["UIF-001"], + "interface_refs": [], + "asset_refs": [], + "status": "READY" + } + ], + "uiux_readiness_rows": [ + {"mapping_ref": "X2B-UI-001", "status": "READY", "evidence": "ui-ux-design.md#X2B-UI-001"}, + {"mapping_ref": "X2B-PX-001", "status": "READY", "evidence": "ui-ux-design.md#X2B-PX-001"}, + {"mapping_ref": "X2B-ADP-001", "status": "READY", "evidence": "ui-ux-design.md#X2B-ADP-001"}, + {"mapping_ref": "X2B-ADP-002", "status": "READY", "evidence": "ui-ux-design.md#X2B-ADP-002"} + ], "artifacts": [ {"path": "plan.md", "decision": "Required", "owner": "X0/X4", "content": "UI-only scope, gates, reconciliation, and closeout are evidenced."}, {"path": "research.md", "decision": "Required", "owner": "X1", "content": "UI state and component Test decisions are recorded."}, @@ -19,7 +114,7 @@ {"path": "test-readiness.md", "decision": "Required", "owner": "X4", "content": "TC-UI-001 has one readiness row."} ], "decisions": [ - {"id": "DEC-UI-001", "affected_refs": ["UIF-001", "TC-UI-001"]} + {"id": "DEC-UI-001", "affected_refs": ["X2B-UI-001", "X2B-PX-001", "X2B-ADP-001", "X2B-ADP-002", "UIF-001", "TC-UI-001"]} ], "uif_contracts": [ {"id": "UIF-001", "source_refs": ["SRC-UI-001"], "requirement_refs": ["UI-001", "VIS-001"], "related_refs": []} @@ -52,7 +147,7 @@ {"id": "VAL-UI-001", "covered_refs": ["TC-UI-001", "UIF-001"]} ], "reconciliation": { - "resolved_refs": ["DEC-UI-001", "UIF-001", "TC-UI-001", "VAL-UI-001"], + "resolved_refs": ["DEC-UI-001", "X2B-UI-001", "X2B-PX-001", "X2B-ADP-001", "X2B-ADP-002", "UIF-001", "TC-UI-001", "VAL-UI-001"], "findings": [], "blocker_owners": {} }, @@ -60,7 +155,7 @@ "X0_CONTROL_READY": {"status": "READY", "evidence": ["plan.md#x0-feature-plan-control"]}, "X1_DECISIONS_READY": {"status": "READY", "evidence": ["research.md#DEC-UI-001"]}, "X2A_DESIGN_READY": {"status": "N/A", "evidence": ["plan.md#active-lane-matrix-local-ui-only"]}, - "X2B_UIUX_READY": {"status": "READY", "evidence": ["ui-ux-design.md#delivery-readiness", "contracts/uif/banner.expected.json#UIF-001"]}, + "X2B_UIUX_READY": {"status": "READY", "evidence": ["ui-ux-design.md#spec-ui-input-inventory", "ui-ux-design.md#delivery-mappings", "ui-ux-design.md#delivery-readiness", "contracts/uif/banner.expected.json#UIF-001"]}, "X2C_TEST_DESIGN_READY": {"status": "READY", "evidence": ["contracts/test/test-conditions.json#TC-UI-001"]}, "X2_RECONCILIATION_READY": {"status": "READY", "evidence": ["plan.md#x2-cross-lane-reconciliation"]}, "X3_VALIDATION_PATHS_READY": {"status": "READY", "evidence": ["quickstart.md#VAL-UI-001"]} diff --git a/presets/workflow-preset/tests/test_preset_contract.py b/presets/workflow-preset/tests/test_preset_contract.py index 5295036671..0d724f12a0 100644 --- a/presets/workflow-preset/tests/test_preset_contract.py +++ b/presets/workflow-preset/tests/test_preset_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy import json import re import unittest @@ -16,6 +17,17 @@ ) from validators.speckit_behavior_contract import validate_behavior_contract_bundle from validators.speckit_plan_contract import validate_plan_artifact_bundle +from validators.speckit_spec_contract import ( + ADAPTATION_DIMENSIONS, + CONFLICT_PRECEDENCE, + RESTORATION_DIMENSIONS, + validate_ui_specification_contract, +) +from validators.speckit_tasks_contract import ( + FINAL_REVIEW_SCOPES, + MAPPING_DIMENSIONS, + validate_tasks_x2b_derivation, +) from validators.speckit_test_contract import ( validate_test_conditions, validate_test_readiness, @@ -44,6 +56,19 @@ def load_json(path: Path) -> dict: return json.loads(read(path)) +def replace_string_values(value, old: str, new: str): + if isinstance(value, dict): + return { + key: replace_string_values(child, old, new) + for key, child in value.items() + } + if isinstance(value, list): + return [replace_string_values(child, old, new) for child in value] + if isinstance(value, str): + return value.replace(old, new) + return value + + def minimal_test_conditions(*, technique: str = "contract_testing") -> dict: return { "contract_type": "speckit.test.conditions.v1", @@ -105,7 +130,8 @@ def source_contract_snapshot() -> dict: "ref": "SRC-001", "role": "requirement-input", "locator_or_description": "current conversation direction", - "authorized_scope": "refund submission behavior", + "bounded_scope": "refund submission behavior", + "supplied_facts": ["refunds can be submitted"], "projected_refs": ["FR-001"], "status": "projected", }, @@ -114,7 +140,8 @@ def source_contract_snapshot() -> dict: "role": "requirement-input", "locator_or_description": "opaque product document", "revision": "supplied-r7", - "authorized_scope": "refund eligibility section", + "bounded_scope": "refund eligibility section", + "supplied_facts": ["eligibility rules in the supplied section"], "feature_slice": "refund eligibility", "broad": True, "projected_refs": ["FR-002"], @@ -124,7 +151,8 @@ def source_contract_snapshot() -> dict: "ref": "SRC-003", "role": "visual-input", "locator_or_description": "opaque executable visual reference", - "authorized_scope": "refund error states", + "bounded_scope": "refund error states", + "supplied_facts": ["supplied error-state markup and appearance"], "projected_refs": ["UI-001", "VIS-001"], "status": "projected", "uif_required": True, @@ -133,7 +161,8 @@ def source_contract_snapshot() -> dict: "ref": "SRC-004", "role": "technical-evidence", "locator_or_description": "latency measurement report", - "authorized_scope": "technical evidence citation only", + "bounded_scope": "technical evidence citation only", + "supplied_facts": ["latency measurement"], "projected_refs": [], "status": "retained", }, @@ -141,7 +170,8 @@ def source_contract_snapshot() -> dict: "ref": "SRC-005", "role": "context-only", "locator_or_description": "competitor overview", - "authorized_scope": "background only", + "bounded_scope": "background only", + "supplied_facts": ["competitor context"], "projected_refs": [], "status": "context-only", }, @@ -160,6 +190,335 @@ def source_contract_snapshot() -> dict: } +def minimal_ui_spec_contract() -> dict: + requirements = [ + { + "id": "UI-001", + "kind": "interaction", + "statement": "The refund panel exposes error feedback.", + "source_refs": ["SRC-UI-001"], + "evidence_locators": ["refund.html#refund-panel"], + "evidence_support": ["surface", "state", "viewport", "interaction"], + "surface": "refund panel", + "state": "validation error with supplied invalid amount", + "viewport": "1280x720 web", + "derivation": "observed", + "acceptance": "The cited error text is visible in the panel.", + "outcome_only": True, + "status": "specified", + }, + { + "id": "VIS-001", + "kind": "restoration", + "statement": "The refund panel matches the supplied baseline.", + "source_refs": ["SRC-UI-001"], + "evidence_locators": ["baseline.png#refund-panel"], + "evidence_support": ["surface", "state", "viewport", "restoration"], + "surface": "refund panel", + "state": "validation error with supplied invalid amount", + "viewport": "1280x720 web", + "derivation": "observed", + "acceptance": "PXT-001 satisfies its declared envelope.", + "outcome_only": True, + "status": "specified", + }, + ] + return { + "sources": [ + { + "ref": "SRC-UI-001", + "role": "visual-input", + "locator_or_description": "supplied refund HTML and baseline", + "bounded_scope": "refund error panel", + "supplied_facts": [ + "refund.html#refund-panel", + "baseline.png#refund-panel", + ], + "projected_refs": ["UI-001", "VIS-001"], + "status": "projected", + } + ], + "all_spec_requirement_refs": [ + requirement["id"] for requirement in requirements + ], + "requirements": requirements, + "restoration_requested": True, + "restoration_dimensions": [ + { + "dimension": dimension, + "requirement_refs": ["VIS-001"], + "source_refs": ["SRC-UI-001"], + "evidence_locators": ["baseline.png#refund-panel"], + "acceptance": f"{dimension} matches the cited baseline.", + "status": "required", + } + for dimension in sorted(RESTORATION_DIMENSIONS) + ], + "pixel_restoration_requested": True, + "pixel_profiles": [ + { + "id": "PXR-001", + "scope": "refund panel", + "requirement_refs": ["UI-001", "VIS-001"], + "source_refs": ["SRC-UI-001"], + "target_refs": ["PXT-001"], + "target_matrix": [ + { + "target_ref": "PXT-001", + "surface": "refund panel", + "state": "validation error with supplied invalid amount", + "viewport": "1280x720", + } + ], + "fidelity_mode": "pixel-tolerant", + "exception_policy": "Only stable PEX refs weaken exact regions.", + "exception_refs": ["PEX-001"], + "status": "specified", + } + ], + "pixel_targets": [ + { + "id": "PXT-001", + "profile_id": "PXR-001", + "surface": "refund panel", + "state": "validation error with supplied invalid amount", + "viewport": "1280x720", + "device_pixel_ratio": "1", + "baseline_source_ref": "SRC-UI-001", + "baseline_locator": "baseline.png#refund-panel", + "rendering_context": { + "fonts": "Inter with supplied fallback", + "color_mode": "light", + "locale": "en-US", + "platform": "Chromium on Windows", + }, + "visual_dimensions": { + "geometry-sizing-spacing-alignment-flow": "match baseline", + "overflow-and-clipping": "match baseline", + "typography": "match baseline family/size/weight/metrics", + "color-border-radius-shadow-opacity-effects": "match baseline", + "asset-identity-variant-crop-aspect-fitting": "match baseline", + "layering-stacking-fixed-sticky-occlusion": "match baseline", + }, + "fidelity_mode": "pixel-tolerant", + "acceptance_envelope": { + "kind": "per-channel", + "threshold": 1, + }, + "exception_refs": ["PEX-001"], + "derivation": "observed", + "status": "specified", + } + ], + "pixel_exceptions": [ + { + "id": "PEX-001", + "target_refs": ["PXT-001"], + "region": "timestamp text only", + "reason": "dynamic value", + "allowed_divergence": "glyph pixels inside timestamp bounds", + "bound": "the measured timestamp bounding box", + "requirement_refs": ["VIS-001"], + "source_refs": ["SRC-UI-001"], + } + ], + "cross_platform_restoration_requested": True, + "adaptation_policies": [ + { + "id": "ADP-001", + "source_platform": "HTML/Web", + "target_platform": "Android", + "mode": "brand-preserving-native", + "target_contexts": { + "window_or_device": "compact portrait", + "input": "touch and external keyboard", + "accessibility": "font scale 1.0-2.0 and screen reader", + "locale": "en-US plus RTL expansion", + }, + "source_refs": ["SRC-UI-001"], + "conflict_precedence": list(CONFLICT_PRECEDENCE), + "decisions": [ + { + "dimension": dimension, + "decision": ( + "preserve" + if dimension + in { + "content-and-information-hierarchy", + "task-flow-and-navigation", + "ui-state-and-feedback", + "color-effects-and-brand", + } + else "adapt" + ), + "requirement_refs": ["UI-001", "VIS-001"], + "source_refs": ["SRC-UI-001"], + "outcome": f"Target outcome for {dimension}.", + "acceptance": f"Observe the declared {dimension} outcome.", + "status": "specified", + } + for dimension in sorted(ADAPTATION_DIMENSIONS) + ], + "status": "specified", + } + ], + } + + +def minimal_tasks_x2b_bundle() -> dict: + ui_dimensions = sorted(MAPPING_DIMENSIONS["UI"]) + pixel_dimensions = sorted(MAPPING_DIMENSIONS["PX"]) + adaptation_dimensions = sorted(MAPPING_DIMENSIONS["ADP"]) + pixel_binding_dimensions = [ + dimension + for dimension in pixel_dimensions + if dimension != "asset-preparation" + ] + return { + "plan_output_ready": "READY", + "current_plan_revision": "PLAN-47", + "tasks_handoff_revision": "PLAN-47", + "uiux_delivery_readiness": "READY", + "declared_traceability_refs": [ + "UI-001", + "VIS-001", + "PXT-001", + "PEX-001", + "ADP-001", + ], + "x2b_mappings": [ + { + "id": "X2B-UI-001", + "status": "Required", + "implementation_dimensions": list(ui_dimensions), + "depends_on": [], + "traceability_refs": ["UI-001"], + }, + { + "id": "X2B-PX-001", + "status": "Required", + "implementation_dimensions": list(pixel_dimensions), + "depends_on": ["X2B-UI-001"], + "traceability_refs": [ + "VIS-001", + "PXT-001", + "PEX-001", + ], + }, + { + "id": "X2B-ADP-001", + "status": "Required", + "implementation_dimensions": list(adaptation_dimensions), + "depends_on": ["X2B-UI-001"], + "traceability_refs": ["ADP-001"], + }, + { + "id": "X2B-PX-REVIEW-001", + "status": "Required", + "implementation_dimensions": [], + "depends_on": [], + "traceability_refs": ["PXT-001"], + "review_method_only": True, + "no_task_rationale": ( + "The Plan records only a review method; Tasks cannot " + "execute rendered visual comparison." + ), + }, + { + "id": "X2B-ADP-NA-001", + "status": "N/A", + "implementation_dimensions": [], + "depends_on": [], + "traceability_refs": [], + "reason": "The supplied slice targets one platform.", + }, + ], + "tasks": [ + { + "id": "T040", + "kind": "implementation", + "action_classes": ["implementation"], + "paths": ["src/ui/RefundPanel.tsx"], + "mapping_refs": ["X2B-UI-001"], + "implementation_dimensions": list(ui_dimensions), + "traceability_refs": ["UI-001"], + "depends_on": [], + "parallel": False, + "description": "Implement the mapped component and UI states.", + }, + { + "id": "T041", + "kind": "implementation", + "action_classes": ["implementation"], + "paths": ["src/ui/assets/refund-panel.svg"], + "mapping_refs": ["X2B-PX-001"], + "implementation_dimensions": ["asset-preparation"], + "traceability_refs": ["VIS-001", "PXT-001"], + "depends_on": ["T040"], + "parallel": False, + "description": "Prepare the locally authorized panel asset.", + }, + { + "id": "T042", + "kind": "implementation", + "action_classes": ["implementation"], + "paths": ["src/ui/RefundPanel.tsx"], + "mapping_refs": ["X2B-PX-001"], + "implementation_dimensions": pixel_binding_dimensions, + "traceability_refs": ["VIS-001", "PXT-001", "PEX-001"], + "depends_on": ["T040", "T041"], + "parallel": False, + "description": "Implement mapped visual dimensions and bind assets.", + }, + { + "id": "T043", + "kind": "implementation", + "action_classes": ["implementation"], + "paths": ["src/platform/android/RefundPanel.kt"], + "mapping_refs": ["X2B-ADP-001"], + "implementation_dimensions": list(adaptation_dimensions), + "traceability_refs": ["ADP-001"], + "depends_on": ["T040"], + "parallel": False, + "description": "Implement the mapped Android adaptation.", + }, + ], + "required_test_readiness_tc_refs": ["TC-UI-001"], + "test_tasks": [ + { + "id": "T044", + "action_classes": ["functional-validation"], + "tc_refs": ["TC-UI-001"], + "paths": ["tests/ui/test_refund_panel.py"], + } + ], + "phases": ["Setup", "Refund panel", "Final Code Review"], + "final_review": { + "phase": "Final Code Review", + "kind": "code-design-contract-review", + "action_classes": ["code-design-contract-review"], + "mapping_refs": [ + "X2B-UI-001", + "X2B-PX-001", + "X2B-ADP-001", + ], + "scopes": [ + "implementation-conformance", + "x2b-blockers-and-plan-drift", + FINAL_REVIEW_SCOPES["UI"], + FINAL_REVIEW_SCOPES["PX"], + FINAL_REVIEW_SCOPES["ADP"], + ], + "paths": [ + "src/ui/RefundPanel.tsx", + "src/ui/assets/refund-panel.svg", + "src/platform/android/RefundPanel.kt", + ], + "description": "Review code/design-contract conformance for X2-B.", + }, + } + + def minimal_assertions() -> dict: return { "contract_type": "speckit.behavior.assertions.v1", @@ -229,7 +588,11 @@ def test_governance_uses_one_authority_and_gate_vocabulary(self) -> None: self.assertIn("Analyze Cross-Command Audit", governance) self.assertIn("Source Reference Contract", governance) self.assertIn( - "SRC-* + UI/VIS-* -> ui-ux-design.md -> UIF source_refs + requirement_refs", + "SRC-* + UI/VIS/RST/PXR/PXT/PEX/ADP refs", + governance, + ) + self.assertIn( + "X2B-* delivery mappings + UIF source_refs/requirement_refs", governance, ) @@ -331,8 +694,11 @@ def test_full_spectrum_spec_carrier_is_optional_and_stable(self) -> None: "Functional Requirements", "Non-Functional Requirements", "UX Journeys and Interaction Expectations", - "UI Surfaces and States", - "Visual Requirements and Sources", + "UI Specification Contract", + "UI Evidence Projection Rules", + "Restoration Equivalence", + "Pixel-Restoration Profiles", + "Cross-Platform Restoration Adaptation", "Security and Privacy", "Data and Integration Constraints", "Dependencies and Boundaries", @@ -355,7 +721,8 @@ def test_source_reference_template_has_one_source_neutral_shape(self) -> None: "Role", "Opaque locator / description", "Revision / identity", - "Authorized scope / facts", + "Bounded feature scope", + "Supplied content / facts", "Projected requirement refs", "Status / blocker", ): @@ -377,7 +744,7 @@ def test_specify_has_no_core_checklist_side_effect(self) -> None: "hooks.after_specify", "SPECIFY_FEATURE_DIRECTORY", ".specify/feature.json", - "Authorized Source Input Contract", + "Bounded Supplied Input Contract", "Full-Spectrum Projection", "feature-local WHAT/WHY SSOT", "Do not compute completeness", @@ -387,21 +754,76 @@ def test_specify_has_no_core_checklist_side_effect(self) -> None: self.assertIn("MUST NOT create, read, evaluate, or modify", command) self.assertNotIn("{CORE_TEMPLATE}", command) - def test_source_commands_keep_external_actions_outside_preset(self) -> None: + def test_specify_starts_at_bounded_evidence_without_upstream_responsibilities( + self, + ) -> None: specify = read(COMMANDS / "speckit.specify.md") clarify = read(COMMANDS / "speckit.clarify.md") checklist = read(COMMANDS / "speckit.checklist.md") for term in ( - "dereference or execute a locator", - "import manifest", - "provider-specific\nschema", - "Intake is not an SDD stage", + "bounded content or source-backed facts have already\nbeen supplied", + "locator without supplied content or source-backed facts is provenance only", + "SRC_EVIDENCE_MISSING", + "UI Evidence Projection Rules", + "surface x state x viewport", + "PIXEL_PROFILE_INCOMPLETE", + "one allowed adaptation mode", ): self.assertIn(term, specify) + for forbidden in ( + "authorization", + "tool-call", + "provider", + "plugin", + "dereference", + "adapter", + "external synchronization", + ): + self.assertNotIn(forbidden, specify.casefold()) self.assertIn("external\nwrite-back or synchronization", clarify) self.assertIn("preserve the originating `SRC-*` provenance", clarify) self.assertIn("MUST NOT dereference a locator", checklist) + def test_ui_spec_template_exposes_deterministic_restoration_contracts(self) -> None: + template = read(TEMPLATES / "spec-template.md") + for term in ( + "Evidence locator(s) within supplied input", + "observed / derived / assumed / unresolved / conflicting", + "HTML / semantic markup", + "CSS / computed-style facts", + "responsive-viewports", + "PXR-001", + "PXT-001", + "PEX-001", + "pixel-exact", + "pixel-tolerant", + "perceptual-equivalent", + "structural-only", + "framework-equivalent", + "native-adaptive", + "brand-preserving-native", + "visual-equivalent-native", + "target-platform hard constraints and accessibility requirements", + "`Swift` is invalid", + "Concrete widgets, classes, code properties", + ): + self.assertIn(term, template) + self.assertIn("UI requirement source of truth inside `spec.md`", template) + self.assertIn("capture/comparison\nprocedures", template) + + def test_visual_checklist_covers_evidence_pixel_and_adaptation_quality(self) -> None: + checklist = read(TEMPLATES / "requirements" / "visual-gate.md") + for checklist_id in ( + "CHK-UI-003", + "CHK-RST-001", + "CHK-PXR-001", + "CHK-PXR-002", + "CHK-ADP-001", + "CHK-ADP-002", + "CHK-BND-001", + ): + self.assertIn(checklist_id, checklist) + def test_clarify_writes_only_spec_and_uses_cross_domain_priority(self) -> None: command = read(COMMANDS / "speckit.clarify.md") for term in ( @@ -430,6 +852,262 @@ def test_checklist_generates_unanswered_questions_only(self) -> None: self.assertNotIn("Readiness Matrix", template) +class UISpecContractTests(unittest.TestCase): + def test_complete_ui_spec_contract_is_valid(self) -> None: + validate_ui_specification_contract(minimal_ui_spec_contract()) + + def test_locator_only_and_visual_role_overreach_are_rejected(self) -> None: + payload = minimal_ui_spec_contract() + payload["sources"][0]["supplied_facts"] = [] + with self.assertRaisesRegex(ValueError, "locator alone"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["sources"][0]["projected_refs"].append("FR-001") + payload["all_spec_requirement_refs"].append("FR-001") + with self.assertRaisesRegex(ValueError, "visual-input projects unrelated"): + validate_ui_specification_contract(payload) + + def test_requirement_source_can_project_mixed_spec_and_ui_refs(self) -> None: + payload = minimal_ui_spec_contract() + payload["sources"][0]["role"] = "requirement-input" + payload["sources"][0]["projected_refs"].append("FR-001") + payload["all_spec_requirement_refs"].append("FR-001") + validate_ui_specification_contract(payload) + + def test_derivation_gap_and_conflict_states_remain_distinguishable(self) -> None: + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "derived" + with self.assertRaisesRegex(ValueError, "derived_from"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "derived" + requirement["derived_from"] = ["invented-observation"] + with self.assertRaisesRegex(ValueError, "does not cite an observation"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "assumed" + with self.assertRaisesRegex(ValueError, "documented default"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "conflicting" + requirement["status"] = "BLOCKED" + requirement["blocker"] = "UI_EVIDENCE_CONFLICT" + with self.assertRaisesRegex(ValueError, "needs two locators"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "unresolved" + with self.assertRaisesRegex(ValueError, "must remain BLOCKED"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + requirement = payload["requirements"][0] + requirement["derivation"] = "unresolved" + requirement["status"] = "BLOCKED" + requirement["blocker"] = "UI_EVIDENCE_MISSING" + requirement["evidence_locators"] = [] + del requirement["acceptance"] + validate_ui_specification_contract(payload) + + def test_state_viewport_evidence_and_outcome_ownership_are_structural(self) -> None: + payload = minimal_ui_spec_contract() + payload["requirements"][0]["evidence_support"].remove("viewport") + with self.assertRaisesRegex(ValueError, "corresponding evidence for viewport"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["requirements"][0]["evidence_locators"] = ["missing.html#state"] + with self.assertRaisesRegex(ValueError, "not present in supplied source facts"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["requirements"][0]["outcome_only"] = False + with self.assertRaisesRegex(ValueError, "observable outcome, not implementation"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["requirements"][0]["framework_component"] = "RefundPanel" + with self.assertRaisesRegex( + ValueError, + "implementation/source-unsupported field", + ): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["sources"].append( + { + "ref": "SRC-UI-002", + "role": "visual-input", + "locator_or_description": "unsupplied design locator", + "bounded_scope": "refund success state", + "supplied_facts": [], + "projected_refs": [], + "status": "BLOCKED", + "blocker": "SRC_EVIDENCE_MISSING", + } + ) + validate_ui_specification_contract(payload) + + def test_restoration_dimensions_and_pixel_target_contract_are_closed(self) -> None: + payload = minimal_ui_spec_contract() + payload["restoration_dimensions"].pop() + with self.assertRaisesRegex(ValueError, "dimension matrix is incomplete"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + del payload["pixel_targets"][0]["rendering_context"] + with self.assertRaisesRegex(ValueError, "incomplete rendering_context"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_targets"][0]["baseline_locator"] = "missing.png#panel" + with self.assertRaisesRegex(ValueError, "not present in supplied source facts"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_targets"][0]["exception_refs"] = ["PEX-404"] + with self.assertRaisesRegex(ValueError, "unknown accepted exception"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_exceptions"][0]["source_refs"] = [] + with self.assertRaisesRegex(ValueError, "non-empty source_refs"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_targets"][0]["visual_dimensions"].pop("typography") + with self.assertRaisesRegex(ValueError, "incomplete visual_dimensions"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_targets"][0]["acceptance_envelope"]["kind"] = "perceptual" + with self.assertRaisesRegex(ValueError, "mismatches fidelity mode"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_targets"][0]["fidelity_mode"] = "structural-only" + with self.assertRaisesRegex(ValueError, "differs from its profile"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_profiles"][0]["exception_refs"] = [] + with self.assertRaisesRegex(ValueError, "outside its profile policy"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_profiles"] = [] + payload["pixel_targets"] = [] + payload["pixel_exceptions"] = [] + with self.assertRaisesRegex(ValueError, "stable specified or blocked profile"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["pixel_profiles"][0]["status"] = "BLOCKED" + payload["pixel_profiles"][0]["blocker"] = "PIXEL_BASELINE_MISSING" + del payload["pixel_profiles"][0]["fidelity_mode"] + del payload["pixel_profiles"][0]["exception_policy"] + payload["pixel_targets"][0]["status"] = "BLOCKED" + payload["pixel_targets"][0]["blocker"] = "PIXEL_BASELINE_MISSING" + for field in ( + "baseline_source_ref", + "baseline_locator", + "rendering_context", + "visual_dimensions", + "acceptance_envelope", + ): + del payload["pixel_targets"][0][field] + validate_ui_specification_contract(payload) + + def test_adaptation_policy_rejects_ambiguity_and_lower_priority_override( + self, + ) -> None: + payload = minimal_ui_spec_contract() + payload["adaptation_policies"][0]["target_platform"] = "Swift" + with self.assertRaisesRegex(ValueError, "not a target platform"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["adaptation_policies"][0]["mode"] = "hybrid" + with self.assertRaisesRegex(ValueError, "invalid adaptation mode"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["adaptation_policies"][0]["decisions"].pop() + with self.assertRaisesRegex(ValueError, "incomplete or duplicated"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + del payload["adaptation_policies"][0]["target_contexts"]["accessibility"] + with self.assertRaisesRegex(ValueError, "incomplete target contexts"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + adaptive_decision = next( + decision + for decision in payload["adaptation_policies"][0]["decisions"] + if decision["decision"] == "adapt" + ) + adaptive_decision["source_refs"] = [] + with self.assertRaisesRegex(ValueError, "must cite source evidence"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + content_decision = next( + decision + for decision in payload["adaptation_policies"][0]["decisions"] + if decision["dimension"] == "content-and-information-hierarchy" + ) + content_decision["decision"] = "adapt" + with self.assertRaisesRegex(ValueError, "fails to preserve"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + decision = payload["adaptation_policies"][0]["decisions"][0] + decision["hard_constraint_conflict"] = True + decision["decision"] = "preserve" + with self.assertRaisesRegex(ValueError, "override a hard constraint"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + decision = payload["adaptation_policies"][0]["decisions"][0] + decision["decision"] = "blocked" + decision["status"] = "BLOCKED" + decision["blocker"] = "TARGET_CONTEXT_CONFLICT" + with self.assertRaisesRegex(ValueError, "must be BLOCKED"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + payload["adaptation_policies"] = [] + with self.assertRaisesRegex(ValueError, "requires an adaptation policy"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + policy = payload["adaptation_policies"][0] + policy["mode"] = "visual-equivalent-native" + with self.assertRaisesRegex(ValueError, "override requires an explicit reason"): + validate_ui_specification_contract(payload) + + payload = minimal_ui_spec_contract() + policy = payload["adaptation_policies"][0] + policy["target_platform"] = "HTML/Web" + policy["mode"] = "framework-equivalent" + for policy_decision in policy["decisions"]: + policy_decision["decision"] = "preserve" + validate_ui_specification_contract(payload) + policy["decisions"][0]["decision"] = "adapt" + with self.assertRaisesRegex(ValueError, "framework-equivalent diverges"): + validate_ui_specification_contract(payload) + + class PlanContractTests(unittest.TestCase): def test_plan_nests_x0_x4_in_core_without_new_cross_command_gate(self) -> None: command = read(COMMANDS / "speckit.plan.md") @@ -483,6 +1161,17 @@ def test_plan_has_deterministic_gates_reconciliation_and_resume(self) -> None: "context_gaps", ): self.assertIn(work_unit_field, command) + for issue_46_term in ( + "Consumed Spec SHA-256", + "PLAN_SPEC_INPUT_STALE", + "reference-only Spec UI Input Inventory", + "general UI mappings", + "pixel-target mappings", + "platform-adaptation mappings", + "every applicable `PXT-*` and `ADP-*`", + "never relabeled N/A", + ): + self.assertIn(issue_46_term, command) def test_plan_conditional_decision_table_covers_contextual_outputs(self) -> None: command = read(COMMANDS / "speckit.plan.md") @@ -497,6 +1186,24 @@ def test_plan_conditional_decision_table_covers_contextual_outputs(self) -> None ): self.assertIn(term, command) + def test_plan_x2b_uses_stable_ui_consumption_failure_codes(self) -> None: + command = read(COMMANDS / "speckit.plan.md") + validator = read(VALIDATORS / "speckit_plan_contract.py") + for code in ( + "PLAN_SPEC_INPUT_STALE", + "X2B_SPEC_REF_UNMAPPED", + "X2B_SPEC_REF_DUPLICATE", + "X2B_SPEC_REF_UNKNOWN", + "X2B_PIXEL_TARGET_UNMAPPED", + "X2B_PIXEL_EXCEPTION_UNRESOLVED", + "X2B_ADAPTATION_UNMAPPED", + "X2B_BLOCKER_SUPPRESSED", + "X2B_SPEC_OWNERSHIP_LEAK", + "X2B_DELIVERY_DECISION_INCOMPLETE", + ): + self.assertIn(code, command) + self.assertIn(code, validator) + def test_plan_control_template_has_lanes_navigation_and_closeout(self) -> None: template = read(TEMPLATES / "plan-template.md") for term in ( @@ -514,6 +1221,9 @@ def test_plan_control_template_has_lanes_navigation_and_closeout(self) -> None: self.assertIn(term, template) self.assertIn("Repository Topology", template) self.assertIn("No task IDs, exact per-task paths", template) + self.assertIn("Consumed Spec SHA-256", template) + self.assertIn("Current local Spec SHA-256", template) + self.assertIn("Invalidate affected X2-B", template) def test_plan_artifact_templates_have_non_overlapping_ownership(self) -> None: class_template = read(TEMPLATES / "class-diagram-template.md") @@ -528,6 +1238,20 @@ def test_plan_artifact_templates_have_non_overlapping_ownership(self) -> None: self.assertIn("Pixel delivery/review is owned here", ui_template) self.assertIn("SRC + UI/VIS refs", ui_template) self.assertIn("does not dereference", ui_template) + for issue_46_term in ( + "Consumed Spec SHA-256", + "Spec UI Input Inventory", + "General UI Delivery Mappings", + "Pixel-Target Delivery Mappings", + "Platform-Adaptation Delivery Mappings", + "X2B-UI-001", + "X2B-PX-001", + "X2B-ADP-001", + "does not copy requirement statements", + "must not repeat or weaken those values", + "Every required `X2B-*` mapping has exactly one closed row", + ): + self.assertIn(issue_46_term, ui_template) self.assertIn("### VAL-001", quickstart) self.assertIn("Cleanup/reset", quickstart) self.assertIn("Every required `TC-*` has exactly one row", readiness) @@ -613,6 +1337,225 @@ def test_plan_bundle_rejects_ref_drift_placeholder_and_false_ready(self) -> None with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_READY is inconsistent"): validate_plan_artifact_bundle(bundle) + def test_plan_bundle_rejects_stale_spec_and_generic_mapping_gaps(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["spec_input"]["current_sha256"] = ( + "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ) + with self.assertRaisesRegex(ValueError, "PLAN_SPEC_INPUT_STALE"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["x2b_delivery_mappings"][0]["spec_refs"].remove("UI-001") + with self.assertRaisesRegex(ValueError, "X2B_SPEC_REF_UNMAPPED"): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_rejects_pixel_and_adaptation_mapping_gaps(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + pixel_mapping = next( + mapping + for mapping in bundle["x2b_delivery_mappings"] + if mapping["kind"] == "pixel-target" + ) + pixel_mapping["spec_refs"].remove("PXT-001") + with self.assertRaisesRegex(ValueError, "X2B_PIXEL_TARGET_UNMAPPED"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + pixel_mapping = next( + mapping + for mapping in bundle["x2b_delivery_mappings"] + if mapping["kind"] == "pixel-target" + ) + pixel_mapping["spec_refs"].remove("PEX-001") + with self.assertRaisesRegex(ValueError, "X2B_PIXEL_EXCEPTION_UNRESOLVED"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["x2b_delivery_mappings"] = [ + mapping + for mapping in bundle["x2b_delivery_mappings"] + if mapping["id"] != "X2B-ADP-002" + ] + bundle["uiux_readiness_rows"] = [ + row + for row in bundle["uiux_readiness_rows"] + if row["mapping_ref"] != "X2B-ADP-002" + ] + with self.assertRaisesRegex(ValueError, "X2B_ADAPTATION_UNMAPPED"): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_rejects_duplicate_unknown_and_incomplete_x2b_rows( + self, + ) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["x2b_input_inventory"].append( + dict(bundle["x2b_input_inventory"][0]) + ) + with self.assertRaisesRegex(ValueError, "X2B_SPEC_REF_DUPLICATE"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + orphan = dict(bundle["x2b_delivery_mappings"][0]) + orphan["id"] = "X2B-UI-ORPHAN-001" + orphan["spec_refs"] = ["UI-404"] + bundle["x2b_delivery_mappings"].append(orphan) + with self.assertRaisesRegex(ValueError, "X2B_SPEC_REF_UNKNOWN"): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + del bundle["x2b_delivery_mappings"][0]["component_delivery"] + with self.assertRaisesRegex( + ValueError, + "X2B_DELIVERY_DECISION_INCOMPLETE", + ): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["uiux_readiness_rows"].pop() + with self.assertRaisesRegex( + ValueError, + "X2B_DELIVERY_DECISION_INCOMPLETE", + ): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_propagates_spec_blockers_and_rejects_ownership_leaks( + self, + ) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["spec_input"]["ui_contract_refs"][0]["status"] = "BLOCKED" + bundle["spec_input"]["ui_contract_refs"][0]["blocker"] = "UI-SRC-BLOCKED" + with self.assertRaisesRegex(ValueError, "X2B_BLOCKER_SUPPRESSED"): + validate_plan_artifact_bundle(bundle) + + for forbidden_field in ( + "statement", + "acceptance", + "fidelity_mode", + "acceptance_envelope", + "baseline_locator", + "bound", + "exception_bound", + "decisions", + "adaptation_decision", + ): + with self.subTest(field=forbidden_field): + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + bundle["x2b_delivery_mappings"][0][forbidden_field] = "copied" + with self.assertRaisesRegex( + ValueError, + "X2B_SPEC_OWNERSHIP_LEAK", + ): + validate_plan_artifact_bundle(bundle) + + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + pixel_mapping = next( + mapping + for mapping in bundle["x2b_delivery_mappings"] + if mapping["kind"] == "pixel-target" + ) + pixel_mapping["delivery_mapping"] = { + "layout_delivery": "component-owned", + "acceptance_envelope": {"kind": "copied"}, + } + with self.assertRaisesRegex(ValueError, "X2B_SPEC_OWNERSHIP_LEAK"): + validate_plan_artifact_bundle(bundle) + + def test_plan_x2b_mapping_kind_requires_matching_id_prefix(self) -> None: + cases = ( + ("X2B-UI-001", "X2B-PX-999"), + ("X2B-PX-001", "X2B-UI-999"), + ("X2B-ADP-001", "X2B-UI-998"), + ) + for old_id, mismatched_id in cases: + with self.subTest(old_id=old_id, mismatched_id=mismatched_id): + bundle = replace_string_values( + load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json"), + old_id, + mismatched_id, + ) + with self.assertRaisesRegex( + ValueError, + "X2B_DELIVERY_DECISION_INCOMPLETE", + ): + validate_plan_artifact_bundle(bundle) + + def test_plan_bundle_accepts_same_stable_spec_blocker_propagation(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + blocker = "BLK-SPEC-UI-001" + bundle["spec_input"]["ui_contract_refs"][0]["status"] = "BLOCKED" + bundle["spec_input"]["ui_contract_refs"][0]["blocker"] = blocker + inventory_row = next( + row + for row in bundle["x2b_input_inventory"] + if row["spec_ref"] == "UI-001" + ) + inventory_row["spec_status"] = "BLOCKED" + inventory_row["x2b_applicability"] = "Blocked" + inventory_row["propagated_blocker"] = blocker + del inventory_row["mapping_ref"] + general_mapping = next( + mapping + for mapping in bundle["x2b_delivery_mappings"] + if mapping["id"] == "X2B-UI-001" + ) + general_mapping["spec_refs"].remove("UI-001") + for mapping in bundle["x2b_delivery_mappings"]: + if "ui_vis_refs" in mapping: + mapping["ui_vis_refs"] = ["VIS-001"] + bundle["lanes"]["X2-B"] = {"status": "Blocked", "blocker": blocker} + bundle["gates"]["X2B_UIUX_READY"] = { + "status": "BLOCKED", + "evidence": [blocker], + "blockers": [blocker], + } + bundle["reconciliation"]["blocker_owners"] = {blocker: "X2-B"} + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_blocked_x2b_mapping_blocks_gate_and_plan_output(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "ui_only.json") + blocker = "BLK-X2B-DELIVERY-001" + mapping = bundle["x2b_delivery_mappings"][0] + mapping["status"] = "BLOCKED" + mapping["blocker"] = blocker + readiness = next( + row + for row in bundle["uiux_readiness_rows"] + if row["mapping_ref"] == mapping["id"] + ) + readiness.clear() + readiness.update( + { + "mapping_ref": mapping["id"], + "status": "BLOCKED", + "blocker": blocker, + } + ) + bundle["lanes"]["X2-B"] = {"status": "Blocked", "blocker": blocker} + bundle["gates"]["X2B_UIUX_READY"] = { + "status": "BLOCKED", + "evidence": [blocker], + "blockers": [blocker], + } + bundle["reconciliation"]["blocker_owners"] = {blocker: "X2-B"} + bundle["plan_output_ready"] = "BLOCKED" + validate_plan_artifact_bundle(bundle) + + def test_non_ui_bundle_closes_x2b_with_scoped_spec_evidence(self) -> None: + bundle = load_json(PLAN_BUNDLE_FIXTURES / "minimal_repository.json") + self.assertTrue(bundle["spec_input"]["non_ui_evidence"]) + self.assertEqual([], bundle["spec_input"]["ui_contract_refs"]) + self.assertEqual("N/A", bundle["gates"]["X2B_UIUX_READY"]["status"]) + validate_plan_artifact_bundle(bundle) + + del bundle["spec_input"]["non_ui_evidence"]["reason"] + with self.assertRaisesRegex( + ValueError, + "X2B_DELIVERY_DECISION_INCOMPLETE", + ): + validate_plan_artifact_bundle(bundle) + def test_plan_bundle_validates_test_readiness_rows(self) -> None: bundle = load_json(PLAN_BUNDLE_FIXTURES / "non_ui_single_interface.json") bundle["test_readiness_rows"] = [] @@ -988,6 +1931,249 @@ def test_tasks_forbids_pixel_work_and_keeps_final_review_last(self) -> None: self.assertIn("no phase may follow it", command) self.assertIn("MUST NOT\njudge rendered visual fidelity", command) + def test_tasks_x2b_complete_bundle_maps_all_classes_once(self) -> None: + bundle = minimal_tasks_x2b_bundle() + validate_tasks_x2b_derivation(bundle) + task_refs = [ + mapping_ref + for task in bundle["tasks"] + for mapping_ref in task["mapping_refs"] + ] + self.assertEqual(1, task_refs.count("X2B-UI-001")) + self.assertEqual(2, task_refs.count("X2B-PX-001")) + self.assertEqual(1, task_refs.count("X2B-ADP-001")) + self.assertNotIn("X2B-PX-REVIEW-001", task_refs) + self.assertNotIn("X2B-ADP-NA-001", task_refs) + + def test_tasks_x2b_preflight_rejects_stale_handoff(self) -> None: + bundle = minimal_tasks_x2b_bundle() + bundle["tasks_handoff_revision"] = "PLAN-46" + with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_INCOMPLETE"): + validate_tasks_x2b_derivation(bundle) + + def test_tasks_x2b_required_mapping_must_have_concrete_tasks(self) -> None: + bundle = minimal_tasks_x2b_bundle() + bundle["tasks"] = [ + task for task in bundle["tasks"] if task["id"] != "T043" + ] + with self.assertRaisesRegex(ValueError, "TASK_X2B_MAPPING_UNMAPPED"): + validate_tasks_x2b_derivation(bundle) + + def test_tasks_x2b_duplicate_mapping_and_unknown_ref_are_stable(self) -> None: + duplicate = minimal_tasks_x2b_bundle() + duplicate["x2b_mappings"].append( + deepcopy(duplicate["x2b_mappings"][0]) + ) + with self.assertRaisesRegex(ValueError, "TASK_X2B_MAPPING_DUPLICATE"): + validate_tasks_x2b_derivation(duplicate) + + unknown = minimal_tasks_x2b_bundle() + unknown["tasks"][0]["mapping_refs"] = ["X2B-UI-404"] + with self.assertRaisesRegex(ValueError, "TASK_X2B_REF_UNKNOWN"): + validate_tasks_x2b_derivation(unknown) + + def test_tasks_x2b_blocker_never_becomes_normal_task(self) -> None: + bundle = minimal_tasks_x2b_bundle() + bundle["x2b_mappings"].append( + { + "id": "X2B-UI-BLOCKED-001", + "status": "Blocked", + "implementation_dimensions": [], + "depends_on": [], + "traceability_refs": ["UI-001"], + "blocker": "BLOCK-UI-001", + } + ) + with self.assertRaisesRegex(ValueError, "PLAN_OUTPUT_INCOMPLETE"): + validate_tasks_x2b_derivation(bundle) + + suppressed = minimal_tasks_x2b_bundle() + suppressed["x2b_mappings"].append( + { + "id": "X2B-UI-BLOCKED-001", + "status": "Blocked", + "implementation_dimensions": [], + "depends_on": [], + "traceability_refs": ["UI-001"], + "blocker": "BLOCK-UI-001", + } + ) + task = deepcopy(suppressed["tasks"][0]) + task["id"] = "T045" + task["paths"] = ["src/ui/BlockedPanel.tsx"] + task["mapping_refs"] = ["X2B-UI-BLOCKED-001"] + suppressed["tasks"].append(task) + with self.assertRaisesRegex(ValueError, "TASK_X2B_BLOCKER_SUPPRESSED"): + validate_tasks_x2b_derivation(suppressed) + + def test_tasks_x2b_requires_full_pixel_and_adaptation_dimensions(self) -> None: + pixel = minimal_tasks_x2b_bundle() + pixel["tasks"][2]["implementation_dimensions"].remove("typography") + with self.assertRaisesRegex( + ValueError, + "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED", + ): + validate_tasks_x2b_derivation(pixel) + + adaptation = minimal_tasks_x2b_bundle() + adaptation["tasks"][3]["implementation_dimensions"].remove( + "localization" + ) + with self.assertRaisesRegex(ValueError, "TASK_X2B_ADAPTATION_UNCOVERED"): + validate_tasks_x2b_derivation(adaptation) + + def test_tasks_x2b_derives_dependencies_without_fixed_lane_order(self) -> None: + missing_asset_edge = minimal_tasks_x2b_bundle() + missing_asset_edge["tasks"][2]["depends_on"].remove("T041") + with self.assertRaisesRegex(ValueError, "TASK_X2B_MAPPING_UNMAPPED"): + validate_tasks_x2b_derivation(missing_asset_edge) + + independent_platform = minimal_tasks_x2b_bundle() + independent_platform["x2b_mappings"][2]["depends_on"] = [] + independent_platform["tasks"][3]["depends_on"] = [] + independent_platform["tasks"][3]["parallel"] = True + validate_tasks_x2b_derivation(independent_platform) + + transitive = minimal_tasks_x2b_bundle() + transitive["x2b_mappings"][1]["depends_on"] = [] + transitive["tasks"][2]["depends_on"] = ["T041"] + validate_tasks_x2b_derivation(transitive) + + cyclic = minimal_tasks_x2b_bundle() + cyclic["tasks"][0]["depends_on"] = ["T042"] + with self.assertRaisesRegex(ValueError, "TASK_X2B_MAPPING_UNMAPPED"): + validate_tasks_x2b_derivation(cyclic) + + dependent_parallel = minimal_tasks_x2b_bundle() + dependent_parallel["tasks"][2]["parallel"] = True + with self.assertRaisesRegex(ValueError, "TASK_X2B_MAPPING_UNMAPPED"): + validate_tasks_x2b_derivation(dependent_parallel) + + def test_tasks_x2b_rejects_spec_ownership_and_visual_execution(self) -> None: + for owned_field in ( + "statement", + "baseline_identity", + "baseline_source_ref", + "baseline_locator", + "rendering_context", + "fidelity_mode", + "acceptance", + "acceptance_envelope", + "bound", + "exception_bound", + "adaptation_decisions", + "adaptation_decision", + ): + with self.subTest(owned_field=owned_field): + bundle = minimal_tasks_x2b_bundle() + bundle["tasks"][0][owned_field] = "copied from Spec" + with self.assertRaisesRegex( + ValueError, + "TASK_SPEC_OWNERSHIP_LEAK", + ): + validate_tasks_x2b_derivation(bundle) + + for forbidden in ( + "screenshot capture", + "capture screenshots", + "take screenshots", + "generate a baseline", + "produce the baseline", + "evaluate the acceptance threshold", + "compare pixels", + "run visual-diff", + "pixel comparison", + "perceptual comparison", + "visual acceptance", + "final rendered visual review", + ): + with self.subTest(forbidden=forbidden): + bundle = minimal_tasks_x2b_bundle() + bundle["tasks"][0]["description"] = forbidden + with self.assertRaisesRegex( + ValueError, + "TASK_VISUAL_EXECUTION_LEAK", + ): + validate_tasks_x2b_derivation(bundle) + + bundle = minimal_tasks_x2b_bundle() + bundle["tasks"][0]["description"] = ( + "Use pixel-tolerant fidelity semantics." + ) + with self.assertRaisesRegex(ValueError, "TASK_SPEC_OWNERSHIP_LEAK"): + validate_tasks_x2b_derivation(bundle) + + bundle = minimal_tasks_x2b_bundle() + bundle["tasks"][0]["description"] = "Prepare the component." + bundle["tasks"][0]["action_classes"] = ["visual-execution"] + with self.assertRaisesRegex(ValueError, "TASK_VISUAL_EXECUTION_LEAK"): + validate_tasks_x2b_derivation(bundle) + + def test_tasks_tests_are_derived_only_from_required_test_readiness(self) -> None: + bundle = minimal_tasks_x2b_bundle() + bundle["test_tasks"][0]["tc_refs"] = ["TC-VISUAL-INVENTED"] + with self.assertRaisesRegex(ValueError, "TASK_SPEC_OWNERSHIP_LEAK"): + validate_tasks_x2b_derivation(bundle) + + bundle = minimal_tasks_x2b_bundle() + bundle["test_tasks"][0]["action_classes"] = ["visual-execution"] + with self.assertRaisesRegex(ValueError, "TASK_VISUAL_EXECUTION_LEAK"): + validate_tasks_x2b_derivation(bundle) + + def test_tasks_x2b_rejects_orphan_mapping_traceability(self) -> None: + bundle = minimal_tasks_x2b_bundle() + bundle["x2b_mappings"][0]["traceability_refs"] = [] + with self.assertRaisesRegex(ValueError, "TASK_X2B_REF_UNKNOWN"): + validate_tasks_x2b_derivation(bundle) + + def test_tasks_final_review_covers_x2b_and_stays_last(self) -> None: + missing_mapping = minimal_tasks_x2b_bundle() + missing_mapping["final_review"]["mapping_refs"].remove("X2B-PX-001") + with self.assertRaisesRegex( + ValueError, + "TASK_FINAL_REVIEW_MAPPING_MISSING", + ): + validate_tasks_x2b_derivation(missing_mapping) + + not_last = minimal_tasks_x2b_bundle() + not_last["phases"].append("Visual Acceptance") + with self.assertRaisesRegex( + ValueError, + "TASK_FINAL_REVIEW_MAPPING_MISSING", + ): + validate_tasks_x2b_derivation(not_last) + + visual_review = minimal_tasks_x2b_bundle() + visual_review["final_review"]["description"] = "Run screenshot diff" + with self.assertRaisesRegex(ValueError, "TASK_VISUAL_EXECUTION_LEAK"): + validate_tasks_x2b_derivation(visual_review) + + def test_tasks_non_ui_handoff_remains_valid_without_x2b(self) -> None: + bundle = { + "plan_output_ready": "READY", + "current_plan_revision": "PLAN-NON-UI", + "tasks_handoff_revision": "PLAN-NON-UI", + "uiux_delivery_readiness": "N/A", + "declared_traceability_refs": [], + "x2b_mappings": [], + "tasks": [], + "required_test_readiness_tc_refs": [], + "test_tasks": [], + "phases": ["Service implementation", "Final Code Review"], + "final_review": { + "phase": "Final Code Review", + "kind": "code-design-contract-review", + "action_classes": ["code-design-contract-review"], + "mapping_refs": [], + "scopes": [ + "implementation-conformance", + ], + "paths": ["src/service/refund.py"], + "description": "Review non-UI code/design-contract conformance.", + }, + } + validate_tasks_x2b_derivation(bundle) + def test_analyze_is_read_only_and_owns_all_cross_command_chains(self) -> None: command = read(COMMANDS / "speckit.analyze.md") for term in ( @@ -1022,7 +2208,8 @@ def test_source_audit_reports_role_slice_and_projection_failures(self) -> None: "role": "provider-input", "locator_or_description": "provider-specific packet", "provider_node_id": "node-42", - "authorized_scope": "refund feature", + "bounded_scope": "refund feature", + "supplied_facts": ["provider packet content"], "projected_refs": [], "status": "retained", }, @@ -1030,7 +2217,8 @@ def test_source_audit_reports_role_slice_and_projection_failures(self) -> None: "ref": "SRC-007", "role": "context-only", "locator_or_description": "background note", - "authorized_scope": "background only", + "bounded_scope": "background only", + "supplied_facts": ["background note"], "projected_refs": ["FR-001"], "status": "projected", }, @@ -1038,7 +2226,8 @@ def test_source_audit_reports_role_slice_and_projection_failures(self) -> None: "ref": "SRC-008", "role": "requirement-input", "locator_or_description": "broad roadmap", - "authorized_scope": "entire roadmap", + "bounded_scope": "entire roadmap", + "supplied_facts": ["broad roadmap content"], "broad": True, "projected_refs": [], "status": "projected", @@ -1085,6 +2274,44 @@ def test_source_audit_reports_local_integrity_and_x2b_uif_gaps(self) -> None: }.issubset(codes) ) + def test_source_audit_blocks_locator_only_projection(self) -> None: + snapshot = source_contract_snapshot() + snapshot["spec"]["sources"][2]["supplied_facts"] = [] + codes = { + finding["code"] + for finding in audit_source_reference_contract(snapshot) + } + self.assertIn("SRC_EVIDENCE_MISSING", codes) + + def test_source_audit_accepts_stable_blocker_and_rejects_missing_blocker( + self, + ) -> None: + snapshot = source_contract_snapshot() + source = snapshot["spec"]["sources"][0] + source["supplied_facts"] = [] + source["projected_refs"] = [] + source["status"] = "BLOCKED" + source["blocker"] = "SRC_EVIDENCE_MISSING" + findings = audit_source_reference_contract(snapshot) + self.assertNotIn( + "SRC_FIELD_INVALID", + {finding["code"] for finding in findings}, + ) + self.assertFalse( + any( + finding["source"] == "spec.md:SRC-001" + for finding in findings + ) + ) + + del source["blocker"] + codes = { + finding["code"] + for finding in audit_source_reference_contract(snapshot) + if finding["source"] == "spec.md:SRC-001" + } + self.assertIn("SRC_BLOCKER_MISSING", codes) + def test_audit_reports_deterministic_vertical_breaks(self) -> None: snapshot = { "architecture": { diff --git a/presets/workflow-preset/validators/speckit_analyze_contract.py b/presets/workflow-preset/validators/speckit_analyze_contract.py index c2cd1e9ba9..1bee74b310 100644 --- a/presets/workflow-preset/validators/speckit_analyze_contract.py +++ b/presets/workflow-preset/validators/speckit_analyze_contract.py @@ -35,9 +35,11 @@ "role", "locator_or_description", "revision", - "authorized_scope", + "bounded_scope", + "supplied_facts", "projected_refs", "status", + "blocker", # In-memory audit hints derived from row prose and downstream applicability. "broad", "feature_slice", @@ -188,17 +190,30 @@ def audit_source_reference_contract( ) ) - if not source.get("authorized_scope"): + if not source.get("bounded_scope"): findings.append( _finding( - "SRC_AUTHORIZED_SCOPE_MISSING", + "SRC_BOUNDED_SCOPE_MISSING", source=f"spec.md:{ref}", - target="spec.md:authorized scope / facts", + target="spec.md:bounded feature scope", evidence="missing explicit feature scope", owner="speckit.specify", ) ) + supplied_facts = source.get("supplied_facts") + if not isinstance(supplied_facts, list): + findings.append( + _finding( + "SRC_SUPPLIED_FACTS_INVALID", + source=f"spec.md:{ref}", + target="spec.md:supplied content / facts", + evidence="supplied_facts must be a list", + owner="speckit.specify", + ) + ) + supplied_facts = [] + if not source.get("status"): findings.append( _finding( @@ -211,6 +226,54 @@ def audit_source_reference_contract( ) status = str(source.get("status", "")).lower() + blocker = source.get("blocker") + if "block" in status and ( + not isinstance(blocker, str) or not blocker.strip() + ): + findings.append( + _finding( + "SRC_BLOCKER_MISSING", + source=f"spec.md:{ref}", + target="spec.md:status / blocker", + evidence="BLOCKED source lacks a stable blocker", + owner="speckit.specify", + ) + ) + elif blocker and "block" not in status: + findings.append( + _finding( + "SRC_STATUS_MISSING", + source=f"spec.md:{ref}", + target="spec.md:status / blocker", + evidence="source blocker requires BLOCKED status", + owner="speckit.specify", + ) + ) + if projected_refs and not supplied_facts: + findings.append( + _finding( + "SRC_EVIDENCE_MISSING", + source=f"spec.md:{ref}", + target="local requirement projection", + evidence="locator-only input cannot support projected requirements", + owner="speckit.specify", + ) + ) + elif ( + not supplied_facts + and "block" not in status + and "evidence_missing" not in status + ): + findings.append( + _finding( + "SRC_EVIDENCE_MISSING", + source=f"spec.md:{ref}", + target="spec.md:status / blocker", + evidence="locator-only input lacks SRC_EVIDENCE_MISSING blocker", + owner="speckit.specify", + ) + ) + if "contradict" in status: findings.append( _finding( @@ -232,7 +295,7 @@ def audit_source_reference_contract( _finding( "SRC_FEATURE_SLICE_MISSING", source=f"spec.md:{ref}", - target="spec.md:authorized scope / facts", + target="spec.md:bounded feature scope", evidence="broad source projected without safe feature slice", owner="speckit.specify", ) diff --git a/presets/workflow-preset/validators/speckit_plan_contract.py b/presets/workflow-preset/validators/speckit_plan_contract.py index 34b4a06404..3eb869dc7d 100644 --- a/presets/workflow-preset/validators/speckit_plan_contract.py +++ b/presets/workflow-preset/validators/speckit_plan_contract.py @@ -1,6 +1,7 @@ """Pure in-memory semantic checks for representative Plan artifact bundles.""" from __future__ import annotations +import re from typing import Any, Iterable from validators.speckit_test_contract import ( @@ -18,8 +19,54 @@ "X2_RECONCILIATION_READY", "X3_VALIDATION_PATHS_READY", ) -INTERNAL_REF_PREFIXES = ("DEC-", "OBJ-", "IF-", "SEQ-", "UIF-", "TC-", "VAL-") +INTERNAL_REF_PREFIXES = ( + "DEC-", + "OBJ-", + "IF-", + "SEQ-", + "UIF-", + "X2B-", + "TC-", + "VAL-", +) PLACEHOLDERS = ("[placeholder]", "", "TODO", "TBD") +SPEC_UI_PREFIX_TO_CLASS = { + "UI-": "UI", + "VIS-": "VIS", + "RST-": "RST", + "PXR-": "PXR", + "PXT-": "PXT", + "PEX-": "PEX", + "ADP-": "ADP", +} +X2B_MAPPING_KINDS = { + "general-ui", + "pixel-target", + "platform-adaptation", +} +X2B_KIND_ID_PREFIXES = { + "general-ui": "X2B-UI-", + "pixel-target": "X2B-PX-", + "platform-adaptation": "X2B-ADP-", +} +SPEC_OWNED_MAPPING_FIELDS = { + "statement", + "requirement_statement", + "acceptance", + "baseline_identity", + "baseline_source_ref", + "baseline_locator", + "viewport", + "state", + "rendering_context", + "fidelity_mode", + "acceptance_envelope", + "bound", + "exception_bound", + "decisions", + "adaptation_decision", +} +SHA256_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") def _duplicates(values: Iterable[str]) -> set[str]: @@ -43,6 +90,534 @@ def _strings(value: Any) -> Iterable[str]: yield value +def _fail(code: str, detail: str) -> None: + raise ValueError(f"{code}: {detail}") + + +def _spec_contract_class(ref: str) -> str | None: + for prefix, contract_class in SPEC_UI_PREFIX_TO_CLASS.items(): + if ref.startswith(prefix): + return contract_class + return None + + +def _mapping_ownership_leaks(value: Any) -> set[str]: + if isinstance(value, dict): + leaks = set(value) & SPEC_OWNED_MAPPING_FIELDS + for child in value.values(): + leaks.update(_mapping_ownership_leaks(child)) + return leaks + if isinstance(value, list): + leaks: set[str] = set() + for child in value: + leaks.update(_mapping_ownership_leaks(child)) + return leaks + return set() + + +def _require_non_empty_list( + item: dict[str, Any], + field: str, + context: str, + *, + code: str = "X2B_DELIVERY_DECISION_INCOMPLETE", +) -> list[Any]: + value = item.get(field) + if not isinstance(value, list) or not value: + _fail(code, f"{context} missing non-empty {field}") + return value + + +def _validate_spec_freshness( + bundle: dict[str, Any], + *, + x2b_status: str, +) -> dict[str, Any]: + spec_input = bundle.get("spec_input") + if not isinstance(spec_input, dict): + _fail("PLAN_SPEC_INPUT_STALE", "Plan bundle missing local Spec input evidence") + digest_fields = ("current_sha256", "recorded_x0_sha256") + digests = [spec_input.get(field) for field in digest_fields] + if any(not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest) for digest in digests): + _fail( + "PLAN_SPEC_INPUT_STALE", + "Spec freshness evidence must use sha256:<64 lowercase hex>", + ) + if len(set(digests)) != 1: + _fail( + "PLAN_SPEC_INPUT_STALE", + "current Spec digest differs from recorded X0 or UI/UX digest", + ) + uiux_digest = spec_input.get("recorded_uiux_sha256") + if x2b_status == "N/A": + if uiux_digest != "N/A": + _fail( + "PLAN_SPEC_INPUT_STALE", + "inactive X2-B must record UI/UX Spec digest as N/A", + ) + elif ( + not isinstance(uiux_digest, str) + or not SHA256_PATTERN.fullmatch(uiux_digest) + or uiux_digest != digests[0] + ): + _fail( + "PLAN_SPEC_INPUT_STALE", + "current Spec digest differs from recorded UI/UX digest", + ) + return spec_input + + +def _validate_x2b_contract( + bundle: dict[str, Any], + *, + x2b_status: str, + decision_ids: set[str], + uif_ids: set[str], + known_internal_ids: set[str], + asset_ids: set[str], +) -> set[str]: + spec_input = _validate_spec_freshness(bundle, x2b_status=x2b_status) + spec_refs = spec_input.get("ui_contract_refs") + inventory = bundle.get("x2b_input_inventory") + mappings = bundle.get("x2b_delivery_mappings") + readiness_rows = bundle.get("uiux_readiness_rows") + for name, value in ( + ("spec_input.ui_contract_refs", spec_refs), + ("x2b_input_inventory", inventory), + ("x2b_delivery_mappings", mappings), + ("uiux_readiness_rows", readiness_rows), + ): + if not isinstance(value, list): + _fail("X2B_DELIVERY_DECISION_INCOMPLETE", f"{name} must be a list") + + if x2b_status == "N/A": + if spec_refs or inventory or mappings or readiness_rows: + _fail( + "X2B_SPEC_REF_UNMAPPED", + "N/A X2-B must have no applicable UI Spec refs or delivery mappings", + ) + non_ui_evidence = spec_input.get("non_ui_evidence") + if ( + not isinstance(non_ui_evidence, dict) + or not non_ui_evidence.get("spec_scope_ref") + or not non_ui_evidence.get("reason") + ): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + "N/A X2-B requires Spec scope ref and concrete non-UI reason", + ) + return set() + + ref_values = [str(record.get("ref", "")) for record in spec_refs] + if any(not ref or _spec_contract_class(ref) is None for ref in ref_values): + _fail("X2B_SPEC_REF_UNKNOWN", "Spec UI input contains an unknown ref class") + duplicates = _duplicates(ref_values) + if duplicates: + _fail( + "X2B_SPEC_REF_DUPLICATE", + f"Spec UI input duplicates {sorted(duplicates)[0]}", + ) + + spec_by_ref = dict(zip(ref_values, spec_refs)) + all_source_refs: set[str] = set() + blocked_spec_refs: dict[str, str] = {} + for ref, record in spec_by_ref.items(): + if record.get("contract_class") != _spec_contract_class(ref): + _fail("X2B_SPEC_REF_UNKNOWN", f"{ref} has mismatched contract class") + source_refs = _require_non_empty_list( + record, + "source_refs", + f"Spec UI input {ref}", + ) + if any(not str(source_ref).startswith("SRC-") for source_ref in source_refs): + _fail("X2B_SPEC_REF_UNKNOWN", f"{ref} has invalid SRC ref") + all_source_refs.update(map(str, source_refs)) + status = record.get("status") + if status == "BLOCKED": + blocker = record.get("blocker") + if not isinstance(blocker, str) or not blocker: + _fail("X2B_BLOCKER_SUPPRESSED", f"{ref} lacks its upstream blocker") + blocked_spec_refs[ref] = blocker + elif status != "specified": + _fail("X2B_SPEC_REF_UNKNOWN", f"{ref} has invalid Spec status") + if blocked_spec_refs and x2b_status != "Blocked": + _fail( + "X2B_BLOCKER_SUPPRESSED", + "blocked Spec UI applicability requires blocked X2-B", + ) + + inventory_refs = [str(row.get("spec_ref", "")) for row in inventory] + inventory_duplicates = _duplicates(inventory_refs) + if inventory_duplicates: + _fail( + "X2B_SPEC_REF_DUPLICATE", + f"X2-B input inventory duplicates {sorted(inventory_duplicates)[0]}", + ) + unknown_inventory = set(inventory_refs) - set(spec_by_ref) + if unknown_inventory: + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"X2-B inventory contains unknown {sorted(unknown_inventory)[0]}", + ) + missing_inventory = set(spec_by_ref) - set(inventory_refs) + if missing_inventory: + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"X2-B inventory omits {sorted(missing_inventory)[0]}", + ) + inventory_by_ref = dict(zip(inventory_refs, inventory)) + + for ref, spec_record in spec_by_ref.items(): + row = inventory_by_ref[ref] + if row.get("contract_class") != spec_record.get("contract_class"): + _fail("X2B_SPEC_REF_UNKNOWN", f"{ref} inventory class differs from Spec") + if row.get("spec_status") != spec_record.get("status"): + _fail("X2B_BLOCKER_SUPPRESSED", f"{ref} inventory hides Spec status") + if ref in blocked_spec_refs: + if ( + row.get("x2b_applicability") != "Blocked" + or row.get("propagated_blocker") != blocked_spec_refs[ref] + or row.get("mapping_ref") + ): + _fail( + "X2B_BLOCKER_SUPPRESSED", + f"{ref} does not propagate {blocked_spec_refs[ref]}", + ) + elif ( + row.get("x2b_applicability") != "Required" + or not row.get("mapping_ref") + or row.get("propagated_blocker") + ): + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"{ref} lacks one required X2B mapping ref", + ) + + mapping_ids = [str(mapping.get("id", "")) for mapping in mappings] + if any(not mapping_id.startswith("X2B-") for mapping_id in mapping_ids): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + "delivery mapping ids must use X2B-*", + ) + mapping_duplicates = _duplicates(mapping_ids) + if mapping_duplicates: + _fail( + "X2B_SPEC_REF_DUPLICATE", + f"delivery mappings duplicate {sorted(mapping_duplicates)[0]}", + ) + mappings_by_id = dict(zip(mapping_ids, mappings)) + covered_by: dict[str, list[str]] = {ref: [] for ref in spec_by_ref} + + for mapping_id, mapping in mappings_by_id.items(): + forbidden = sorted(_mapping_ownership_leaks(mapping)) + if forbidden: + _fail( + "X2B_SPEC_OWNERSHIP_LEAK", + f"{mapping_id} duplicates Spec-owned {forbidden[0]}", + ) + kind = mapping.get("kind") + if kind not in X2B_MAPPING_KINDS: + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} has invalid mapping kind", + ) + if not mapping_id.startswith(X2B_KIND_ID_PREFIXES[str(kind)]): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} prefix does not match {kind}", + ) + mapped_refs = list( + map(str, _require_non_empty_list(mapping, "spec_refs", mapping_id)) + ) + unknown_refs = set(mapped_refs) - set(spec_by_ref) + if unknown_refs: + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"{mapping_id} references unknown {sorted(unknown_refs)[0]}", + ) + for ref in mapped_refs: + covered_by[ref].append(mapping_id) + if ref in blocked_spec_refs: + _fail( + "X2B_BLOCKER_SUPPRESSED", + f"{mapping_id} maps blocked Spec ref {ref}", + ) + + source_refs = set( + map(str, _require_non_empty_list(mapping, "source_refs", mapping_id)) + ) + if not source_refs.issubset(all_source_refs): + _fail("X2B_SPEC_REF_UNKNOWN", f"{mapping_id} has unknown SRC ref") + required_sources = { + str(source_ref) + for ref in mapped_refs + for source_ref in spec_by_ref[ref].get("source_refs", []) + } + if not required_sources.issubset(source_refs): + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"{mapping_id} omits a mapped Spec source ref", + ) + decision_refs = set( + map(str, _require_non_empty_list(mapping, "decision_refs", mapping_id)) + ) + if not decision_refs.issubset(decision_ids): + _fail("X2B_SPEC_REF_UNKNOWN", f"{mapping_id} has unknown DEC-UI ref") + if any(not ref.startswith("DEC-UI-") for ref in decision_refs): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} decision refs must use DEC-UI-*", + ) + for field in ("uif_refs", "interface_refs", "asset_refs"): + refs = mapping.get(field, []) + if not isinstance(refs, list): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} {field} must be a list", + ) + if field == "uif_refs" and not set(map(str, refs)).issubset(uif_ids): + _fail("X2B_SPEC_REF_UNKNOWN", f"{mapping_id} has unknown UIF ref") + if field == "interface_refs": + if any(not str(ref).startswith("IF-") for ref in refs) or not set( + map(str, refs) + ).issubset(known_internal_ids): + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"{mapping_id} has unknown interface ref", + ) + if field == "asset_refs" and not set(map(str, refs)).issubset(asset_ids): + _fail("X2B_SPEC_REF_UNKNOWN", f"{mapping_id} has unknown asset ref") + + if kind == "general-ui": + if any(_spec_contract_class(ref) not in {"UI", "VIS", "RST"} for ref in mapped_refs): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} general mapping has non-general Spec ref", + ) + for field in ( + "surface_region_binding", + "component_delivery", + "navigation_input_responsive_accessibility", + ): + if not mapping.get(field): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} missing {field}", + ) + elif kind == "pixel-target": + if any(_spec_contract_class(ref) not in {"PXR", "PXT", "PEX"} for ref in mapped_refs): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} pixel mapping has non-pixel Spec ref", + ) + pxr_ref = str(mapping.get("pxr_ref", "")) + pxt_ref = str(mapping.get("pxt_ref", "")) + pex_refs = list(map(str, mapping.get("pex_refs", []))) + if ( + _spec_contract_class(pxr_ref) != "PXR" + or _spec_contract_class(pxt_ref) != "PXT" + or pxr_ref not in spec_by_ref + or pxt_ref not in spec_by_ref + ): + _fail( + "X2B_PIXEL_TARGET_UNMAPPED", + f"{mapping_id} lacks resolvable PXR/PXT refs", + ) + if pxt_ref not in mapped_refs: + _fail( + "X2B_PIXEL_TARGET_UNMAPPED", + f"{mapping_id} does not own its PXT ref", + ) + if any(ref not in spec_by_ref or _spec_contract_class(ref) != "PEX" for ref in pex_refs): + _fail( + "X2B_PIXEL_EXCEPTION_UNRESOLVED", + f"{mapping_id} has unknown PEX ref", + ) + if any(ref not in mapped_refs for ref in pex_refs): + _fail( + "X2B_PIXEL_EXCEPTION_UNRESOLVED", + f"{mapping_id} does not bind every PEX ref", + ) + mapped_pex_refs = { + ref for ref in mapped_refs if _spec_contract_class(ref) == "PEX" + } + if set(pex_refs) != mapped_pex_refs: + _fail( + "X2B_PIXEL_EXCEPTION_UNRESOLVED", + f"{mapping_id} PEX bindings do not match mapped exceptions", + ) + ui_vis_refs = list( + map(str, _require_non_empty_list(mapping, "ui_vis_refs", mapping_id)) + ) + if any(ref not in spec_by_ref or _spec_contract_class(ref) not in {"UI", "VIS"} for ref in ui_vis_refs): + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"{mapping_id} has unknown UI/VIS ref", + ) + if any(ref in blocked_spec_refs for ref in ui_vis_refs): + _fail( + "X2B_BLOCKER_SUPPRESSED", + f"{mapping_id} consumes a blocked UI/VIS ref", + ) + ui_vis_sources = { + str(source_ref) + for ref in ui_vis_refs + for source_ref in spec_by_ref[ref].get("source_refs", []) + } + if not ui_vis_sources.issubset(source_refs): + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"{mapping_id} omits a UI/VIS source ref", + ) + for field in ( + "target_region_binding", + "delivery_mapping", + "local_delivery_review_method", + ): + if not mapping.get(field): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} missing {field}", + ) + else: + if len(mapped_refs) != 1 or _spec_contract_class(mapped_refs[0]) != "ADP": + _fail( + "X2B_ADAPTATION_UNMAPPED", + f"{mapping_id} must map one ADP policy/dimension ref", + ) + if mapping.get("adp_ref") != mapped_refs[0]: + _fail( + "X2B_ADAPTATION_UNMAPPED", + f"{mapping_id} ADP ref differs from its mapped Spec ref", + ) + ui_vis_refs = list( + map(str, _require_non_empty_list(mapping, "ui_vis_refs", mapping_id)) + ) + if any(ref not in spec_by_ref or _spec_contract_class(ref) not in {"UI", "VIS"} for ref in ui_vis_refs): + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"{mapping_id} has unknown UI/VIS ref", + ) + if any(ref in blocked_spec_refs for ref in ui_vis_refs): + _fail( + "X2B_BLOCKER_SUPPRESSED", + f"{mapping_id} consumes a blocked UI/VIS ref", + ) + ui_vis_sources = { + str(source_ref) + for ref in ui_vis_refs + for source_ref in spec_by_ref[ref].get("source_refs", []) + } + if not ui_vis_sources.issubset(source_refs): + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"{mapping_id} omits a UI/VIS source ref", + ) + for field in ("target_context_binding", "target_delivery_design"): + if not mapping.get(field): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} missing {field}", + ) + + status = mapping.get("status") + if status == "BLOCKED": + if not mapping.get("blocker"): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} BLOCKED without blocker", + ) + elif status != "READY": + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} has invalid delivery status", + ) + + if ( + any(mapping.get("status") == "BLOCKED" for mapping in mappings) + and x2b_status != "Blocked" + ): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + "a blocked delivery mapping requires blocked X2-B", + ) + + for ref, mapping_refs in covered_by.items(): + if ref in blocked_spec_refs: + if mapping_refs: + _fail("X2B_BLOCKER_SUPPRESSED", f"{ref} blocker was mapped as delivery") + continue + if not mapping_refs: + contract_class = _spec_contract_class(ref) + code = { + "PXT": "X2B_PIXEL_TARGET_UNMAPPED", + "PEX": "X2B_PIXEL_EXCEPTION_UNRESOLVED", + "ADP": "X2B_ADAPTATION_UNMAPPED", + }.get(str(contract_class), "X2B_SPEC_REF_UNMAPPED") + _fail(code, f"{ref} has no delivery mapping") + if len(mapping_refs) != 1: + _fail( + "X2B_SPEC_REF_DUPLICATE", + f"{ref} is mapped by {mapping_refs}", + ) + if inventory_by_ref[ref].get("mapping_ref") != mapping_refs[0]: + _fail( + "X2B_SPEC_REF_UNMAPPED", + f"{ref} inventory mapping does not resolve", + ) + + readiness_mapping_refs = [str(row.get("mapping_ref", "")) for row in readiness_rows] + readiness_duplicates = _duplicates(readiness_mapping_refs) + if readiness_duplicates: + _fail( + "X2B_SPEC_REF_DUPLICATE", + f"UI/UX readiness duplicates {sorted(readiness_duplicates)[0]}", + ) + if set(readiness_mapping_refs) != set(mapping_ids): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + "UI/UX readiness must contain one row per delivery mapping", + ) + readiness_by_mapping = dict(zip(readiness_mapping_refs, readiness_rows)) + for mapping_id, mapping in mappings_by_id.items(): + row = readiness_by_mapping[mapping_id] + if row.get("status") != mapping.get("status"): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} readiness status differs from mapping", + ) + if row.get("status") == "READY" and not row.get("evidence"): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} READY readiness lacks evidence", + ) + if row.get("status") == "BLOCKED" and row.get("blocker") != mapping.get( + "blocker" + ): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + f"{mapping_id} readiness fails to propagate blocker", + ) + + required_mapping_refs = { + str(row["mapping_ref"]) + for row in inventory + if row.get("x2b_applicability") == "Required" + } + if required_mapping_refs != set(mapping_ids): + unknown_mapping = set(mapping_ids) - required_mapping_refs + if unknown_mapping: + _fail( + "X2B_SPEC_REF_UNKNOWN", + f"orphan mapping {sorted(unknown_mapping)[0]}", + ) + _fail( + "X2B_SPEC_REF_UNMAPPED", + "inventory references a missing X2B mapping", + ) + return set(mapping_ids) + + def _validate_artifact_decisions(artifacts: list[dict[str, Any]]) -> None: paths = [artifact.get("path") for artifact in artifacts] if any(not isinstance(path, str) or not path for path in paths): @@ -239,6 +814,26 @@ def validate_plan_artifact_bundle(bundle: dict[str, Any]) -> None: declared_ids.extend( path.get("id", "") for path in bundle.get("validation_paths", []) ) + base_known_ids = set(declared_ids) + declared_asset_refs = bundle.get("declared_asset_refs", []) + if not isinstance(declared_asset_refs, list): + _fail( + "X2B_DELIVERY_DECISION_INCOMPLETE", + "declared_asset_refs must be a list", + ) + x2b_mapping_ids = _validate_x2b_contract( + bundle, + x2b_status=x2b_status, + decision_ids={ + str(decision.get("id", "")) for decision in bundle.get("decisions", []) + }, + uif_ids={ + str(contract.get("id", "")) for contract in bundle.get("uif_contracts", []) + }, + known_internal_ids=base_known_ids, + asset_ids=set(map(str, declared_asset_refs)), + ) + declared_ids.extend(sorted(x2b_mapping_ids)) if any(not item for item in declared_ids) or _duplicates(declared_ids): raise ValueError("Plan bundle has missing or duplicate stable IDs") known_ids = set(declared_ids) @@ -254,6 +849,16 @@ def validate_plan_artifact_bundle(bundle: dict[str, Any]) -> None: consumed_refs.update(contract.get("related_refs", [])) for path in bundle.get("validation_paths", []): consumed_refs.update(path.get("covered_refs", [])) + for mapping in bundle.get("x2b_delivery_mappings", []): + for field in ( + "decision_refs", + "uif_refs", + "interface_refs", + ): + consumed_refs.update(mapping.get(field, [])) + for row in bundle.get("uiux_readiness_rows", []): + if row.get("mapping_ref"): + consumed_refs.add(row["mapping_ref"]) unresolved = sorted( ref diff --git a/presets/workflow-preset/validators/speckit_spec_contract.py b/presets/workflow-preset/validators/speckit_spec_contract.py new file mode 100644 index 0000000000..d242c538dc --- /dev/null +++ b/presets/workflow-preset/validators/speckit_spec_contract.py @@ -0,0 +1,738 @@ +"""Pure in-memory validation for the feature-local UI specification contract.""" +from __future__ import annotations + +from typing import Any, Iterable + + +SOURCE_ROLES = { + "requirement-input", + "visual-input", + "technical-evidence", + "context-only", +} +UI_REQUIREMENT_PREFIXES = ("UI-", "VIS-") +UI_KINDS = { + "content", + "structure", + "interaction", + "state", + "responsive", + "accessibility", + "visual", + "asset", + "restoration", +} +DERIVATIONS = {"observed", "derived", "assumed", "unresolved", "conflicting"} +EVIDENCE_DIMENSIONS = { + "content", + "structure", + "surface", + "state", + "viewport", + "interaction", + "responsive", + "accessibility", + "visual", + "asset", + "restoration", +} +KIND_EVIDENCE_DIMENSION = { + "content": "content", + "structure": "structure", + "interaction": "interaction", + "state": "state", + "responsive": "responsive", + "accessibility": "accessibility", + "visual": "visual", + "asset": "asset", + "restoration": "restoration", +} +RESTORATION_DIMENSIONS = { + "content", + "information-structure", + "visual-appearance", + "interaction-feedback", + "ui-states", + "responsive-viewports", + "accessibility", + "asset-identity-substitution", +} +FIDELITY_MODES = { + "pixel-exact", + "pixel-tolerant", + "perceptual-equivalent", + "structural-only", +} +PIXEL_VISUAL_DIMENSIONS = { + "geometry-sizing-spacing-alignment-flow", + "overflow-and-clipping", + "typography", + "color-border-radius-shadow-opacity-effects", + "asset-identity-variant-crop-aspect-fitting", + "layering-stacking-fixed-sticky-occlusion", +} +RENDERING_CONTEXT_FIELDS = {"fonts", "color_mode", "locale", "platform"} +TARGET_CONTEXT_FIELDS = {"window_or_device", "input", "accessibility", "locale"} +PROHIBITED_REQUIREMENT_FIELDS = { + "dom_structure", + "css_structure", + "framework_component", + "widget_class", + "code_property", + "resource_path", + "capture_method", + "comparison_method", + "implementation_strategy", +} +ADAPTATION_MODES = { + "framework-equivalent", + "native-adaptive", + "brand-preserving-native", + "visual-equivalent-native", +} +ADAPTATION_DECISIONS = {"preserve", "adapt", "add", "omit", "clarify", "blocked"} +ADAPTATION_DIMENSIONS = { + "content-and-information-hierarchy", + "task-flow-and-navigation", + "surface-and-component-role", + "ui-state-and-feedback", + "geometry-and-composition", + "typography", + "color-effects-and-brand", + "assets-and-variants", + "input-modality-and-gestures", + "responsive-adaptive-layout", + "system-ui-and-safe-regions", + "accessibility-and-user-scaling", + "localization-and-layout-direction", +} +CONFLICT_PRECEDENCE = [ + "target-hard-constraints-and-accessibility", + "explicit-product-requirements", + "adaptation-policy-and-dimension-decisions", + "source-backed-ui-evidence", + "target-platform-defaults", + "implementation-preference", +] + + +def _duplicates(values: Iterable[str]) -> set[str]: + seen: set[str] = set() + duplicates: set[str] = set() + for value in values: + if value in seen: + duplicates.add(value) + seen.add(value) + return duplicates + + +def _require_list(item: dict[str, Any], field: str, context: str) -> list[Any]: + value = item.get(field) + if not isinstance(value, list) or not value: + raise ValueError(f"{context} missing non-empty {field}") + return value + + +def _blocked(item: dict[str, Any]) -> bool: + return str(item.get("status", "")).upper() == "BLOCKED" + + +def _require_blocker(item: dict[str, Any], context: str) -> None: + if not _blocked(item) or not item.get("blocker"): + raise ValueError(f"{context} must remain BLOCKED with a stable blocker") + + +def _validate_evidence_locators( + locators: Iterable[Any], + source_refs: Iterable[str], + sources_by_id: dict[str, dict[str, Any]], + context: str, +) -> None: + supplied_facts = { + str(fact) + for source_ref in source_refs + for fact in sources_by_id[source_ref].get("supplied_facts", []) + } + unsupported = set(map(str, locators)) - supplied_facts + if unsupported: + raise ValueError( + f"{context} evidence locator is not present in supplied source facts: " + f"{sorted(unsupported)[0]}" + ) + + +def _validate_sources( + sources: list[dict[str, Any]], + requirement_ids: set[str], +) -> dict[str, dict[str, Any]]: + source_ids = [str(source.get("ref", "")) for source in sources] + if any(not source_id.startswith("SRC-") for source_id in source_ids): + raise ValueError("source refs must use SRC-*") + duplicates = _duplicates(source_ids) + if duplicates: + raise ValueError(f"duplicate source ref: {sorted(duplicates)[0]}") + + sources_by_id = dict(zip(source_ids, sources)) + for source_id, source in sources_by_id.items(): + if source.get("role") not in SOURCE_ROLES: + raise ValueError(f"{source_id} has invalid source role") + if not source.get("locator_or_description"): + raise ValueError(f"{source_id} missing opaque locator/description") + if not source.get("bounded_scope"): + raise ValueError(f"{source_id} missing bounded feature scope") + + projected_refs = source.get("projected_refs") + if not isinstance(projected_refs, list): + raise ValueError(f"{source_id} projected_refs must be a list") + supplied_facts = source.get("supplied_facts") + if not isinstance(supplied_facts, list): + raise ValueError(f"{source_id} supplied_facts must be a list") + if projected_refs and not supplied_facts: + raise ValueError( + f"{source_id} locator alone cannot support requirement projection" + ) + if not supplied_facts: + if projected_refs: + raise ValueError(f"{source_id} missing evidence cannot project requirements") + if not _blocked(source) or source.get("blocker") != "SRC_EVIDENCE_MISSING": + raise ValueError( + f"{source_id} locator-only input must use SRC_EVIDENCE_MISSING" + ) + elif projected_refs and source.get("status") != "projected": + raise ValueError(f"{source_id} projected refs require projected status") + elif not projected_refs and source.get("status") not in { + "retained", + "context-only", + "BLOCKED", + }: + raise ValueError(f"{source_id} has invalid non-projecting status") + if _blocked(source) and not source.get("blocker"): + raise ValueError(f"{source_id} BLOCKED source lacks stable blocker") + + missing_refs = set(map(str, projected_refs)) - requirement_ids + if missing_refs: + raise ValueError( + f"{source_id} projects unknown requirement: {sorted(missing_refs)[0]}" + ) + normative_refs = [ + str(ref) + for ref in projected_refs + if str(ref).startswith(("FR-", "NFR-", "UX-", "UI-", "VIS-")) + ] + role = source["role"] + if role in {"technical-evidence", "context-only"} and normative_refs: + raise ValueError(f"{source_id} role cannot project normative requirements") + if role == "visual-input" and any( + not ref.startswith(UI_REQUIREMENT_PREFIXES) for ref in normative_refs + ): + raise ValueError(f"{source_id} visual-input projects unrelated requirement") + + return sources_by_id + + +def _validate_requirements( + requirements: list[dict[str, Any]], + sources_by_id: dict[str, dict[str, Any]], +) -> None: + requirement_ids = [str(requirement.get("id", "")) for requirement in requirements] + if any(not requirement_id.startswith(UI_REQUIREMENT_PREFIXES) for requirement_id in requirement_ids): + raise ValueError("UI specification requirement ids must use UI-* or VIS-*") + duplicates = _duplicates(requirement_ids) + if duplicates: + raise ValueError(f"duplicate UI requirement id: {sorted(duplicates)[0]}") + + requirements_by_id = dict(zip(requirement_ids, requirements)) + observed_ids = { + requirement_id + for requirement_id, requirement in requirements_by_id.items() + if requirement.get("derivation") == "observed" + } + for requirement_id, requirement in requirements_by_id.items(): + prohibited_fields = sorted(set(requirement) & PROHIBITED_REQUIREMENT_FIELDS) + if prohibited_fields: + raise ValueError( + f"{requirement_id} contains implementation/source-unsupported field: " + f"{prohibited_fields[0]}" + ) + if requirement.get("kind") not in UI_KINDS: + raise ValueError(f"{requirement_id} has invalid requirement kind") + for field in ("statement", "surface", "state", "viewport"): + if not requirement.get(field): + raise ValueError(f"{requirement_id} missing {field}") + if not _blocked(requirement) and not requirement.get("acceptance"): + raise ValueError(f"{requirement_id} missing acceptance") + if requirement.get("outcome_only") is not True: + raise ValueError( + f"{requirement_id} must define an observable outcome, not implementation" + ) + + source_refs = list(map(str, _require_list(requirement, "source_refs", requirement_id))) + missing_sources = set(source_refs) - set(sources_by_id) + if missing_sources: + raise ValueError( + f"{requirement_id} references unknown source: {sorted(missing_sources)[0]}" + ) + for source_ref in source_refs: + if requirement_id not in sources_by_id[source_ref]["projected_refs"]: + raise ValueError( + f"{requirement_id} missing reciprocal projection from {source_ref}" + ) + + derivation = requirement.get("derivation") + if derivation not in DERIVATIONS: + raise ValueError(f"{requirement_id} has invalid derivation") + evidence_locators = requirement.get("evidence_locators") + if not isinstance(evidence_locators, list): + raise ValueError(f"{requirement_id} evidence_locators must be a list") + if derivation in {"observed", "derived"} and not evidence_locators: + raise ValueError(f"{requirement_id} lacks cited supplied evidence") + if derivation in {"observed", "derived"}: + _validate_evidence_locators( + evidence_locators, + source_refs, + sources_by_id, + requirement_id, + ) + evidence_support = set( + map( + str, + _require_list(requirement, "evidence_support", requirement_id), + ) + ) + if not evidence_support.issubset(EVIDENCE_DIMENSIONS): + raise ValueError(f"{requirement_id} has invalid evidence support") + required_support = {"surface", "state", "viewport"} + required_support.add(KIND_EVIDENCE_DIMENSION[str(requirement["kind"])]) + missing_support = required_support - evidence_support + if missing_support: + raise ValueError( + f"{requirement_id} lacks corresponding evidence for " + f"{sorted(missing_support)[0]}" + ) + if derivation == "derived": + derived_from = set( + map(str, _require_list(requirement, "derived_from", requirement_id)) + ) + valid_derivation_inputs = set(map(str, evidence_locators)) | observed_ids + if not derived_from.issubset(valid_derivation_inputs): + raise ValueError( + f"{requirement_id} derived_from does not cite an observation" + ) + if derivation == "assumed" and not requirement.get("assumption"): + raise ValueError(f"{requirement_id} assumed requirement lacks documented default") + if derivation == "conflicting" and len(evidence_locators) < 2: + raise ValueError(f"{requirement_id} conflicting requirement needs two locators") + if derivation in {"unresolved", "conflicting"}: + _require_blocker(requirement, requirement_id) + elif _blocked(requirement): + _require_blocker(requirement, requirement_id) + elif requirement.get("status") != "specified": + raise ValueError(f"{requirement_id} has invalid status") + + +def _validate_restoration( + payload: dict[str, Any], + requirement_ids: set[str], + sources_by_id: dict[str, dict[str, Any]], +) -> None: + if not payload.get("restoration_requested"): + return + rows = payload.get("restoration_dimensions") + if not isinstance(rows, list): + raise ValueError("restoration requires a dimension matrix") + dimensions = [str(row.get("dimension", "")) for row in rows] + if set(dimensions) != RESTORATION_DIMENSIONS or len(dimensions) != len( + RESTORATION_DIMENSIONS + ): + raise ValueError("restoration dimension matrix is incomplete or duplicated") + for row in rows: + context = f"restoration dimension {row['dimension']}" + refs = set(map(str, _require_list(row, "requirement_refs", context))) + if not refs.issubset(requirement_ids): + raise ValueError(f"{context} references unknown UI/VIS requirement") + status = str(row.get("status", "")) + if status == "required": + if not row.get("acceptance"): + raise ValueError(f"{context} missing measurable acceptance") + row_source_refs = list( + map(str, _require_list(row, "source_refs", context)) + ) + if not set(row_source_refs).issubset(set(sources_by_id)): + raise ValueError(f"{context} references unknown source") + evidence_locators = _require_list(row, "evidence_locators", context) + _validate_evidence_locators( + evidence_locators, + row_source_refs, + sources_by_id, + context, + ) + elif status == "not-applicable": + if not row.get("rationale"): + raise ValueError(f"{context} N/A missing rationale") + elif status == "BLOCKED": + _require_blocker(row, context) + else: + raise ValueError(f"{context} has invalid status") + + +def _validate_pixel_profiles( + payload: dict[str, Any], + requirement_ids: set[str], + sources_by_id: dict[str, dict[str, Any]], +) -> None: + source_ids = set(sources_by_id) + profiles = payload.get("pixel_profiles", []) + targets = payload.get("pixel_targets", []) + exceptions = payload.get("pixel_exceptions", []) + if not all(isinstance(items, list) for items in (profiles, targets, exceptions)): + raise ValueError("pixel profile structures must be lists") + + profile_ids = [str(profile.get("id", "")) for profile in profiles] + target_ids = [str(target.get("id", "")) for target in targets] + exception_ids = [str(exception.get("id", "")) for exception in exceptions] + if _duplicates(profile_ids) or _duplicates(target_ids) or _duplicates(exception_ids): + raise ValueError("pixel restoration ids must be unique") + if any(not item.startswith("PXR-") for item in profile_ids): + raise ValueError("pixel profile ids must use PXR-*") + if any(not item.startswith("PXT-") for item in target_ids): + raise ValueError("pixel target ids must use PXT-*") + if any(not item.startswith("PEX-") for item in exception_ids): + raise ValueError("pixel exception ids must use PEX-*") + + targets_by_id = dict(zip(target_ids, targets)) + exceptions_by_id = dict(zip(exception_ids, exceptions)) + profiles_by_id = dict(zip(profile_ids, profiles)) + for profile_id, profile in zip(profile_ids, profiles): + profile_blocked = _blocked(profile) + if profile_blocked: + _require_blocker(profile, profile_id) + elif profile.get("status") != "specified": + raise ValueError(f"{profile_id} has invalid status") + mode = profile.get("fidelity_mode") + if mode is not None and mode not in FIDELITY_MODES: + raise ValueError(f"{profile_id} has invalid fidelity mode") + if not profile_blocked and mode not in FIDELITY_MODES: + raise ValueError(f"{profile_id} missing fidelity mode") + for field in ("scope", "exception_policy"): + if not profile_blocked and not profile.get(field): + raise ValueError(f"{profile_id} missing {field}") + refs = set(map(str, _require_list(profile, "requirement_refs", profile_id))) + if not refs.issubset(requirement_ids): + raise ValueError(f"{profile_id} references unknown UI/VIS requirement") + profile_sources = set(map(str, _require_list(profile, "source_refs", profile_id))) + if not profile_sources.issubset(source_ids): + raise ValueError(f"{profile_id} references unknown source") + declared_targets = list(map(str, _require_list(profile, "target_refs", profile_id))) + declared_exceptions = profile.get("exception_refs") + if not isinstance(declared_exceptions, list): + raise ValueError(f"{profile_id} exception_refs must be a list") + if not set(map(str, declared_exceptions)).issubset(set(exception_ids)): + raise ValueError(f"{profile_id} references unknown accepted exception") + if len(declared_targets) != len(set(declared_targets)): + raise ValueError(f"{profile_id} target matrix contains duplicates") + missing_targets = set(declared_targets) - set(targets_by_id) + if missing_targets: + raise ValueError(f"{profile_id} target matrix references unknown target") + actual_targets = { + target_id + for target_id, target in targets_by_id.items() + if target.get("profile_id") == profile_id + } + if set(declared_targets) != actual_targets: + raise ValueError(f"{profile_id} target matrix is incomplete") + has_blocked_target = any( + _blocked(targets_by_id[target_ref]) for target_ref in declared_targets + ) + if has_blocked_target and not _blocked(profile): + raise ValueError(f"{profile_id} must be BLOCKED while a target is blocked") + target_matrix = _require_list(profile, "target_matrix", profile_id) + matrix_by_ref = { + str(row.get("target_ref")): ( + str(row.get("surface")), + str(row.get("state")), + str(row.get("viewport")), + ) + for row in target_matrix + if isinstance(row, dict) + } + if set(matrix_by_ref) != set(declared_targets): + raise ValueError(f"{profile_id} applicable target matrix is incomplete") + if len(set(matrix_by_ref.values())) != len(matrix_by_ref): + raise ValueError(f"{profile_id} applicable target matrix is duplicated") + for target_ref, expected_coordinates in matrix_by_ref.items(): + actual_target = targets_by_id[target_ref] + actual_coordinates = ( + str(actual_target.get("surface")), + str(actual_target.get("state")), + str(actual_target.get("viewport")), + ) + if actual_coordinates != expected_coordinates: + raise ValueError(f"{profile_id} target matrix coordinates do not resolve") + + for target_id, target in targets_by_id.items(): + context = f"pixel target {target_id}" + for field in ("profile_id", "surface", "state", "viewport"): + if not target.get(field): + raise ValueError(f"{context} missing {field}") + if target.get("profile_id") not in profile_ids: + raise ValueError(f"{context} references unknown profile") + if _blocked(target): + _require_blocker(target, context) + continue + for field in ( + "device_pixel_ratio", + "baseline_locator", + "acceptance_envelope", + ): + if not target.get(field): + raise ValueError(f"{context} missing {field}") + rendering_context = target.get("rendering_context") + if not isinstance(rendering_context, dict) or set(rendering_context) != ( + RENDERING_CONTEXT_FIELDS + ): + raise ValueError(f"{context} has incomplete rendering_context") + if any(not value for value in rendering_context.values()): + raise ValueError(f"{context} has empty rendering_context constraint") + visual_dimensions = target.get("visual_dimensions") + if not isinstance(visual_dimensions, dict) or set(visual_dimensions) != ( + PIXEL_VISUAL_DIMENSIONS + ): + raise ValueError(f"{context} has incomplete visual_dimensions") + if any(not value for value in visual_dimensions.values()): + raise ValueError(f"{context} has empty visual dimension outcome") + if target.get("baseline_source_ref") not in source_ids: + raise ValueError(f"{context} lacks one resolvable baseline source") + profile = profiles_by_id[str(target["profile_id"])] + if target.get("baseline_source_ref") not in profile["source_refs"]: + raise ValueError(f"{context} baseline source is outside its profile") + _validate_evidence_locators( + [target["baseline_locator"]], + [str(target["baseline_source_ref"])], + sources_by_id, + context, + ) + fidelity_mode = target.get("fidelity_mode") + if fidelity_mode not in FIDELITY_MODES: + raise ValueError(f"{context} has invalid fidelity mode") + if fidelity_mode != profile.get("fidelity_mode"): + raise ValueError(f"{context} fidelity mode differs from its profile") + envelope = target.get("acceptance_envelope") + if not isinstance(envelope, dict): + raise ValueError(f"{context} acceptance_envelope must be structured") + expected_envelope_kinds = { + "pixel-exact": {"equality"}, + "pixel-tolerant": {"per-channel", "per-pixel", "aggregate"}, + "perceptual-equivalent": {"perceptual"}, + "structural-only": {"structural"}, + } + if envelope.get("kind") not in expected_envelope_kinds[str(fidelity_mode)]: + raise ValueError(f"{context} acceptance envelope mismatches fidelity mode") + if "threshold" not in envelope: + raise ValueError(f"{context} acceptance envelope lacks threshold") + if fidelity_mode in {"perceptual-equivalent", "structural-only"} and not envelope.get( + "metric" + ): + raise ValueError(f"{context} acceptance envelope lacks metric") + if fidelity_mode == "pixel-exact" and envelope.get("threshold") != 0: + raise ValueError(f"{context} pixel-exact threshold must be zero") + exception_refs = target.get("exception_refs") + if not isinstance(exception_refs, list): + raise ValueError(f"{context} exception_refs must be a list") + unknown_exceptions = set(map(str, exception_refs)) - set(exceptions_by_id) + if unknown_exceptions: + raise ValueError(f"{context} references unknown accepted exception") + if not set(map(str, exception_refs)).issubset( + set(map(str, profile["exception_refs"])) + ): + raise ValueError(f"{context} exception is outside its profile policy") + if target.get("derivation") not in {"observed", "derived"}: + raise ValueError(f"{context} must be source-backed or BLOCKED") + if target.get("status") != "specified": + raise ValueError(f"{context} has invalid status") + + for exception_id, exception in exceptions_by_id.items(): + context = f"pixel exception {exception_id}" + for field in ("region", "reason", "allowed_divergence", "bound"): + if not exception.get(field): + raise ValueError(f"{context} missing {field}") + target_refs = set(map(str, _require_list(exception, "target_refs", context))) + if not target_refs.issubset(set(target_ids)): + raise ValueError(f"{context} references unknown target") + refs = set(map(str, _require_list(exception, "requirement_refs", context))) + if not refs.issubset(requirement_ids): + raise ValueError(f"{context} references unknown UI/VIS requirement") + exception_sources = set( + map(str, _require_list(exception, "source_refs", context)) + ) + if not exception_sources.issubset(source_ids): + raise ValueError(f"{context} references unknown source") + + if payload.get("pixel_restoration_requested") and not profiles: + raise ValueError( + "pixel restoration request needs a stable specified or blocked profile" + ) + + +def _validate_adaptation( + payload: dict[str, Any], + requirement_ids: set[str], + source_ids: set[str], +) -> None: + policies = payload.get("adaptation_policies", []) + if not isinstance(policies, list): + raise ValueError("adaptation_policies must be a list") + if payload.get("cross_platform_restoration_requested") and not policies: + raise ValueError("cross-platform restoration requires an adaptation policy") + + policy_ids = [str(policy.get("id", "")) for policy in policies] + if _duplicates(policy_ids): + raise ValueError("adaptation policy ids must be unique") + for policy_id, policy in zip(policy_ids, policies): + if _blocked(policy): + _require_blocker(policy, policy_id) + elif policy.get("status") != "specified": + raise ValueError(f"{policy_id} has invalid status") + if not policy_id.startswith("ADP-"): + raise ValueError("adaptation policy ids must use ADP-*") + source_platform = policy.get("source_platform") + target_platform = policy.get("target_platform") + if not source_platform or not target_platform: + raise ValueError(f"{policy_id} missing source or target platform") + if str(target_platform).casefold() == "swift": + raise ValueError(f"{policy_id} Swift alone is not a target platform") + if policy.get("mode") not in ADAPTATION_MODES: + raise ValueError(f"{policy_id} has invalid adaptation mode") + mode = str(policy["mode"]) + source_platform_normalized = str(source_platform).casefold() + target_platform_normalized = str(target_platform).casefold() + web_platforms = {"html", "html/web", "web"} + if mode == "framework-equivalent" and ( + source_platform_normalized not in web_platforms + or target_platform_normalized not in web_platforms + ): + raise ValueError( + f"{policy_id} framework-equivalent requires HTML/Web endpoints" + ) + if ( + target_platform_normalized in {"android", "ios", "ipados"} + and mode != "brand-preserving-native" + and not policy.get("mode_override_reason") + ): + raise ValueError( + f"{policy_id} native mode override requires an explicit reason" + ) + target_contexts = policy.get("target_contexts") + if not isinstance(target_contexts, dict) or set(target_contexts) != ( + TARGET_CONTEXT_FIELDS + ): + raise ValueError(f"{policy_id} has incomplete target contexts") + if any(not value for value in target_contexts.values()): + raise ValueError(f"{policy_id} has empty target context") + policy_sources = set(map(str, _require_list(policy, "source_refs", policy_id))) + if not policy_sources.issubset(source_ids): + raise ValueError(f"{policy_id} references unknown source") + if policy.get("conflict_precedence") != CONFLICT_PRECEDENCE: + raise ValueError(f"{policy_id} has invalid conflict precedence") + + decisions = policy.get("decisions") + if not isinstance(decisions, list): + raise ValueError(f"{policy_id} missing dimension decisions") + dimensions = [str(decision.get("dimension", "")) for decision in decisions] + if set(dimensions) != ADAPTATION_DIMENSIONS or len(dimensions) != len( + ADAPTATION_DIMENSIONS + ): + raise ValueError(f"{policy_id} dimension decisions are incomplete or duplicated") + for decision in decisions: + dimension = str(decision["dimension"]) + context = f"{policy_id}/{dimension}" + value = decision.get("decision") + if value not in ADAPTATION_DECISIONS: + raise ValueError(f"{context} has invalid adaptation decision") + requirement_refs = set( + map(str, _require_list(decision, "requirement_refs", context)) + ) + if not requirement_refs.issubset(requirement_ids): + raise ValueError(f"{context} references unknown UI/VIS requirement") + if value in {"adapt", "add", "omit"}: + cited_sources = set(map(str, decision.get("source_refs", []))) + if not cited_sources.issubset(source_ids): + raise ValueError(f"{context} references unknown source") + if not cited_sources and not decision.get("hard_constraint_refs"): + raise ValueError( + f"{context} must cite source evidence or a target hard constraint" + ) + if value in {"clarify", "blocked"}: + _require_blocker(decision, context) + if decision.get("hard_constraint_conflict") and value not in { + "adapt", + "add", + "omit", + "blocked", + }: + raise ValueError( + f"{context} lets lower-priority evidence override a hard constraint" + ) + + has_blocked_decision = any(_blocked(decision) for decision in decisions) + if has_blocked_decision and not _blocked(policy): + raise ValueError(f"{policy_id} must be BLOCKED while a decision is blocked") + if _blocked(policy) and not has_blocked_decision: + raise ValueError(f"{policy_id} BLOCKED policy has no blocked decision") + + if mode == "framework-equivalent": + divergent = [ + decision["dimension"] + for decision in decisions + if decision["decision"] not in {"preserve", "clarify", "blocked"} + ] + if divergent: + raise ValueError( + f"{policy_id} framework-equivalent diverges at {divergent[0]}" + ) + + if target_platform_normalized in {"android", "ios", "ipados"}: + by_dimension = { + decision["dimension"]: decision["decision"] for decision in decisions + } + for invariant in ( + "content-and-information-hierarchy", + "task-flow-and-navigation", + "ui-state-and-feedback", + "color-effects-and-brand", + ): + if by_dimension[invariant] not in {"preserve", "blocked", "clarify"}: + raise ValueError( + f"{policy_id} native target fails to preserve {invariant}" + ) + + +def validate_ui_specification_contract(payload: dict[str, Any]) -> None: + """Validate source-backed UI, restoration, pixel, and adaptation structures.""" + + requirements = payload.get("requirements") + sources = payload.get("sources") + if not isinstance(requirements, list) or not requirements: + raise ValueError("UI specification must include requirements") + if not isinstance(sources, list) or not sources: + raise ValueError("UI specification must include sources") + + requirement_ids = { + str(requirement.get("id", "")) for requirement in requirements + } + all_spec_requirement_refs = payload.get("all_spec_requirement_refs") + if not isinstance(all_spec_requirement_refs, list) or not all_spec_requirement_refs: + raise ValueError( + "UI specification must include all_spec_requirement_refs" + ) + all_requirement_ids = list(map(str, all_spec_requirement_refs)) + if _duplicates(all_requirement_ids): + raise ValueError("all_spec_requirement_refs contains duplicates") + if not requirement_ids.issubset(set(all_requirement_ids)): + raise ValueError( + "all_spec_requirement_refs omits a UI/VIS requirement" + ) + sources_by_id = _validate_sources(sources, set(all_requirement_ids)) + _validate_requirements(requirements, sources_by_id) + _validate_restoration(payload, requirement_ids, sources_by_id) + _validate_pixel_profiles(payload, requirement_ids, sources_by_id) + _validate_adaptation(payload, requirement_ids, set(sources_by_id)) diff --git a/presets/workflow-preset/validators/speckit_tasks_contract.py b/presets/workflow-preset/validators/speckit_tasks_contract.py new file mode 100644 index 0000000000..b359900e0d --- /dev/null +++ b/presets/workflow-preset/validators/speckit_tasks_contract.py @@ -0,0 +1,875 @@ +"""Pure in-memory validation for Plan X2-B to Tasks derivation.""" +from __future__ import annotations + +import re +from typing import Any, Iterable + + +MAPPING_DIMENSIONS = { + "UI": { + "component", + "state", + "interaction", + "navigation", + "responsive", + "accessibility", + }, + "PX": { + "geometry", + "sizing", + "spacing", + "alignment", + "flow", + "overflow", + "clipping", + "typography", + "text-metrics", + "color", + "gradient", + "border", + "radius", + "shadow", + "opacity", + "effects", + "asset-preparation", + "asset-binding", + "asset-crop", + "asset-aspect", + "asset-fitting", + "layering", + "stacking", + "fixed-sticky", + "occlusion", + }, + "ADP": { + "target-component", + "navigation", + "presentation", + "input-modality", + "gestures", + "system-ui", + "safe-regions", + "adaptive-layout", + "accessibility-scaling", + "localization", + "layout-direction", + }, +} +FINAL_REVIEW_SCOPES = { + "UI": "component-state-interaction", + "PX": "geometry-typography-appearance-asset-layering-overflow-clipping", + "ADP": ( + "platform-navigation-input-system-safe-region-adaptive-" + "accessibility-localization" + ), +} +SPEC_OWNED_FIELDS = { + "statement", + "requirement_statement", + "baseline_identity", + "baseline_source_ref", + "baseline_locator", + "rendering_context", + "fidelity_mode", + "acceptance", + "acceptance_envelope", + "bound", + "exception_bound", + "adaptation_decisions", + "adaptation_decision", +} +FORBIDDEN_VISUAL_EXECUTION_TERMS = { + "visual_acceptance", + "pixel_fidelity_review", + "screenshot capture", + "screenshot comparison", + "screenshot diff", + "screenshot-based evidence", + "baseline production", + "baseline capture", + "pixel comparison", + "pixel/perceptual comparison", + "perceptual comparison", + "visual diff", + "visual acceptance", + "visual restoration", + "rendered fidelity", + "rendered-fidelity", + "final rendered visual review", + "fidelity mode evaluation", + "fidelity-mode evaluation", + "acceptance envelope evaluation", + "acceptance-envelope evaluation", + "threshold evaluation", + "source dereference", + "external source acquisition", + "external source certification", +} +SPEC_SEMANTIC_VALUE_TERMS = { + "pixel-exact", + "pixel-tolerant", + "perceptual-equivalent", + "structural-only", + "acceptance envelope", + "exception bound", + "adaptation decision", +} +VISUAL_EXECUTION_PATTERNS = { + "capture screenshot": ( + r"\b(?:capture|captures|captured|capturing|take|takes|took|taking|" + r"generate|generates|generated|generating|produce|produces|produced|" + r"producing)\b.{0,24}\bscreenshots?\b" + ), + "screenshot execution": ( + r"\bscreenshots?\b.{0,24}\b(?:capture|comparison|compare|diff|" + r"production|produce)\b" + ), + "compare pixels": ( + r"\b(?:compare|compares|compared|comparing)\b.{0,24}\bpixels?\b" + ), + "pixel comparison": ( + r"\bpixels?\b.{0,24}\b(?:compare|compares|compared|comparing|" + r"comparison)\b" + ), + "baseline production": ( + r"\b(?:generate|generates|generated|generating|produce|produces|" + r"produced|producing|create|creates|created|creating|render|renders|" + r"rendered|rendering)\b.{0,24}\b(?:a |the )?baseline\b" + ), + "acceptance threshold evaluation": ( + r"\b(?:evaluate|evaluates|evaluated|evaluating|check|checks|checked|" + r"checking|judge|judges|judged|judging)\b.{0,32}\b(?:acceptance )?" + r"thresholds?\b" + ), +} +TASK_ACTION_CLASSES = { + "implementation", + "functional-validation", + "visual-execution", +} +FINAL_REVIEW_ACTION_CLASS = "code-design-contract-review" +TRACEABILITY_PREFIXES = ("UI-", "VIS-", "PXT-", "PEX-", "ADP-") + + +def _duplicates(values: Iterable[str]) -> set[str]: + seen: set[str] = set() + duplicates: set[str] = set() + for value in values: + if value in seen: + duplicates.add(value) + seen.add(value) + return duplicates + + +def _strings(value: Any) -> Iterable[str]: + if isinstance(value, dict): + for child in value.values(): + yield from _strings(child) + elif isinstance(value, list): + for child in value: + yield from _strings(child) + elif isinstance(value, str): + yield value + + +def _normalized_text(value: Any) -> str: + text = " ".join(_strings(value)).casefold() + text = re.sub(r"[_/\\-]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _visual_execution_leaks(value: Any) -> set[str]: + normalized = _normalized_text(value) + leaks = { + term + for term in FORBIDDEN_VISUAL_EXECUTION_TERMS + if _normalized_text(term) in normalized + } + leaks.update( + label + for label, pattern in VISUAL_EXECUTION_PATTERNS.items() + if re.search(pattern, normalized) + ) + return leaks + + +def _spec_semantic_value_leaks(value: Any) -> set[str]: + normalized = _normalized_text(value) + return { + term + for term in SPEC_SEMANTIC_VALUE_TERMS + if _normalized_text(term) in normalized + } + + +def _ownership_leaks(value: Any) -> set[str]: + if isinstance(value, dict): + leaks = set(value) & SPEC_OWNED_FIELDS + for child in value.values(): + leaks.update(_ownership_leaks(child)) + return leaks + if isinstance(value, list): + leaks: set[str] = set() + for child in value: + leaks.update(_ownership_leaks(child)) + return leaks + return set() + + +def _fail(code: str, detail: str) -> None: + raise ValueError(f"{code}: {detail}") + + +def _require_list( + item: dict[str, Any], + field: str, + context: str, + *, + allow_empty: bool = False, + error_code: str = "TASK_X2B_MAPPING_UNMAPPED", +) -> list[Any]: + value = item.get(field) + if not isinstance(value, list) or (not value and not allow_empty): + _fail( + error_code, + f"{context} missing {'list' if allow_empty else 'non-empty list'} {field}", + ) + return value + + +def _mapping_class(mapping_ref: str) -> str | None: + for mapping_class in ("UI", "PX", "ADP"): + if mapping_ref.startswith(f"X2B-{mapping_class}-"): + return mapping_class + return None + + +def _depends_on( + task_id: str, + prerequisite_id: str, + dependency_graph: dict[str, set[str]], +) -> bool: + pending = list(dependency_graph[task_id]) + visited: set[str] = set() + while pending: + current = pending.pop() + if current == prerequisite_id: + return True + if current in visited: + continue + visited.add(current) + pending.extend(dependency_graph[current]) + return False + + +def _validate_acyclic_dependencies( + dependency_graph: dict[str, set[str]], +) -> None: + visiting: set[str] = set() + visited: set[str] = set() + + def visit(task_id: str) -> None: + if task_id in visiting: + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"task dependency cycle reaches {task_id}", + ) + if task_id in visited: + return + visiting.add(task_id) + for dependency_id in dependency_graph[task_id]: + visit(dependency_id) + visiting.remove(task_id) + visited.add(task_id) + + for task_id in dependency_graph: + visit(task_id) + + +def _validate_preflight(bundle: dict[str, Any]) -> None: + if bundle.get("plan_output_ready") != "READY": + _fail("PLAN_OUTPUT_INCOMPLETE", "PLAN_OUTPUT_READY is not READY") + current_revision = bundle.get("current_plan_revision") + handoff_revision = bundle.get("tasks_handoff_revision") + if ( + not isinstance(current_revision, str) + or not current_revision + or handoff_revision != current_revision + ): + _fail( + "PLAN_OUTPUT_INCOMPLETE", + "Tasks handoff revision is missing or stale", + ) + if bundle.get("uiux_delivery_readiness") not in {"READY", "N/A"}: + _fail( + "PLAN_OUTPUT_INCOMPLETE", + "UI/UX Delivery Readiness is not closed", + ) + + +def validate_tasks_x2b_derivation(bundle: dict[str, Any]) -> None: + """Validate X2-B mapping coverage without executing or persisting tasks.""" + + _validate_preflight(bundle) + mappings = bundle.get("x2b_mappings") + tasks = bundle.get("tasks") + test_tasks = bundle.get("test_tasks") + phases = bundle.get("phases") + if not isinstance(mappings, list) or not isinstance(tasks, list): + _fail( + "PLAN_OUTPUT_INCOMPLETE", + "Tasks derivation requires X2-B inventory and task candidates", + ) + if not isinstance(test_tasks, list) or not isinstance(phases, list): + _fail( + "PLAN_OUTPUT_INCOMPLETE", + "Tasks derivation requires test-task and phase inventories", + ) + if bundle.get("uiux_delivery_readiness") == "N/A" and (mappings or tasks): + _fail( + "TASK_X2B_REF_UNKNOWN", + "non-UI handoff cannot contain X2-B mappings or X2-B tasks", + ) + + mapping_ids = [str(mapping.get("id", "")) for mapping in mappings] + if any(_mapping_class(mapping_id) is None for mapping_id in mapping_ids): + _fail("TASK_X2B_REF_UNKNOWN", "unknown X2B mapping id class") + duplicate_mappings = _duplicates(mapping_ids) + if duplicate_mappings: + _fail( + "TASK_X2B_MAPPING_DUPLICATE", + f"duplicate mapping {sorted(duplicate_mappings)[0]}", + ) + mappings_by_id = dict(zip(mapping_ids, mappings)) + declared_traceability_refs = set( + map(str, bundle.get("declared_traceability_refs", [])) + ) + + blocked_mapping_ids: set[str] = set() + implementation_mapping_ids: set[str] = set() + review_only_mapping_ids: set[str] = set() + for mapping_id, mapping in mappings_by_id.items(): + mapping_class = str(_mapping_class(mapping_id)) + status = mapping.get("status") + dimensions = set( + map( + str, + _require_list( + mapping, + "implementation_dimensions", + mapping_id, + allow_empty=True, + ), + ) + ) + invalid_dimensions = dimensions - MAPPING_DIMENSIONS[mapping_class] + if invalid_dimensions: + code = ( + "TASK_X2B_ADAPTATION_UNCOVERED" + if mapping_class == "ADP" + else "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED" + ) + _fail( + code, + f"{mapping_id} has invalid dimension {sorted(invalid_dimensions)[0]}", + ) + dependencies = list( + map( + str, + _require_list( + mapping, + "depends_on", + mapping_id, + allow_empty=True, + ), + ) + ) + if mapping_id in dependencies: + _fail("TASK_X2B_REF_UNKNOWN", f"{mapping_id} depends on itself") + traceability_refs = mapping.get("traceability_refs") + if not isinstance(traceability_refs, list): + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{mapping_id} traceability_refs must be a list", + ) + if status == "Required" and not traceability_refs: + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{mapping_id} is orphaned from upstream Plan refs", + ) + if any( + not str(ref).startswith(TRACEABILITY_PREFIXES) + or str(ref) not in declared_traceability_refs + for ref in traceability_refs + ): + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{mapping_id} has unknown traceability ref", + ) + + if status == "Required": + if mapping.get("review_method_only") is True: + if dimensions or not mapping.get("no_task_rationale"): + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{mapping_id} invalid review-method-only rationale", + ) + review_only_mapping_ids.add(mapping_id) + else: + if not dimensions: + _fail( + "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED", + f"{mapping_id} has no implementation dimensions", + ) + implementation_mapping_ids.add(mapping_id) + elif status == "Blocked": + if not mapping.get("blocker"): + _fail( + "TASK_X2B_BLOCKER_SUPPRESSED", + f"{mapping_id} blocked without stable blocker", + ) + blocked_mapping_ids.add(mapping_id) + elif status == "N/A": + if not mapping.get("reason"): + _fail("TASK_X2B_MAPPING_UNMAPPED", f"{mapping_id} N/A lacks reason") + else: + _fail("TASK_X2B_REF_UNKNOWN", f"{mapping_id} has invalid handoff status") + + for mapping_id, mapping in mappings_by_id.items(): + unknown_dependencies = set(map(str, mapping.get("depends_on", []))) - set( + mappings_by_id + ) + if unknown_dependencies: + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{mapping_id} depends on unknown {sorted(unknown_dependencies)[0]}", + ) + + task_ids = [str(task.get("id", "")) for task in tasks] + if any(not task_id.startswith("T") for task_id in task_ids): + _fail("TASK_X2B_REF_UNKNOWN", "implementation task ids must use T*") + duplicate_tasks = _duplicates(task_ids) + if duplicate_tasks: + _fail( + "TASK_X2B_MAPPING_DUPLICATE", + f"duplicate task id {sorted(duplicate_tasks)[0]}", + ) + tasks_by_id = dict(zip(task_ids, tasks)) + task_ids_by_mapping: dict[str, set[str]] = { + mapping_id: set() for mapping_id in mappings_by_id + } + dimensions_by_mapping: dict[str, set[str]] = { + mapping_id: set() for mapping_id in mappings_by_id + } + mapping_path_dimensions: set[tuple[str, str, str]] = set() + + for task_id, task in tasks_by_id.items(): + if task.get("kind") != "implementation": + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} is not an implementation task candidate", + ) + action_classes = set( + map( + str, + _require_list(task, "action_classes", task_id), + ) + ) + if "visual-execution" in action_classes: + _fail( + "TASK_VISUAL_EXECUTION_LEAK", + f"{task_id} declares forbidden visual-execution", + ) + if ( + not action_classes.issubset(TASK_ACTION_CLASSES) + or action_classes != {"implementation"} + ): + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} has invalid X2-B action classes", + ) + paths = list(map(str, _require_list(task, "paths", task_id))) + if any(not path or path.endswith(("/", "\\")) for path in paths): + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{task_id} lacks a concrete file/configuration/asset path", + ) + mapping_refs = list( + map(str, _require_list(task, "mapping_refs", task_id)) + ) + unknown_mapping_refs = set(mapping_refs) - set(mappings_by_id) + if unknown_mapping_refs: + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} references unknown {sorted(unknown_mapping_refs)[0]}", + ) + blocked_refs = set(mapping_refs) & blocked_mapping_ids + if blocked_refs: + _fail( + "TASK_X2B_BLOCKER_SUPPRESSED", + f"{task_id} implements blocked {sorted(blocked_refs)[0]}", + ) + review_only_refs = set(mapping_refs) & review_only_mapping_ids + if review_only_refs: + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{task_id} executes review-method-only {sorted(review_only_refs)[0]}", + ) + task_dimensions = set( + map(str, _require_list(task, "implementation_dimensions", task_id)) + ) + for mapping_ref in mapping_refs: + mapping_class = str(_mapping_class(mapping_ref)) + invalid_for_mapping = task_dimensions - MAPPING_DIMENSIONS[mapping_class] + if invalid_for_mapping: + code = ( + "TASK_X2B_ADAPTATION_UNCOVERED" + if mapping_class == "ADP" + else "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED" + ) + _fail( + code, + f"{task_id} has invalid dimension for {mapping_ref}", + ) + extra_dimensions = task_dimensions - set( + map( + str, + mappings_by_id[mapping_ref]["implementation_dimensions"], + ) + ) + if extra_dimensions: + code = ( + "TASK_X2B_ADAPTATION_UNCOVERED" + if mapping_class == "ADP" + else "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED" + ) + _fail( + code, + f"{task_id} invents dimension for {mapping_ref}", + ) + task_ids_by_mapping[mapping_ref].add(task_id) + dimensions_by_mapping[mapping_ref].update(task_dimensions) + for path in paths: + for dimension in task_dimensions: + key = (mapping_ref, path, dimension) + if key in mapping_path_dimensions: + _fail( + "TASK_X2B_MAPPING_DUPLICATE", + f"duplicate task coverage for {mapping_ref}/{path}/{dimension}", + ) + mapping_path_dimensions.add(key) + + traceability_refs = task.get("traceability_refs", []) + if not isinstance(traceability_refs, list): + _fail("TASK_X2B_REF_UNKNOWN", f"{task_id} traceability_refs must be a list") + if any( + not str(ref).startswith(TRACEABILITY_PREFIXES) + or str(ref) not in declared_traceability_refs + for ref in traceability_refs + ): + _fail("TASK_X2B_REF_UNKNOWN", f"{task_id} has unknown traceability ref") + mapped_traceability_refs = { + str(ref) + for mapping_ref in mapping_refs + for ref in mappings_by_id[mapping_ref].get("traceability_refs", []) + } + if not set(map(str, traceability_refs)).issubset(mapped_traceability_refs): + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} reinterprets traceability outside its mapping", + ) + + task_dependencies = task.get("depends_on") + if not isinstance(task_dependencies, list): + _fail("TASK_X2B_REF_UNKNOWN", f"{task_id} depends_on must be a list") + if task_id in task_dependencies: + _fail("TASK_X2B_REF_UNKNOWN", f"{task_id} depends on itself") + parallel = task.get("parallel") + if not isinstance(parallel, bool): + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} parallel marker must be boolean", + ) + if parallel and task_dependencies: + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{task_id} cannot be parallel while dependencies remain", + ) + + ownership_leaks = sorted(_ownership_leaks(task)) + if ownership_leaks: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + f"{task_id} copies Spec-owned {ownership_leaks[0]}", + ) + semantic_leaks = sorted(_spec_semantic_value_leaks(task)) + if semantic_leaks: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + f"{task_id} reinterprets Spec-owned {semantic_leaks[0]}", + ) + visual_leaks = sorted(_visual_execution_leaks(task)) + if visual_leaks: + _fail( + "TASK_VISUAL_EXECUTION_LEAK", + f"{task_id} contains forbidden {visual_leaks[0]}", + ) + + for task_id, task in tasks_by_id.items(): + unknown_dependencies = set(map(str, task.get("depends_on", []))) - set( + tasks_by_id + ) + if unknown_dependencies: + _fail( + "TASK_X2B_REF_UNKNOWN", + f"{task_id} depends on unknown task {sorted(unknown_dependencies)[0]}", + ) + dependency_graph = { + task_id: set(map(str, task.get("depends_on", []))) + for task_id, task in tasks_by_id.items() + } + _validate_acyclic_dependencies(dependency_graph) + + for mapping_id in implementation_mapping_ids: + mapping = mappings_by_id[mapping_id] + if not task_ids_by_mapping[mapping_id]: + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{mapping_id} has no concrete implementation task", + ) + missing_dimensions = set(map(str, mapping["implementation_dimensions"])) - ( + dimensions_by_mapping[mapping_id] + ) + if missing_dimensions: + code = ( + "TASK_X2B_ADAPTATION_UNCOVERED" + if _mapping_class(mapping_id) == "ADP" + else "TASK_X2B_IMPLEMENTATION_DIMENSION_UNCOVERED" + ) + _fail( + code, + f"{mapping_id} lacks task coverage for {sorted(missing_dimensions)[0]}", + ) + + for dependency_ref in map(str, mapping.get("depends_on", [])): + dependency_tasks = task_ids_by_mapping[dependency_ref] + if not dependency_tasks: + continue + for task_id in task_ids_by_mapping[mapping_id]: + if task_id in dependency_tasks: + continue + if not any( + _depends_on(task_id, dependency_task, dependency_graph) + for dependency_task in dependency_tasks + ): + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{task_id} omits dependency from {dependency_ref}", + ) + + task_items = list(tasks_by_id.items()) + for index, (left_id, left) in enumerate(task_items): + left_paths = set(map(str, left.get("paths", []))) + for right_id, right in task_items[index + 1 :]: + if not left_paths.intersection(set(map(str, right.get("paths", [])))): + continue + if not _depends_on( + left_id, + right_id, + dependency_graph, + ) and not _depends_on( + right_id, + left_id, + dependency_graph, + ): + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{left_id} and {right_id} share a path without dependency", + ) + + for mapping_id in implementation_mapping_ids: + mapping_task_ids = task_ids_by_mapping[mapping_id] + preparation_tasks = { + task_id + for task_id in mapping_task_ids + if "asset-preparation" + in set(map(str, tasks_by_id[task_id]["implementation_dimensions"])) + } + binding_tasks = { + task_id + for task_id in mapping_task_ids + if "asset-binding" + in set(map(str, tasks_by_id[task_id]["implementation_dimensions"])) + } + for binding_task in binding_tasks - preparation_tasks: + if preparation_tasks and not any( + _depends_on(binding_task, task_id, dependency_graph) + for task_id in preparation_tasks + ): + _fail( + "TASK_X2B_MAPPING_UNMAPPED", + f"{binding_task} must depend on asset preparation", + ) + + required_tc_refs = set( + map(str, bundle.get("required_test_readiness_tc_refs", [])) + ) + covered_tc_refs: set[str] = set() + for test_task in test_tasks: + action_classes = set( + map( + str, + _require_list( + test_task, + "action_classes", + "functional UI test task", + error_code="TASK_SPEC_OWNERSHIP_LEAK", + ), + ) + ) + if "visual-execution" in action_classes: + _fail( + "TASK_VISUAL_EXECUTION_LEAK", + "functional UI test task declares visual-execution", + ) + if action_classes != {"functional-validation"}: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + "functional UI test task has invalid action classes", + ) + tc_refs = test_task.get("tc_refs") + if not isinstance(tc_refs, list) or not tc_refs: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + "functional UI test task lacks Required Test Readiness TC ref", + ) + unknown_tc_refs = set(map(str, tc_refs)) - required_tc_refs + if unknown_tc_refs: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + f"test task invents {sorted(unknown_tc_refs)[0]} from visual mapping", + ) + covered_tc_refs.update(map(str, tc_refs)) + if covered_tc_refs != required_tc_refs: + _fail( + "TASK_X2B_REF_UNKNOWN", + "Required Test Readiness TC refs do not match generated test tasks", + ) + + final_review = bundle.get("final_review") + if not isinstance(final_review, dict): + _fail("TASK_FINAL_REVIEW_MAPPING_MISSING", "Final Code Review is absent") + if not phases or phases[-1] != "Final Code Review": + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review is not the last mandatory phase", + ) + if ( + final_review.get("phase") != "Final Code Review" + or final_review.get("kind") != "code-design-contract-review" + ): + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review has an invalid review contract", + ) + review_action_classes = set( + map( + str, + _require_list( + final_review, + "action_classes", + "Final Code Review", + error_code="TASK_FINAL_REVIEW_MAPPING_MISSING", + ), + ) + ) + if review_action_classes != {FINAL_REVIEW_ACTION_CLASS}: + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review has an invalid action class", + ) + review_mapping_refs = set( + map( + str, + _require_list( + final_review, + "mapping_refs", + "Final Code Review", + allow_empty=True, + error_code="TASK_FINAL_REVIEW_MAPPING_MISSING", + ), + ) + ) + if review_mapping_refs != implementation_mapping_ids: + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review does not cover every implementation mapping", + ) + review_scopes = set( + map( + str, + _require_list( + final_review, + "scopes", + "Final Code Review", + error_code="TASK_FINAL_REVIEW_MAPPING_MISSING", + ), + ) + ) + required_review_scopes = {"implementation-conformance"} + if mappings: + required_review_scopes.add("x2b-blockers-and-plan-drift") + required_review_scopes.update( + FINAL_REVIEW_SCOPES[str(_mapping_class(mapping_id))] + for mapping_id in implementation_mapping_ids + ) + if not required_review_scopes.issubset(review_scopes): + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review lacks an X2-B implementation scope", + ) + review_paths = set( + map( + str, + _require_list( + final_review, + "paths", + "Final Code Review", + error_code="TASK_FINAL_REVIEW_MAPPING_MISSING", + ), + ) + ) + implementation_paths = { + str(path) + for task in tasks + for path in task.get("paths", []) + } + if not implementation_paths.issubset(review_paths): + _fail( + "TASK_FINAL_REVIEW_MAPPING_MISSING", + "Final Code Review omits an implementation path", + ) + ownership_leaks = sorted(_ownership_leaks(final_review)) + if ownership_leaks: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + f"Final Code Review copies Spec-owned {ownership_leaks[0]}", + ) + semantic_leaks = sorted(_spec_semantic_value_leaks(final_review)) + if semantic_leaks: + _fail( + "TASK_SPEC_OWNERSHIP_LEAK", + f"Final Code Review reinterprets Spec-owned {semantic_leaks[0]}", + ) + visual_leaks = sorted(_visual_execution_leaks(final_review)) + if visual_leaks: + _fail( + "TASK_VISUAL_EXECUTION_LEAK", + f"Final Code Review contains forbidden {visual_leaks[0]}", + ) + + if blocked_mapping_ids: + _fail( + "PLAN_OUTPUT_INCOMPLETE", + "blocked X2-B mappings prevent complete-looking tasks", + ) diff --git a/tests/test_presets.py b/tests/test_presets.py index c8e480dbe3..40754b148e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -4651,7 +4651,7 @@ def test_workflow_preset_catalog_matches_manifest(self): assert catalog_updated_at >= datetime(2026, 6, 18, tzinfo=timezone.utc) assert entry["bundled"] is True - assert entry["version"] == "3.1.1" + assert entry["version"] == "3.2.0" assert entry["version"] == manifest["preset"]["version"] assert entry["repository"] == manifest["preset"]["repository"] assert entry["requires"]["speckit_version"] == manifest["requires"]["speckit_version"]