@michaelxwb/code-flow 0.6.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -42
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,202 @@
1
+ """E2E precision and scope for a TASK start.
2
+
3
+ Plan locks E2E quality fields; Start must turn them into a concrete execution
4
+ scope. A TASK writes tests only for its owned E2E scenarios and runs its owned
5
+ scenarios plus the global smoke set. Unrelated E2E journeys stay deferred
6
+ instead of turning every small change into a full-suite run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Mapping
14
+
15
+ from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
16
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
17
+
18
+ _PLACEHOLDERS = frozenset(("", "-", "n/a", "na", "none", "无", "todo", "tbd", "待定"))
19
+
20
+
21
+ def _meaningful(value: object) -> bool:
22
+ return str(value or "").strip().lower() not in _PLACEHOLDERS
23
+
24
+
25
+ def _task_scenario_ids(task_file: str, task_id: str) -> set[str]:
26
+ text = Path(task_file).read_text(encoding="utf-8")
27
+ section = re.search(
28
+ rf"(?ms)^##\s+{re.escape(task_id)}:.*?(?=^##\s+TASK-|\Z)", text
29
+ )
30
+ if section is None:
31
+ raise ValueError(f"task_not_found: {task_id}")
32
+ refs = re.search(
33
+ r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)
34
+ )
35
+ if refs is None:
36
+ return set()
37
+ return set(re.findall(r"\b(?:S|E|B)-\d+\b", refs.group(1)))
38
+
39
+
40
+ def _item(row: Mapping[str, object]) -> dict[str, object]:
41
+ return {
42
+ **{field: row[field] for field in ("level", "env", "components", "steps", "expected", "expected_refs", "assertions",
43
+ "preconditions", "defer_reason", "defer_owner", "target_phase", "features", "tags")
44
+ if field in row},
45
+ "id": str(row.get("id", "")),
46
+ "title": str(row.get("title", "")),
47
+ "owner": row.get("owner") if isinstance(row.get("owner"), str) else "",
48
+ "priority": row.get("priority") if isinstance(row.get("priority"), str) else "",
49
+ "exec": row.get("exec") if isinstance(row.get("exec"), str) else "",
50
+ "boundary": str(row.get("boundary", "")),
51
+ "why_e2e": str(row.get("why_e2e", "")),
52
+ "trigger_paths": list(row.get("trigger_paths") or []),
53
+ "smoke": bool(row.get("smoke")),
54
+ "check": row.get("check") if isinstance(row.get("check"), str) else "",
55
+ "checks": scenario_check_ids(row),
56
+ "command": list(row.get("command") or []) if isinstance(row.get("command"), list) else [],
57
+ }
58
+
59
+
60
+ def _quality_errors(items: list[dict[str, object]]) -> list[dict[str, object]]:
61
+ errors: list[dict[str, object]] = []
62
+ for item in items:
63
+ scenario_id = str(item.get("id", ""))
64
+ priority = str(item.get("priority", "")).upper()
65
+ if priority not in ("P0", "P1"):
66
+ errors.append({
67
+ "code": "e2e_priority_not_critical",
68
+ "scenario": scenario_id,
69
+ "message": "E2E 只允许 P0/P1 关键旅程;请改为 component/contract/integration。",
70
+ })
71
+ if not _meaningful(item.get("why_e2e")):
72
+ errors.append({
73
+ "code": "e2e_justification_missing",
74
+ "scenario": scenario_id,
75
+ "message": "E2E 必须说明为什么低层测试无法覆盖。",
76
+ })
77
+ if not _meaningful(item.get("boundary")):
78
+ errors.append({
79
+ "code": "e2e_boundary_missing",
80
+ "scenario": scenario_id,
81
+ "message": "E2E 必须声明真实边界。",
82
+ })
83
+ if str(item.get("exec", "")).lower() == "aspirational":
84
+ errors.append({
85
+ "code": "e2e_aspirational",
86
+ "scenario": scenario_id,
87
+ "message": "aspirational E2E 不能进入 TASK 执行范围。",
88
+ })
89
+ trigger_paths = [
90
+ str(path) for path in item.get("trigger_paths", []) or [] if _meaningful(path)
91
+ ]
92
+ if not item.get("smoke") and not trigger_paths:
93
+ errors.append({
94
+ "code": "e2e_trigger_paths_missing",
95
+ "scenario": scenario_id,
96
+ "message": "非 smoke E2E 必须声明触发路径,避免小改动跑全量。",
97
+ })
98
+ return errors
99
+
100
+
101
+ def _group_scenarios(data: Mapping[str, object], owned_ids: set[str], task_id: str
102
+ ) -> tuple[list[dict[str, object]], list[dict[str, object]], list[dict[str, object]]]:
103
+ owned: list[dict[str, object]] = []
104
+ smoke: list[dict[str, object]] = []
105
+ deferred: list[dict[str, object]] = []
106
+ for row in data.get("scenarios", []) or []:
107
+ if not isinstance(row, Mapping):
108
+ continue
109
+ kind = str(row.get("kind") or row.get("level") or "").lower()
110
+ if kind != "e2e":
111
+ continue
112
+ item = _item(row)
113
+ scenario_id = str(item["id"])
114
+ scenario_owner = str(item.get("owner") or "")
115
+ if scenario_id in owned_ids and (not scenario_owner or scenario_owner == task_id):
116
+ owned.append(item)
117
+ elif item["smoke"]:
118
+ smoke.append(item)
119
+ else:
120
+ deferred.append(item)
121
+ return owned, smoke, deferred
122
+
123
+
124
+ def _scoped_quality(data: Mapping[str, object], scheduled: list[dict[str, object]], root: Path
125
+ ) -> tuple[list[dict[str, object]], list[dict[str, object]], bool]:
126
+ errors = _quality_errors(scheduled)
127
+ warnings: list[dict[str, object]] = []
128
+ if data.get("scenarios_file"):
129
+ from codeflow.acceptance.cf_scenario_quality import quality_report
130
+ strict = [{**item, "level": "e2e", "owner": item.get("defer_owner") or item.get("owner", "")}
131
+ for item in scheduled]
132
+ quality_errors = quality_report(strict)["errors"]
133
+ if data.get("quality_schema", 1) >= 2:
134
+ from codeflow.acceptance.cf_check_registration import validate_registration
135
+ errors = quality_errors + validate_registration(str(root), scheduled, data.get("checks", []))["errors"]
136
+ elif scheduled:
137
+ warnings.extend({**item, "code": f"legacy_{item['code']}"} for item in quality_errors)
138
+ warnings.append({"code": "legacy_quality_schema", "message":
139
+ "旧计划保留原 E2E 门禁;补齐 expected_refs/延期信息,运行 acceptance manifest --register-checks 重锁,"
140
+ "再用 --verify-plan 检查质量和测试登记。新质量检查尚未通过。"})
141
+ gate_required = data.get("e2e_gate") in (1, True, "required")
142
+ if errors and not gate_required:
143
+ # Existing projects upgraded mid-flight remain startable, but every
144
+ # legacy E2E is surfaced for re-planning instead of being silently
145
+ # treated as a scoped critical journey.
146
+ warnings.extend({**item, "code": f"legacy_{item['code']}"} for item in errors)
147
+ warnings.append({
148
+ "code": "legacy_e2e_plan",
149
+ "message": "旧 manifest 未声明 why_e2e/trigger_paths;先用 plan --verify-plan 重新锁定。",
150
+ })
151
+ errors = []
152
+ return errors, warnings, gate_required
153
+
154
+
155
+ def build_e2e_plan(
156
+ manifest_file: str,
157
+ task_file: str,
158
+ task_id: str,
159
+ validate_baseline: bool = True,
160
+ ) -> dict[str, object]:
161
+ path = Path(manifest_file)
162
+ owned_ids = _task_scenario_ids(task_file, task_id)
163
+ if not path.is_file():
164
+ return {
165
+ "task": task_id,
166
+ "manifest": str(path),
167
+ "owned": [],
168
+ "smoke": [],
169
+ "scheduled": [],
170
+ "deferred_count": 0,
171
+ "deferred_ids": [],
172
+ "quality": {"ok": True, "errors": [], "warnings": [], "gate": "none"},
173
+ }
174
+ data = load_manifest(path)
175
+ if validate_baseline:
176
+ validate_execution_baseline(path, data)
177
+ owned, smoke, deferred = _group_scenarios(data, owned_ids, task_id)
178
+ # 延后场景不在编码阶段调度:由需求级 verify-e2e/人工门禁执行。
179
+ scheduled = [item for item in [*owned, *smoke] if item.get("exec") != "deferred"]
180
+ deferred.extend(item for item in [*owned, *smoke] if item.get("exec") == "deferred")
181
+ root = next((parent.parent for parent in path.parents if parent.name == ".code-flow"), path.parent)
182
+ errors, warnings, gate_required = _scoped_quality(data, scheduled, root)
183
+ if not scheduled:
184
+ warnings.append({
185
+ "code": "no_scheduled_e2e",
186
+ "message": "本 TASK 没有需要编写或执行的 E2E;不要把其他场景补进来。",
187
+ })
188
+ return {
189
+ "task": task_id,
190
+ "manifest": str(path),
191
+ "owned": owned,
192
+ "smoke": smoke,
193
+ "scheduled": scheduled,
194
+ "deferred_count": len(deferred),
195
+ "deferred_ids": [str(item["id"]) for item in deferred],
196
+ "quality": {
197
+ "ok": not errors,
198
+ "errors": errors,
199
+ "warnings": warnings,
200
+ "gate": "required" if gate_required else "legacy",
201
+ },
202
+ }
@@ -0,0 +1,60 @@
1
+ """Author check registration through real collection before locking a plan."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+
9
+ from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
10
+ from codeflow.acceptance.cf_check_registration import (
11
+ REGISTRATION_FIELDS, CollectionError, register_check, registration_required,
12
+ )
13
+
14
+
15
+ class RegistrationError(ValueError):
16
+ def __init__(self, report: Mapping[str, object]) -> None:
17
+ self.report = dict(report)
18
+ super().__init__("check_registration_failed")
19
+
20
+
21
+ def _overrides(file: str, ids: set[str]) -> dict[str, Mapping[str, object]]:
22
+ if not file:
23
+ return {}
24
+ data = json.loads(Path(file).read_text(encoding="utf-8"))
25
+ checks = data.get("checks") if isinstance(data, dict) else None
26
+ if not isinstance(checks, dict) or set(checks) - ids:
27
+ raise ValueError("registration_file_invalid_check_ids")
28
+ for check_id, config in checks.items():
29
+ if not isinstance(config, dict) or set(config) - set(REGISTRATION_FIELDS):
30
+ raise ValueError(f"registration_file_invalid_fields:{check_id}")
31
+ return checks
32
+
33
+
34
+ def register_checks(manifest: dict[str, object], root: str, file: str = "") -> dict[str, object]:
35
+ checks = manifest.get("checks", [])
36
+ overrides = _overrides(file, {str(check["id"]) for check in checks})
37
+ report: dict[str, object] = {"ok": True, "registered": [], "errors": []}
38
+ deadline = time.monotonic() + 60
39
+ for check in checks:
40
+ check_id = str(check["id"])
41
+ rows = [row for row in manifest["scenarios"] if check_id in scenario_check_ids(row)
42
+ and (registration_required({**row, "level": check.get("level", row.get("level"))})
43
+ or registration_required(row) or check_id in overrides)]
44
+ if not rows:
45
+ continue
46
+ check.update(overrides.get(check_id, {}))
47
+ try:
48
+ issues = register_check(root, check, rows, deadline)
49
+ if issues:
50
+ raise RegistrationError({"ok": False, "errors": issues})
51
+ except (OSError, ValueError) as exc:
52
+ details = exc.report if isinstance(exc, RegistrationError) else (
53
+ exc.details if isinstance(exc, CollectionError) else {})
54
+ report["errors"].append({"check_id": check_id, "error": str(exc), **details})
55
+ else:
56
+ report["registered"].append(check_id)
57
+ report["ok"] = not report["errors"]
58
+ if not report["ok"]:
59
+ raise RegistrationError(report)
60
+ return report
@@ -0,0 +1,73 @@
1
+ """User-confirmed risk acceptance, scoped to the owning TASK and scenarios."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ import re
6
+ from typing import Mapping, Optional
7
+
8
+ from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
9
+
10
+ AGENT_IDENTITIES = frozenset(("agent", "assistant", "claude", "claude code", "codex",
11
+ "opencode", "costrict", "ai", "bot"))
12
+
13
+
14
+ def risk_scenario_ids(data: Mapping[str, object], check_id: str, owner: str) -> list[str]:
15
+ """Resolve the locked plan's ownership; never accept a free-form owner."""
16
+ rows = data.get("scenarios", [])
17
+ if not isinstance(rows, list) or re.fullmatch(r"TASK-\d+", owner) is None:
18
+ return []
19
+ return sorted(str(row["id"]) for row in rows if isinstance(row, Mapping)
20
+ and row.get("owner") == owner and row.get("id")
21
+ and check_id in scenario_check_ids(row))
22
+
23
+
24
+ def _matches_scenario(risk: Mapping[str, object], check: Mapping[str, object],
25
+ scenario: Mapping[str, object]) -> bool:
26
+ if risk.get("owner") != scenario.get("owner"):
27
+ return False
28
+ if check.get("id") not in scenario_check_ids(scenario):
29
+ return False
30
+ ids = risk.get("scenario_ids")
31
+ scenario_id = scenario.get("scenario_ref") or scenario.get("id")
32
+ # Legacy records remain valid only for scenarios owned by the recorded TASK.
33
+ return ids is None or (isinstance(ids, list) and scenario_id in ids)
34
+
35
+
36
+ def accepted_risk(check: Optional[Mapping[str, object]],
37
+ scenario: Optional[Mapping[str, object]] = None) -> Optional[dict[str, object]]:
38
+ if not isinstance(check, Mapping) or not isinstance(check.get("accepted_risk"), Mapping):
39
+ return None
40
+ risk = check["accepted_risk"]
41
+ fields = ("owner", "reason", "confirmed_by", "confirmed_at", "source", "expires_at")
42
+ if any(not isinstance(risk.get(key), str) or not str(risk[key]).strip() for key in fields):
43
+ return None
44
+ if re.fullmatch(r"TASK-\d+", str(risk["owner"])) is None:
45
+ return None
46
+ if str(risk["confirmed_by"]).strip().lower().split(":", 1)[0] in AGENT_IDENTITIES:
47
+ return None
48
+ expires = str(risk["expires_at"]).strip()
49
+ try:
50
+ deadline = datetime.fromisoformat(expires.replace("Z", "+00:00"))
51
+ except ValueError:
52
+ return None
53
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", expires):
54
+ deadline = deadline.replace(hour=23, minute=59, second=59, microsecond=999999)
55
+ if deadline.tzinfo is None:
56
+ deadline = deadline.replace(tzinfo=timezone.utc)
57
+ if deadline <= datetime.now(timezone.utc):
58
+ return None
59
+ if scenario is not None and not _matches_scenario(risk, check, scenario):
60
+ return None
61
+ return dict(risk)
62
+
63
+
64
+ def risk_counts(data: Mapping[str, object]) -> dict[str, int]:
65
+ checks, rows = data.get("checks", []), data.get("scenarios", [])
66
+ checks = checks if isinstance(checks, list) else []
67
+ rows = rows if isinstance(rows, list) else []
68
+ recorded = [check for check in checks if isinstance(check, Mapping)
69
+ and isinstance(check.get("accepted_risk"), Mapping)]
70
+ active = sum(any(accepted_risk(check, row) is not None for row in rows
71
+ if isinstance(row, Mapping)) for check in recorded)
72
+ return {"accepted_risks": active, "recorded_risks": len(recorded),
73
+ "inactive_risks": len(recorded) - active}
@@ -0,0 +1,282 @@
1
+ """Validated acceptance records shared by execution and lifecycle gates."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Mapping, Optional
8
+
9
+
10
+ EXEC_MODES = ("auto", "manual", "deferred", "aspirational")
11
+
12
+
13
+ def scenario_check_ids(row: Mapping[str, object]) -> list[str]:
14
+ """The plural mapping is authoritative when present and non-empty."""
15
+ checks = row.get("checks")
16
+ if isinstance(checks, list) and checks:
17
+ return list(dict.fromkeys(str(value) for value in checks))
18
+ check = row.get("check")
19
+ return [check] if isinstance(check, str) and check else []
20
+
21
+
22
+ def recorded_check_hash(row: Mapping[str, object], check_id: str) -> str:
23
+ """Read one check's proof; never reuse an aggregate hash for many checks."""
24
+ evidence = row.get("evidence")
25
+ if not isinstance(evidence, Mapping):
26
+ return ""
27
+ hashes = evidence.get("check_hashes")
28
+ if isinstance(hashes, Mapping):
29
+ value = hashes.get(check_id)
30
+ elif scenario_check_ids(row) == [check_id]:
31
+ value = evidence.get("input_hash") or evidence.get("fingerprint")
32
+ else:
33
+ return ""
34
+ return value if isinstance(value, str) else ""
35
+
36
+
37
+ def check_outcome(result: Mapping[str, object], check_id: str) -> Mapping[str, object]:
38
+ """Prefer the executed check's outcome over the enclosing scenario verdict."""
39
+ if "check_results" not in result:
40
+ return result # Compatibility with evidence written before per-check outcomes.
41
+ outcomes = result.get("check_results")
42
+ outcome = outcomes.get(check_id) if isinstance(outcomes, Mapping) else None
43
+ shared = {key: value for key, value in result.items()
44
+ if key not in ("status", "exit_code", "risk", "error")}
45
+ return {**shared, "status": "incomplete", "exit_code": 1,
46
+ **(outcome if isinstance(outcome, Mapping) else {})}
47
+
48
+
49
+ def verified_evidence(row: Mapping[str, object], fingerprint: str = "") -> bool:
50
+ evidence = row.get("evidence")
51
+ if row.get("status") != "verified" or not isinstance(evidence, dict):
52
+ return False
53
+ if row.get("kind") != "manual" and row.get("exec") != "manual":
54
+ if fingerprint and evidence.get("fingerprint") != fingerprint:
55
+ return False
56
+ return evidence.get("status") == "passed" and evidence.get("exit_code") == 0
57
+ author, detail = evidence.get("confirmed_by"), evidence.get("evidence")
58
+ agents = {"agent", "assistant", "codex", "claude", "opencode", "costrict"}
59
+ return (isinstance(author, str) and bool(author.strip())
60
+ and author.strip().lower().split(":", 1)[0] not in agents
61
+ and isinstance(detail, str) and bool(detail.strip()))
62
+
63
+
64
+ def validate_scenarios(value: object) -> list[dict[str, object]]:
65
+ if not isinstance(value, list):
66
+ raise ValueError("invalid manifest scenarios: expected list")
67
+ seen: set[str] = set()
68
+ for item in value:
69
+ if not isinstance(item, dict):
70
+ raise ValueError("invalid scenario: expected object")
71
+ scenario_id = item.get("id")
72
+ if not isinstance(scenario_id, str) or not scenario_id.strip():
73
+ raise ValueError("scenario id missing")
74
+ if scenario_id in seen:
75
+ raise ValueError(f"duplicate scenario id: {scenario_id}")
76
+ seen.add(scenario_id)
77
+ for field in ("kind", "owner", "cwd"):
78
+ if field in item and not isinstance(item[field], str):
79
+ raise ValueError(f"invalid scenario {scenario_id}:{field}")
80
+ command = item.get("command")
81
+ if command is not None and (not isinstance(command, list) or not all(isinstance(x, str) for x in command)):
82
+ raise ValueError(f"invalid scenario {scenario_id}:command")
83
+ check = item.get("check")
84
+ if check is not None and not isinstance(check, str):
85
+ raise ValueError(f"invalid scenario {scenario_id}:check")
86
+ checks = item.get("checks")
87
+ if checks is not None and (not isinstance(checks, list) or not all(isinstance(x, str) for x in checks)):
88
+ raise ValueError(f"invalid scenario {scenario_id}:checks")
89
+ timeout = item.get("timeout", 60)
90
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
91
+ raise ValueError(f"invalid scenario {scenario_id}:timeout")
92
+ deps = item.get("depends_on", [])
93
+ if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
94
+ raise ValueError(f"invalid scenario {scenario_id}:depends_on")
95
+ if "runs" in item and not isinstance(item["runs"], list):
96
+ raise ValueError(f"invalid scenario {scenario_id}:runs")
97
+ revision = item.get("revision", 0)
98
+ if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
99
+ raise ValueError(f"invalid scenario {scenario_id}:revision")
100
+ _validate_scenario_metadata(scenario_id, item)
101
+ for item in value:
102
+ unknown = set(item.get("depends_on", [])) - seen
103
+ if unknown:
104
+ raise ValueError(f"acceptance scenario dependency missing: {', '.join(sorted(unknown))}")
105
+ return value
106
+
107
+
108
+ def _validate_scenario_metadata(scenario_id: str, item: Mapping[str, object]) -> None:
109
+ """Execution metadata mirrors design §2.5 and manifest v3 checks."""
110
+ priority = item.get("priority")
111
+ if priority is not None and (not isinstance(priority, str) or not priority.strip()):
112
+ raise ValueError(f"invalid scenario {scenario_id}:priority")
113
+ exec_mode = item.get("exec")
114
+ if exec_mode is not None and (not isinstance(exec_mode, str) or exec_mode not in EXEC_MODES):
115
+ raise ValueError(f"invalid scenario {scenario_id}:exec")
116
+ env = item.get("env")
117
+ if env is not None and (not isinstance(env, str) or not env.strip()):
118
+ raise ValueError(f"invalid scenario {scenario_id}:env")
119
+ inputs = item.get("inputs")
120
+ if inputs is not None and (
121
+ not isinstance(inputs, list) or not all(isinstance(value, str) and value for value in inputs)
122
+ ):
123
+ raise ValueError(f"invalid scenario {scenario_id}:inputs")
124
+ why_e2e = item.get("why_e2e")
125
+ if why_e2e is not None and not isinstance(why_e2e, str):
126
+ raise ValueError(f"invalid scenario {scenario_id}:why_e2e")
127
+ trigger_paths = item.get("trigger_paths")
128
+ if trigger_paths is not None and (
129
+ not isinstance(trigger_paths, list)
130
+ or not all(isinstance(value, str) and value for value in trigger_paths)
131
+ ):
132
+ raise ValueError(f"invalid scenario {scenario_id}:trigger_paths")
133
+ smoke = item.get("smoke")
134
+ if smoke is not None and not isinstance(smoke, bool):
135
+ raise ValueError(f"invalid scenario {scenario_id}:smoke")
136
+ _validate_contract_metadata(scenario_id, item)
137
+
138
+
139
+ def _validate_contract_metadata(scenario_id: str, item: Mapping[str, object]) -> None:
140
+ if "expected_checks" in item:
141
+ from codeflow.acceptance.cf_scenario_quality import validate_expected_checks
142
+ validate_expected_checks(item)
143
+ for field in ("components", "steps", "expected", "assertions", "preconditions", "features", "tags"):
144
+ if field in item and (not isinstance(item[field], list) or not all(isinstance(value, str) for value in item[field])):
145
+ raise ValueError(f"invalid scenario {scenario_id}:{field}")
146
+ for field in ("defer_reason", "defer_owner", "target_phase"):
147
+ if field in item and not isinstance(item[field], str):
148
+ raise ValueError(f"invalid scenario {scenario_id}:{field}")
149
+ refs = item.get("expected_refs", [])
150
+ if not isinstance(refs, list) or not all(isinstance(value, Mapping) for value in refs):
151
+ raise ValueError(f"invalid scenario {scenario_id}:expected_refs")
152
+
153
+
154
+ def validate_checks(value: object) -> list[dict[str, object]]:
155
+ """Validate the manifest v3 checks array (execution identity)."""
156
+ if value is None:
157
+ return []
158
+ if not isinstance(value, list):
159
+ raise ValueError("invalid manifest checks: expected list")
160
+ seen: set[str] = set()
161
+ for item in value:
162
+ if not isinstance(item, dict):
163
+ raise ValueError("invalid check: expected object")
164
+ check_id = item.get("id")
165
+ if not isinstance(check_id, str) or not check_id.strip():
166
+ raise ValueError("check id missing")
167
+ if check_id in seen:
168
+ raise ValueError(f"duplicate check id: {check_id}")
169
+ seen.add(check_id)
170
+ command = item.get("command")
171
+ if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
172
+ raise ValueError(f"invalid check {check_id}:command")
173
+ cwd = item.get("cwd", ".")
174
+ if not isinstance(cwd, str):
175
+ raise ValueError(f"invalid check {check_id}:cwd")
176
+ timeout = item.get("timeout", 60)
177
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
178
+ raise ValueError(f"invalid check {check_id}:timeout")
179
+ _validate_check_environment(check_id, item)
180
+ _validate_check_registration(check_id, item)
181
+ return value
182
+
183
+
184
+ def validate_check_levels(scenarios: list[dict[str, object]], checks: list[dict[str, object]]) -> None:
185
+ """A mixed-level composition must declare every member's level."""
186
+ by_id = {str(check["id"]): check for check in checks}
187
+ for row in scenarios:
188
+ members = [by_id[check_id] for check_id in scenario_check_ids(row) if check_id in by_id]
189
+ levels = {str(check.get("level") or row.get("level") or "") for check in members}
190
+ if len(levels) > 1 and any("level" not in check for check in members):
191
+ raise ValueError(f"scenario {row.get('id')} mixed checks require explicit levels")
192
+
193
+
194
+ def _validate_check_registration(check_id: str, item: Mapping[str, object]) -> None:
195
+ from codeflow.acceptance.cf_scenario_source import LEVELS
196
+ if "level" in item and (not isinstance(item["level"], str) or item["level"] not in LEVELS):
197
+ raise ValueError(f"invalid check {check_id}:level")
198
+ if "suite" in item and item["suite"] not in ("unit", "component", "contract", "integration", "system", "browser", "capacity"):
199
+ raise ValueError(f"invalid check {check_id}:suite")
200
+ for field in ("test_ids", "markers"):
201
+ if field not in item:
202
+ continue
203
+ values = item[field]
204
+ if not isinstance(values, list) or not all(isinstance(value, str) and value.strip() for value in values):
205
+ raise ValueError(f"invalid check {check_id}:{field}")
206
+ if len(values) != len(set(values)):
207
+ raise ValueError(f"duplicate check {check_id}:{field}")
208
+ if "collect" in item and not isinstance(item["collect"], Mapping):
209
+ raise ValueError(f"invalid check {check_id}:collect")
210
+
211
+
212
+ _ENV_SCOPES = ("shared", "isolated")
213
+
214
+
215
+ def _validate_check_environment(check_id: str, item: Mapping[str, object]) -> None:
216
+ """env/env_scope/preflight/parallel_safe describe how a check may run."""
217
+ env = item.get("env", "none")
218
+ if not isinstance(env, str) or not env.strip():
219
+ raise ValueError(f"invalid check {check_id}:env")
220
+ revision = item.get("environment_revision")
221
+ if revision is not None and (not isinstance(revision, str) or not revision.strip()):
222
+ raise ValueError(f"invalid check {check_id}:environment_revision")
223
+ ttl = item.get("evidence_ttl_seconds")
224
+ if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, (int, float))
225
+ or not math.isfinite(ttl) or ttl <= 0):
226
+ raise ValueError(f"invalid check {check_id}:evidence_ttl_seconds")
227
+ scope = item.get("env_scope", "shared")
228
+ if not isinstance(scope, str) or scope not in _ENV_SCOPES:
229
+ raise ValueError(f"invalid check {check_id}:env_scope")
230
+ if "parallel_safe" in item and not isinstance(item["parallel_safe"], bool):
231
+ raise ValueError(f"invalid check {check_id}:parallel_safe")
232
+ preflight = item.get("preflight", [])
233
+ if not isinstance(preflight, list):
234
+ raise ValueError(f"invalid check {check_id}:preflight")
235
+ for entry in preflight:
236
+ if isinstance(entry, str):
237
+ if not entry.strip():
238
+ raise ValueError(f"invalid check {check_id}:preflight")
239
+ elif isinstance(entry, list):
240
+ if not entry or not all(isinstance(token, str) and token for token in entry):
241
+ raise ValueError(f"invalid check {check_id}:preflight")
242
+ else:
243
+ raise ValueError(f"invalid check {check_id}:preflight")
244
+ paths = item.get("paths")
245
+ if paths is not None and (
246
+ not isinstance(paths, list)
247
+ or not all(isinstance(path, str) and path.strip() for path in paths)
248
+ ):
249
+ raise ValueError(f"invalid check {check_id}:paths")
250
+
251
+
252
+ def load_manifest(path: Path, *, text: Optional[str] = None) -> dict[str, object]:
253
+ data = json.loads(path.read_text(encoding="utf-8") if text is None else text)
254
+ if not isinstance(data, dict):
255
+ raise ValueError("invalid acceptance manifest: expected object")
256
+ quality_schema = data.get("quality_schema", 1)
257
+ if type(quality_schema) is not int or quality_schema not in (1, 2):
258
+ raise ValueError(f"unsupported acceptance quality_schema: {quality_schema!r}")
259
+ validate_scenarios(data.get("scenarios"))
260
+ if "checks" in data:
261
+ validate_checks(data.get("checks"))
262
+ validate_check_levels(data["scenarios"], data["checks"])
263
+ check_ids = {
264
+ item.get("id") for item in data.get("checks", [])
265
+ if isinstance(item, dict)
266
+ } if isinstance(data.get("checks"), list) else set()
267
+ for scenario in data.get("scenarios", []):
268
+ if not isinstance(scenario, dict):
269
+ continue
270
+ single = scenario.get("check")
271
+ if isinstance(single, str) and single and single not in check_ids:
272
+ raise ValueError(f"scenario {scenario.get('id')} references missing check: {single}")
273
+ multiple = scenario.get("checks")
274
+ if isinstance(multiple, list):
275
+ unknown = [str(value) for value in multiple if str(value) not in check_ids]
276
+ if unknown:
277
+ raise ValueError(
278
+ f"scenario {scenario.get('id')} references missing checks: {', '.join(unknown)}"
279
+ )
280
+ if "task_file" in data and not isinstance(data["task_file"], str):
281
+ raise ValueError("invalid acceptance manifest: task_file")
282
+ return data