@michaelxwb/code-flow 0.6.4 → 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 -40
  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 +67 -35
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +67 -35
  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 +73 -34
  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 -40
  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 +67 -35
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
  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 -40
  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 +68 -41
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
  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 -1714
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
  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 -380
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
  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} +37 -3
  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} +252 -26
  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 -299
  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,660 @@
1
+ """Extract and validate plan Acceptance Coverage manifests.
2
+
3
+ This low-level acceptance module owns manifest extraction, immutable-field
4
+ hashing and drift validation. The path-frozen CLI entry
5
+ (`codeflow.cli.cf_acceptance_manifest`) only parses arguments and prints output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import shlex
14
+ from pathlib import Path
15
+ from typing import Callable, Mapping, Optional, Sequence
16
+
17
+ from codeflow.acceptance.cf_acceptance_schema import (
18
+ EXEC_MODES, load_manifest, scenario_check_ids, validate_checks, validate_scenarios,
19
+ )
20
+ from codeflow.core.cf_markdown import column_index, frontmatter_block, table_cells
21
+ from codeflow.task.cf_task_doc import TaskDocumentError, parse_task_document
22
+
23
+
24
+ _ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
25
+
26
+ _MANIFEST_SCHEMA = 3
27
+ _LEGACY_MANIFEST_SCHEMAS = (2,)
28
+
29
+
30
+ def kind(level: str) -> str:
31
+ """Map test level to execution kind.
32
+
33
+ - functional: auto-executed in Done Gate (unit, integration)
34
+ - e2e: deferred until explicit --verify-e2e (needs external dependencies)
35
+ - manual: user confirmation required, never auto-executed
36
+
37
+ 层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
38
+ 取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
39
+ """
40
+ head = level.strip().lower()
41
+ for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
42
+ head = head.split(separator, 1)[0]
43
+ head = head.strip()
44
+ if head in ("manual", "手工", "手动"):
45
+ return "manual"
46
+ if head in ("e2e", "端到端", "end"):
47
+ return "e2e"
48
+ return "functional"
49
+
50
+
51
+ _EXEC_ALIASES = {
52
+ "auto": "auto",
53
+ "automatic": "auto",
54
+ "自动化": "auto",
55
+ "manual": "manual",
56
+ "手工": "manual",
57
+ "手动": "manual",
58
+ "deferred": "deferred",
59
+ "延期": "deferred",
60
+ "aspirational": "aspirational",
61
+ "愿望": "aspirational",
62
+ "意向": "aspirational",
63
+ }
64
+
65
+ _COLUMN_NAMES = {
66
+ "source": ("来源设计", "来源", "source"),
67
+ "level": ("测试层级", "测试级别", "level"),
68
+ "boundary": ("关键真实边界", "真实边界", "boundary"),
69
+ "owner": ("负责任务", "负责任任务", "负责人", "owner"),
70
+ "status": ("状态", "status"),
71
+ "command": ("执行命令", "命令", "command"),
72
+ "cwd": ("工作目录", "cwd"),
73
+ "timeout": ("超时", "timeout"),
74
+ "depends": ("依赖", "depends"),
75
+ "priority": ("优先级", "priority"),
76
+ "exec": ("执行方式", "exec"),
77
+ "env": ("环境", "env"),
78
+ "inputs": ("输入", "inputs"),
79
+ "why_e2e": ("E2E理由", "E2E 理由", "为什么E2E", "why_e2e"),
80
+ "trigger_paths": ("触发路径", "影响路径", "trigger_paths"),
81
+ "smoke": ("冒烟", "常驻冒烟", "smoke"),
82
+ }
83
+
84
+
85
+ def _column_indexes(cells: Sequence[str]) -> dict[str, int]:
86
+ """Map table headers to field offsets (fields omit the leading 场景ID cell)."""
87
+ indexes: dict[str, int] = {}
88
+ for key, names in _COLUMN_NAMES.items():
89
+ index = column_index(cells[1:], *names)
90
+ if index >= 0:
91
+ indexes[key] = index
92
+ return indexes
93
+
94
+
95
+ def _column_value(fields: Sequence[str], columns: Mapping[str, int], key: str, fallback: int) -> str:
96
+ """Prefer named headers; only headerless legacy tables use positional fallback."""
97
+ index = columns.get(key, -1) if columns else fallback
98
+ return fields[index].strip() if 0 <= index < len(fields) else ""
99
+
100
+
101
+ def coverage_execution(coverage: str, scenario: str) -> tuple[str, str]:
102
+ """Return (kind, exec mode) for one Acceptance Coverage table row."""
103
+ columns: dict[str, int] = {}
104
+ for line in coverage.splitlines():
105
+ cells = table_cells(line)
106
+ if not cells:
107
+ continue
108
+ header = _column_indexes(cells)
109
+ if "level" in header:
110
+ columns = header
111
+ if cells[0] == scenario:
112
+ fields = cells[1:]
113
+ return (kind(_column_value(fields, columns, "level", 1)),
114
+ _parse_exec(_column_value(fields, columns, "exec", 9)))
115
+ return "", ""
116
+
117
+
118
+ def _metadata_cell(fields: Sequence[str], columns: Mapping[str, int], key: str) -> str:
119
+ index = columns.get(key, -1)
120
+ return fields[index].strip() if 0 <= index < len(fields) else ""
121
+
122
+
123
+ def _parse_priority(cell: str) -> str:
124
+ value = (cell or "").strip().strip("`").upper()
125
+ return value if value and value not in {"-", "N/A"} else ""
126
+
127
+
128
+ def _parse_exec(cell: str) -> str:
129
+ value = (cell or "").strip().strip("`")
130
+ if not value or value in {"-", "N/A"}:
131
+ return ""
132
+ normalized = _EXEC_ALIASES.get(value.lower(), value.lower())
133
+ if normalized not in EXEC_MODES:
134
+ raise ValueError(f"未知执行方式: {value}(允许 {', '.join(EXEC_MODES)})")
135
+ return normalized
136
+
137
+
138
+ def _parse_bool(cell: str, scenario_id: str = "") -> Optional[bool]:
139
+ value = (cell or "").strip().strip("`").lower()
140
+ if not value or value in {"-", "n/a", "none"}:
141
+ return None
142
+ if value in {"true", "yes", "y", "1", "是", "冒烟", "smoke"}:
143
+ return True
144
+ if value in {"false", "no", "n", "0", "否", "不是"}:
145
+ return False
146
+ raise ValueError(f"{scenario_id} 布尔列取值非法: {cell}")
147
+
148
+
149
+ def _parse_inputs(cell: str, scenario_id: str = "") -> list[str]:
150
+ text = (cell or "").strip().strip("`")
151
+ if not text or text in {"-", "none", "N/A", "TBD", "tbd"}:
152
+ return []
153
+ if text.startswith("["):
154
+ try:
155
+ value = json.loads(text)
156
+ except json.JSONDecodeError as exc:
157
+ raise ValueError(f"{scenario_id} 输入列不是合法 JSON 数组: {exc}") from exc
158
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
159
+ raise ValueError(f"{scenario_id} 输入列必须是字符串数组")
160
+ return list(value)
161
+ return [item.strip() for item in re.split(r"[,,、;;]", text) if item.strip()]
162
+
163
+
164
+ def _row_metadata(fields: Sequence[str], columns: Mapping[str, int], scenario_id: str) -> dict[str, object]:
165
+ """Only emit optional fields when explicitly present, preserving schema2/legacy rows."""
166
+ metadata: dict[str, object] = {}
167
+ priority = _parse_priority(_metadata_cell(fields, columns, "priority"))
168
+ if priority:
169
+ metadata["priority"] = priority
170
+ exec_mode = _parse_exec(_metadata_cell(fields, columns, "exec"))
171
+ if exec_mode:
172
+ metadata["exec"] = exec_mode
173
+ env = _metadata_cell(fields, columns, "env").strip("`")
174
+ if env and env not in {"-", "N/A"}:
175
+ metadata["env"] = env
176
+ inputs = _parse_inputs(_metadata_cell(fields, columns, "inputs"), scenario_id)
177
+ if inputs:
178
+ metadata["inputs"] = inputs
179
+ why_e2e = _metadata_cell(fields, columns, "why_e2e").strip("`")
180
+ if why_e2e and why_e2e not in {"-", "N/A", "n/a", "TBD", "tbd"}:
181
+ metadata["why_e2e"] = why_e2e
182
+ trigger_paths = _parse_inputs(_metadata_cell(fields, columns, "trigger_paths"), scenario_id)
183
+ if trigger_paths:
184
+ metadata["trigger_paths"] = trigger_paths
185
+ smoke = _parse_bool(_metadata_cell(fields, columns, "smoke"), scenario_id)
186
+ if smoke is not None:
187
+ metadata["smoke"] = smoke
188
+ return metadata
189
+
190
+
191
+ def _merge_check_config(check: dict[str, object], env: str, inputs: Sequence[str]) -> None:
192
+ """Same execution identity must not declare conflicting env/inputs."""
193
+ check_id = str(check.get("id", ""))
194
+ if env:
195
+ current = str(check.get("env", "none"))
196
+ if current != "none" and current != env:
197
+ raise ValueError(f"check configuration conflict: {check_id} env {current} != {env}")
198
+ check["env"] = env
199
+ if inputs:
200
+ current = list(check.get("inputs") or [])
201
+ if current and current != list(inputs):
202
+ raise ValueError(f"check configuration conflict: {check_id} inputs {current} != {list(inputs)}")
203
+ check["inputs"] = list(inputs)
204
+
205
+
206
+ def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
207
+ """Parse the optional 命令 column: argv JSON array preferred, else shell words.
208
+
209
+ Empty / `-` / `planned` → None (not yet registered, not executable).
210
+ """
211
+ text = (cell or "").strip()
212
+ if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
213
+ return None
214
+ if text.startswith("["):
215
+ try:
216
+ value = json.loads(text)
217
+ except json.JSONDecodeError as exc:
218
+ raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
219
+ if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
220
+ raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
221
+ return list(value)
222
+ return shlex.split(text)
223
+
224
+
225
+ def _parse_float(cell: str, default: float) -> float:
226
+ try:
227
+ return float((cell or "").strip() or default)
228
+ except (TypeError, ValueError):
229
+ return default
230
+
231
+
232
+ def _manifest_hash(rows: list[dict[str, object]]) -> str:
233
+ immutable = []
234
+ for item in rows:
235
+ entry = {
236
+ key: item[key] for key in
237
+ ("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")
238
+ }
239
+ # Optional E2E precision fields are hashed only when present so old
240
+ # manifests keep their historical baseline hash.
241
+ for key in ("why_e2e", "trigger_paths", "smoke", "components", "steps", "expected",
242
+ "expected_refs", "expected_checks", "assertions", "preconditions", "defer_reason", "defer_owner", "target_phase", "features", "tags"):
243
+ if key in item:
244
+ entry[key] = item[key]
245
+ if "expected_checks" in item:
246
+ entry["checks"] = item.get("checks", [])
247
+ immutable.append(entry)
248
+ payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
249
+ return hashlib.sha256(payload).hexdigest()
250
+
251
+
252
+ def legacy_kind(level: str) -> str:
253
+ """v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
254
+ normalized = level.strip().lower()
255
+ if normalized == "manual":
256
+ return "manual"
257
+ if normalized == "e2e":
258
+ return "e2e"
259
+ return "functional"
260
+
261
+
262
+ def contract_commands(text: str) -> dict[str, str]:
263
+ """Acceptance Contract 表的执行命令(Coverage 表无命令列时的注册回退)。
264
+
265
+ Coverage 模板长期只有 6 列,命令实际写在 TASK 段落 Contract 表;提取器只
266
+ 读 Coverage 会让新需求 finish 必现 no_executable_scenarios。这里按场景 ID
267
+ 回退读取 Contract 的“执行命令”列(按表头定位,兼容列序差异),与 Coverage
268
+ 第 7 列(argv JSON)等价。
269
+ """
270
+ commands: dict[str, str] = {}
271
+ try:
272
+ document = parse_task_document(text)
273
+ except TaskDocumentError as exc:
274
+ if exc.code == "task_document_empty":
275
+ return commands
276
+ raise
277
+ for section in document.tasks:
278
+ contract = re.search(
279
+ r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section.body
280
+ )
281
+ if contract is None:
282
+ continue
283
+ command_idx = -1
284
+ for line in contract.group(1).splitlines():
285
+ cells = table_cells(line)
286
+ if not cells:
287
+ continue
288
+ if command_idx == -1:
289
+ match_column = column_index(cells, "命令")
290
+ if match_column == -1:
291
+ continue
292
+ command_idx = match_column
293
+ continue
294
+ match = _ROW_RE.match(line)
295
+ if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
296
+ continue
297
+ if command_idx < len(cells):
298
+ commands.setdefault(match.group(1), cells[command_idx])
299
+ return commands
300
+
301
+
302
+ def _check_id(command: list[str], cwd: str, timeout: float, config: Mapping[str, object] | None = None) -> str:
303
+ payload = json.dumps([command, cwd, timeout, config or {}], ensure_ascii=False, sort_keys=True).encode("utf-8")
304
+ return "CHK-" + hashlib.sha256(payload).hexdigest()[:12]
305
+
306
+
307
+ def _derive_checks(rows: list[dict[str, object]]) -> list[dict[str, object]]:
308
+ """Derive executable checks from scenario command identity (stable IDs)."""
309
+ by_id: dict[str, dict[str, object]] = {}
310
+ by_command: dict[tuple, dict[str, object]] = {}
311
+ for row in rows:
312
+ command = row.get("command")
313
+ if not isinstance(command, list) or not command:
314
+ continue
315
+ cwd = str(row.get("cwd", "."))
316
+ timeout = float(row.get("timeout", 60))
317
+ trigger_paths = [
318
+ str(item) for item in row.get("trigger_paths", []) or [] if str(item).strip()
319
+ ]
320
+ config = {
321
+ "env": row.get("env") if isinstance(row.get("env"), str) else "",
322
+ "inputs": list(row.get("inputs") or []),
323
+ }
324
+ identity = {
325
+ key: row.get(key) for key in
326
+ ("env", "env_scope", "preflight", "parallel_safe", "profile", "inputs")
327
+ }
328
+ command_key = (tuple(command), cwd, timeout)
329
+ previous = by_command.setdefault(command_key, {"env": "", "inputs": []})
330
+ if config["env"] and previous["env"] and config["env"] != previous["env"]:
331
+ raise ValueError(f"check configuration conflict: {row.get('id')} {command_key} env")
332
+ if config["inputs"] and previous["inputs"] and config["inputs"] != previous["inputs"]:
333
+ raise ValueError(f"check configuration conflict: {row.get('id')} {command_key} inputs")
334
+ previous["env"] = previous["env"] or config["env"]
335
+ previous["inputs"] = previous["inputs"] or config["inputs"]
336
+ check_id = _check_id(command, cwd, timeout, identity)
337
+ row["check"] = check_id
338
+ check = by_id.get(check_id)
339
+ if check is None:
340
+ check = {
341
+ "id": check_id,
342
+ "command": list(command),
343
+ "cwd": cwd,
344
+ "timeout": timeout,
345
+ "env": "none",
346
+ "env_scope": "shared",
347
+ "parallel_safe": False,
348
+ "profile": "changed",
349
+ "inputs": [],
350
+ "preflight": [],
351
+ "paths": [],
352
+ }
353
+ by_id[check_id] = check
354
+ env = row.get("env")
355
+ inputs = row.get("inputs")
356
+ _merge_check_config(
357
+ check,
358
+ env if isinstance(env, str) else "",
359
+ inputs if isinstance(inputs, list) else [],
360
+ )
361
+ if trigger_paths:
362
+ check["paths"] = sorted(set(check.get("paths") or []) | set(trigger_paths))
363
+ if row.get("smoke"):
364
+ check["profile"] = "smoke"
365
+ return list(by_id.values())
366
+
367
+
368
+ _SCENARIO_SOURCE_FIELDS = ("why_e2e", "trigger_paths", "smoke", "components", "steps", "expected", "assertions",
369
+ "expected_refs", "expected_checks", "checks", "preconditions", "defer_reason", "target_phase", "features", "tags")
370
+
371
+
372
+ def _source_contract(scenarios_file: str,
373
+ text: Optional[str] = None) -> tuple[dict[str, object], dict[str, set[str]]]:
374
+ import yaml
375
+ from codeflow.acceptance.cf_scenario_source import ScenarioError, parse_scenarios
376
+
377
+ try:
378
+ source = parse_scenarios(scenarios_file, text=text)
379
+ raw_text = Path(scenarios_file).read_text(encoding="utf-8") if text is None else text
380
+ block = frontmatter_block(raw_text)
381
+ if block is None:
382
+ if raw_text.startswith("---"):
383
+ raise ScenarioError("scenario_source_invalid_frontmatter", "frontmatter 缺少闭合 ---")
384
+ block = raw_text
385
+ raw = yaml.safe_load(block) or {}
386
+ except (OSError, UnicodeError, yaml.YAMLError, ScenarioError) as exc:
387
+ code = getattr(exc, "code", "scenario_source_unreadable")
388
+ message = getattr(exc, "message", str(exc))
389
+ raise ValueError(f"scenario_source_invalid: {code}: {message}") from exc
390
+ raw_scenarios = raw.get("scenarios") if isinstance(raw, Mapping) else None
391
+ explicit = {
392
+ str(item.get("id")): set(item)
393
+ for item in raw_scenarios or []
394
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
395
+ }
396
+ return source, explicit
397
+
398
+
399
+ def _merge_scenario_source(rows: list[dict[str, object]], scenarios_file: str,
400
+ text: Optional[str] = None) -> None:
401
+ """Merge E2E precision fields from the canonical scenario source.
402
+
403
+ The Acceptance Coverage table is a projection. Quality fields live in the
404
+ scenario source so a plan cannot silently lose its E2E justification when
405
+ the table is regenerated. Only additive fields are merged; a populated
406
+ Coverage cell is never overwritten by a default value.
407
+ """
408
+ if not scenarios_file:
409
+ return
410
+ source, explicit = _source_contract(scenarios_file, text)
411
+ by_id = {
412
+ str(item.get("id")): item for item in rows
413
+ if isinstance(item, dict) and isinstance(item.get("id"), str)
414
+ }
415
+ for scenario in source["scenarios"]:
416
+ scenario_id = str(scenario.get("id"))
417
+ row = by_id.get(scenario_id)
418
+ if row is None:
419
+ continue
420
+ provided = explicit.get(scenario_id, set())
421
+ # The scenario source is canonical for fields it actually declares.
422
+ # Explicit false/empty values must be able to clear a stale table cell.
423
+ for field in ("level", "priority", "boundary", "exec",
424
+ "why_e2e", "trigger_paths", "smoke", "env", "components", "steps", "expected",
425
+ "expected_refs", "expected_checks", "checks", "assertions", "preconditions", "defer_reason", "target_phase", "features", "tags"):
426
+ if field in provided:
427
+ row[field] = scenario.get(field)
428
+ if "owner" in provided:
429
+ row["defer_owner"] = scenario.get("owner", "")
430
+ row["kind"] = kind(str(row.get("level", "")))
431
+ for field in _SCENARIO_SOURCE_FIELDS:
432
+ if field in provided:
433
+ continue
434
+ value = scenario.get(field)
435
+ if value not in (None, "", [], False):
436
+ row[field] = value
437
+ if not row.get("priority") and scenario.get("priority"):
438
+ row["priority"] = scenario["priority"]
439
+ if not row.get("boundary") and scenario.get("boundary"):
440
+ row["boundary"] = scenario["boundary"]
441
+
442
+
443
+ def _extract_rows(task_file: str, kind_fn: Callable[[str], str], contract_fallback: bool = True,
444
+ scenarios_file: str = "", merge_source: bool = True, *,
445
+ text: Optional[str] = None, scenario_text: Optional[str] = None) -> dict[str, object]:
446
+ path = Path(task_file)
447
+ text = path.read_text(encoding="utf-8") if text is None else text
448
+ contract_command_cells = contract_commands(text) if contract_fallback else {}
449
+ rows: list[dict[str, object]] = []
450
+ in_table = False
451
+ seen: set[str] = set()
452
+ columns: dict[str, int] = {}
453
+ for line in text.splitlines():
454
+ if line.startswith("## Acceptance Coverage"):
455
+ in_table = True
456
+ continue
457
+ if in_table and line.startswith("## "):
458
+ break
459
+ if not in_table:
460
+ continue
461
+ header = table_cells(line)
462
+ if header and header[0].replace(" ", "") in ("场景ID", "场景编号"):
463
+ columns = _column_indexes(header)
464
+ continue
465
+ match = _ROW_RE.match(line)
466
+ if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
467
+ continue
468
+ fields = [item.strip() for item in match.group(2).split("|")]
469
+ if match.group(1) in seen:
470
+ raise ValueError(f"duplicate scenario id: {match.group(1)}")
471
+ if len(fields) < 5:
472
+ raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
473
+ seen.add(match.group(1))
474
+ depends_cell = _column_value(fields, columns, "depends", 8)
475
+ depends = [item.strip() for item in depends_cell.split(",") if item.strip()]
476
+ level = _column_value(fields, columns, "level", 1)
477
+ # Legacy coverage tables sometimes append a command cell without a
478
+ # header; keep the positional read when the header has no command column.
479
+ if "command" in columns:
480
+ command_cell = _column_value(fields, columns, "command", 5)
481
+ else:
482
+ command_cell = fields[5].strip() if len(fields) > 5 else ""
483
+ if not command_cell:
484
+ command_cell = contract_command_cells.get(match.group(1), "")
485
+ row: dict[str, object] = {
486
+ "id": match.group(1),
487
+ "source": _column_value(fields, columns, "source", 0),
488
+ "level": level,
489
+ "kind": kind_fn(level),
490
+ "boundary": _column_value(fields, columns, "boundary", 2),
491
+ "owner": _column_value(fields, columns, "owner", 3),
492
+ "status": _column_value(fields, columns, "status", 4),
493
+ "command": _parse_command(command_cell, match.group(1)),
494
+ "cwd": _column_value(fields, columns, "cwd", 6) or ".",
495
+ "timeout": _parse_float(_column_value(fields, columns, "timeout", 7), 60.0),
496
+ "depends_on": depends,
497
+ }
498
+ row.update(_row_metadata(fields, columns, match.group(1)))
499
+ rows.append(row)
500
+ if not rows:
501
+ raise ValueError("Acceptance Coverage 缺失或为空")
502
+ if merge_source and not scenarios_file:
503
+ candidate = Path(task_file).with_suffix(".scenarios.md")
504
+ if candidate.is_file():
505
+ scenarios_file = str(candidate)
506
+ if merge_source:
507
+ _merge_scenario_source(rows, scenarios_file, scenario_text)
508
+ validate_scenarios(rows)
509
+ checks = _derive_checks(rows)
510
+ return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows),
511
+ "checks": checks, "scenarios": rows}
512
+
513
+
514
+ def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = kind,
515
+ contract_fallback: bool = True,
516
+ scenarios_file: str = "", merge_source: bool = True, *,
517
+ task_text: Optional[str] = None,
518
+ scenario_text: Optional[str] = None) -> dict[str, object]:
519
+ return _extract_rows(task_file, kind_fn, contract_fallback, scenarios_file, merge_source,
520
+ text=task_text, scenario_text=scenario_text)
521
+
522
+
523
+ IMMUTABLE_SCENARIO_FIELDS = (
524
+ "id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on", "check", "checks", "expected_checks",
525
+ "priority", "exec", "env", "inputs", "why_e2e", "trigger_paths", "smoke",
526
+ "components", "steps", "expected", "expected_refs", "assertions", "preconditions", "defer_reason", "defer_owner",
527
+ "target_phase", "features", "tags",
528
+ )
529
+
530
+ _OPTIONAL_SCENARIO_FIELDS = ("priority", "exec", "env", "inputs", "why_e2e", "trigger_paths", "smoke")
531
+
532
+
533
+ def _heal_legacy_manifest(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
534
+ """旧版提取规则锁定的 manifest:任务内容未变时原地升级并重锁哈希。
535
+
536
+ 两种历史规则:`_kind` 精确匹配把带注解的 E2E 锁成 functional;Coverage 无
537
+ 命令列时不回退 Contract(command=None)。两类都不改变任务事实,按当前提取
538
+ 结果对齐不可变字段并重锁,保留 execution evidence 等运行态字段。
539
+ """
540
+ by_id: dict[str, dict[str, object]] = {}
541
+ scenarios = expected.get("scenarios")
542
+ if isinstance(scenarios, list):
543
+ for item in scenarios:
544
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
545
+ by_id[item["id"]] = item
546
+ current = manifest.get("scenarios")
547
+ if isinstance(current, list):
548
+ for item in current:
549
+ if not isinstance(item, dict):
550
+ continue
551
+ target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
552
+ if target is not None:
553
+ for field in IMMUTABLE_SCENARIO_FIELDS:
554
+ if field in target:
555
+ item[field] = target[field]
556
+ manifest["task_sha256"] = expected["task_sha256"]
557
+ manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
558
+
559
+
560
+ def validate_manifest(task_file: str, manifest_file: str,
561
+ allow_missing_e2e_gate: bool = False, *,
562
+ task_text: Optional[str] = None,
563
+ manifest: Optional[dict[str, object]] = None,
564
+ scenario_file: str = "",
565
+ scenario_text: Optional[str] = None) -> tuple[bool, str]:
566
+ try:
567
+ manifest = load_manifest(Path(manifest_file)) if manifest is None else manifest
568
+ stored_source = manifest.get("scenarios_file")
569
+ source_path = ""
570
+ if isinstance(stored_source, str) and stored_source.strip():
571
+ candidate = Path(manifest_file).parent / stored_source
572
+ if not candidate.is_file():
573
+ return False, "acceptance_manifest_scenarios_missing"
574
+ source_path = str(candidate)
575
+ else:
576
+ fallback = Path(task_file).with_suffix(".scenarios.md")
577
+ if scenario_file and fallback.is_file() and fallback.resolve() == Path(scenario_file).resolve():
578
+ source_path = scenario_file
579
+ expected = extract_manifest(task_file, scenarios_file=source_path, task_text=task_text,
580
+ scenario_text=scenario_text if source_path else None)
581
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
582
+ return False, f"acceptance_manifest_invalid: {exc}"
583
+ schema = manifest.get("schema")
584
+ if schema != _MANIFEST_SCHEMA and schema not in _LEGACY_MANIFEST_SCHEMAS:
585
+ return False, "acceptance_manifest_drift"
586
+ if manifest.get("task_sha256") != expected["task_sha256"]:
587
+ # 旧版规则(无 Contract 回退 + _kind 精确匹配)锁定的 manifest:兼容升级。
588
+ legacy = extract_manifest(task_file, legacy_kind, contract_fallback=False, merge_source=False,
589
+ task_text=task_text)
590
+ if manifest.get("task_sha256") != legacy["task_sha256"]:
591
+ return False, "acceptance_manifest_drift"
592
+ _heal_legacy_manifest(Path(manifest_file), manifest, expected)
593
+ stored_task = manifest.get("task_file", "")
594
+ if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
595
+ return False, "acceptance_manifest_task_mismatch"
596
+ expected_quality = any(
597
+ any(field in scenario for field in ("why_e2e", "trigger_paths", "smoke"))
598
+ for scenario in expected.get("scenarios", []) if isinstance(scenario, Mapping)
599
+ )
600
+ if expected_quality and manifest.get("e2e_gate") != 1 and not allow_missing_e2e_gate:
601
+ return False, "acceptance_manifest_e2e_gate_missing"
602
+ if not isinstance(manifest.get("scenarios"), list):
603
+ return False, "acceptance_manifest_scenarios_changed"
604
+ if schema == _MANIFEST_SCHEMA:
605
+ checks = manifest.get("checks")
606
+ try:
607
+ validate_checks(checks)
608
+ except ValueError as exc:
609
+ return False, f"acceptance_manifest_checks_invalid: {exc}"
610
+ expected_checks = {item["id"]: item for item in expected.get("checks", [])}
611
+ if {item.get("id") for item in checks if isinstance(item, Mapping)} != set(expected_checks):
612
+ return False, "acceptance_manifest_checks_changed"
613
+ stored_checks = {
614
+ item.get("id"): item for item in checks
615
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
616
+ }
617
+ for check_id, expected_check in expected_checks.items():
618
+ expected_paths = expected_check.get("paths")
619
+ if expected_paths and stored_checks.get(check_id, {}).get("paths") != expected_paths:
620
+ return False, "acceptance_manifest_check_scope_changed"
621
+ stored = {
622
+ item.get("id"): item
623
+ for item in manifest["scenarios"]
624
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
625
+ }
626
+ required = {item["id"] for item in expected["scenarios"]}
627
+ if set(stored) != required:
628
+ return False, "acceptance_manifest_scenarios_changed"
629
+ for item in expected["scenarios"]:
630
+ current = stored[item["id"]]
631
+ for field in IMMUTABLE_SCENARIO_FIELDS:
632
+ if field in ("check", "checks") and "checks" not in current:
633
+ continue
634
+ if field in _OPTIONAL_SCENARIO_FIELDS and field not in current:
635
+ continue
636
+ if current.get(field) != item.get(field):
637
+ return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
638
+ text = Path(task_file).read_text(encoding="utf-8") if task_text is None else task_text
639
+ for scenario in expected["scenarios"]:
640
+ owner = scenario["owner"]
641
+ section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
642
+ refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
643
+ if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
644
+ return False, "acceptance_manifest_owner_missing"
645
+ return True, ""
646
+
647
+
648
+ def validate_execution_baseline(path: Path, data: Mapping[str, object]) -> None:
649
+ """Legacy standalone manifests remain usable; locked plans must match."""
650
+ task_name = data.get("task_file")
651
+ if not isinstance(task_name, str) or not task_name:
652
+ return
653
+ task = Path(task_name)
654
+ task = task if task.is_absolute() else path.parent / task
655
+ if not task.is_file():
656
+ raise ValueError(f"acceptance task file missing: {task}")
657
+ if "## Acceptance Coverage" in task.read_text(encoding="utf-8"):
658
+ valid, reason = validate_manifest(str(task), str(path))
659
+ if not valid:
660
+ raise ValueError(reason)