@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,1490 @@
1
+ #!/usr/bin/env python3
2
+ """Execute manifest scenarios with deterministic ordering, checkpoints and resume.
3
+
4
+ Execution is scheduled per check: a check result is persisted as soon as it
5
+ completes, so an interrupted run resumes from the first unfinished/failed check
6
+ instead of starting over. Independent parallel-safe checks may overlap;
7
+ `depends_on` edges serialize them. A global deadline or a failure budget stops
8
+ new work and reports unrun checks as `incomplete`, never as failed/verified.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import time
20
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ import sys
25
+ from typing import Callable, IO, Mapping, Optional, Sequence
26
+
27
+ from codeflow.core import cf_fs
28
+ from codeflow.core.cf_exec_base import (
29
+ execution_environment_hash,
30
+ execution_key,
31
+ invalidate_executions,
32
+ record_report,
33
+ redact_sensitive_payload,
34
+ redact_sensitive_text,
35
+ remaining_seconds,
36
+ run_command,
37
+ worktree_fingerprint,
38
+ )
39
+ from codeflow.acceptance.cf_check_inputs import check_input_hashes, match_path
40
+ from codeflow.acceptance.cf_acceptance_selection import (
41
+ select_owned as _owned, select_profile as _select_profile, select_scenarios as _select_scenarios,
42
+ )
43
+ from codeflow.acceptance.cf_acceptance_risk import accepted_risk as _accepted_risk
44
+ from codeflow.acceptance.cf_acceptance_manifest_core import validate_execution_baseline
45
+ from codeflow.acceptance.cf_acceptance_schema import (check_outcome, load_manifest, recorded_check_hash,
46
+ scenario_check_ids)
47
+ from codeflow.acceptance.cf_acceptance_evidence import persist_results
48
+ from codeflow.evidence.cf_evidence_store import EvidenceStore
49
+ from codeflow.core.cf_worktree_env import (
50
+ SharedEnvLockTimeout,
51
+ check_environment,
52
+ effective_parallel_safe,
53
+ env_scope,
54
+ is_isolated,
55
+ preflight_commands,
56
+ resolve_run_id,
57
+ shared_env_lock,
58
+ worktree_namespace,
59
+ )
60
+
61
+ DEFAULT_JOBS = 4
62
+
63
+
64
+ class AcceptanceInterrupted(KeyboardInterrupt):
65
+ """A checkpointed run was interrupted; carries the partial, truthful payload."""
66
+
67
+ def __init__(self, payload: Mapping[str, object]) -> None:
68
+ super().__init__("acceptance run interrupted")
69
+ self.payload = dict(payload)
70
+
71
+
72
+ def _scenario_cwd(root: Path, item: Mapping[str, object]) -> str:
73
+ cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
74
+ return str(root / cwd)
75
+
76
+
77
+ def _scenario_timeout(item: Mapping[str, object]) -> float:
78
+ timeout = item.get("timeout", 60)
79
+ return float(timeout) if isinstance(timeout, (int, float)) else 60.0
80
+
81
+
82
+ def _included(item: Mapping[str, object], include_e2e: bool, only_e2e: bool) -> bool:
83
+ """Whether the scenario would actually execute under the current flags."""
84
+ kind = str(item.get("kind", "functional"))
85
+ mode = str(item.get("exec", ""))
86
+ if kind == "manual" or mode in ("manual", "aspirational"):
87
+ return False
88
+ deferred = kind == "e2e" or mode == "deferred"
89
+ if only_e2e:
90
+ return deferred
91
+ if deferred:
92
+ return include_e2e
93
+ return True
94
+
95
+
96
+ def _reusable(row: Optional[Mapping[str, object]], item: Mapping[str, object], fingerprint: str) -> bool:
97
+ """Reuse a passed check whose evidence matches current identity and content.
98
+
99
+ Reuse is content-addressed (like the validation cache): same command/cwd/
100
+ timeout AND unchanged non-ignored worktree. Scenarios with `depends_on`
101
+ never reuse (dependency side effects are not captured by the fingerprint),
102
+ failures are never stored, and `--no-cache` disables reuse entirely.
103
+ """
104
+ if not fingerprint or not isinstance(row, Mapping):
105
+ return False
106
+ if item.get("depends_on"):
107
+ return False
108
+ if not item.get("scenario_ref") and row.get("status") != "verified":
109
+ return False
110
+ if not item.get("scenario_ref"):
111
+ if row.get("check") != item.get("check"):
112
+ return False
113
+ if row.get("check_config") and item.get("check_config") and row.get("check_config") != item.get("check_config"):
114
+ return False
115
+ evidence = row.get("evidence")
116
+ if not isinstance(evidence, Mapping):
117
+ return False
118
+ if item.get("scenario_ref"):
119
+ outcome = check_outcome(evidence, str(item.get("check")))
120
+ if row.get("status") != "verified" and outcome is evidence:
121
+ return False # Legacy aggregate failures cannot prove a passing member.
122
+ evidence = outcome
123
+ if evidence.get("status") != "passed" or evidence.get("exit_code") != 0:
124
+ return False
125
+ recorded = evidence.get("fingerprint")
126
+ if item.get("scenario_ref"):
127
+ recorded = recorded_check_hash(row, str(item.get("check")))
128
+ if recorded != fingerprint:
129
+ return False
130
+ if evidence.get("command") != item.get("command"):
131
+ return False
132
+ if str(evidence.get("cwd", ".")) != str(item.get("cwd", ".")):
133
+ return False
134
+ return float(evidence.get("timeout", 60)) == _scenario_timeout(item)
135
+
136
+
137
+ def _command_outcome(
138
+ item: Mapping[str, object], root: Path, deadline: Optional[float],
139
+ env: Optional[Mapping[str, str]] = None,
140
+ ) -> dict[str, object]:
141
+ """Execute one command identity and normalize its execution status."""
142
+ command = item.get("command")
143
+ if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
144
+ return {"status": "not_configured"}
145
+ timeout_value = _scenario_timeout(item)
146
+ cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
147
+ identity = {key: item[key] for key in ("suite", "test_ids", "markers", "collect", "level", "environment_revision") if key in item}
148
+ outcome = run_command(command, str(root / cwd), timeout_value, deadline, env=env, identity_context=identity)
149
+ result = _normalize_command_outcome(outcome)
150
+ result["execution_ms"] = float(outcome.get("execution_ms", 0))
151
+ return result
152
+
153
+
154
+ def _normalize_command_outcome(outcome: Mapping[str, object]) -> dict[str, object]:
155
+ if outcome["status"] == "deadline_exceeded":
156
+ return {"status": "incomplete", "error": "deadline_exceeded"}
157
+ if outcome["status"] == "timeout":
158
+ return {"status": "failed", "error": "timeout",
159
+ "output": redact_sensitive_text(str(outcome.get("stdout", "")))}
160
+ if outcome["status"] == "spawn_error":
161
+ return {"status": "failed", "error": redact_sensitive_text(str(outcome.get("stderr", "")))}
162
+ if outcome["returncode"] == 0:
163
+ return {"status": "passed", "exit_code": 0}
164
+ output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-4000:]
165
+ return {"status": "failed", "exit_code": outcome["returncode"], "output": redact_sensitive_text(output)}
166
+
167
+
168
+ def _run(
169
+ item: Mapping[str, object], root: Path, include_e2e: bool = False, deadline: Optional[float] = None,
170
+ only_e2e: bool = False, env: Optional[Mapping[str, str]] = None,
171
+ ) -> dict[str, object]:
172
+ scenario_id = str(item.get("id", "unknown"))
173
+ kind = str(item.get("kind", "functional"))
174
+ mode = str(item.get("exec", ""))
175
+ if mode == "aspirational":
176
+ return {"id": scenario_id, "kind": kind, "status": "not_included", "reason": "aspirational"}
177
+ if kind == "manual" or mode == "manual":
178
+ return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
179
+ deferred = kind == "e2e" or mode == "deferred"
180
+ if only_e2e and not deferred:
181
+ return {"id": scenario_id, "kind": kind, "status": "not_included"}
182
+ if deferred and not include_e2e and not only_e2e:
183
+ return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
184
+ outcome = _command_outcome(item, root, deadline, env)
185
+ return {"id": scenario_id, "kind": kind, **outcome}
186
+
187
+
188
+ def _preflight_failures(
189
+ check: Mapping[str, object], root: Path, env: Optional[Mapping[str, str]], deadline: Optional[float]
190
+ ) -> list[dict[str, object]]:
191
+ """Probe declared preflight commands; each failure is a missing dependency."""
192
+ cwd = check.get("cwd", ".") if isinstance(check.get("cwd", "."), str) else "."
193
+ timeout = _scenario_timeout(check)
194
+ failures: list[dict[str, object]] = []
195
+ for argv in preflight_commands(check):
196
+ outcome = run_command(argv, str(root / cwd), timeout, deadline, env=env)
197
+ status = outcome["status"]
198
+ item: dict[str, object] = {
199
+ "command": list(argv),
200
+ "cwd": cwd,
201
+ "timeout": timeout,
202
+ "reason": str(status),
203
+ }
204
+ if status == "deadline_exceeded":
205
+ item["exit_code"] = None
206
+ failures.append(item)
207
+ break
208
+ if status == "timeout":
209
+ item["exit_code"] = None
210
+ item["output"] = redact_sensitive_text(str(outcome.get("stdout", ""))[-2000:])
211
+ failures.append(item)
212
+ continue
213
+ if status == "spawn_error":
214
+ item["exit_code"] = None
215
+ item["output"] = redact_sensitive_text(str(outcome.get("stderr", ""))[-2000:])
216
+ failures.append(item)
217
+ continue
218
+ if outcome["returncode"] != 0:
219
+ item["exit_code"] = outcome["returncode"]
220
+ output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()
221
+ item["output"] = redact_sensitive_text(output[-2000:])
222
+ item["reason"] = "exit_code"
223
+ failures.append(item)
224
+ return failures
225
+
226
+
227
+ def _env_blocked_result(
228
+ item: Mapping[str, object], check_id: str, missing: Sequence[Mapping[str, object]]
229
+ ) -> dict[str, object]:
230
+ return {
231
+ "id": str(item.get("id", "unknown")),
232
+ "kind": str(item.get("kind", "functional")),
233
+ "status": "env_blocked",
234
+ "check_id": check_id,
235
+ "owner": str(item.get("owner", "") or ""),
236
+ "missing": [dict(entry) for entry in missing],
237
+ "error": "env_preflight_failed",
238
+ }
239
+
240
+
241
+ def _individual_results(results: Sequence[Mapping[str, object]]) -> list[Mapping[str, object]]:
242
+ """Expose each member's diagnostics without attributing them to the scenario."""
243
+ individual: list[Mapping[str, object]] = []
244
+ for result in results:
245
+ outcomes = result.get("check_results")
246
+ if not isinstance(outcomes, Mapping):
247
+ individual.append(result)
248
+ continue
249
+ for check_id in outcomes:
250
+ individual.append({**check_outcome(result, str(check_id)), "check_id": str(check_id)})
251
+ return individual
252
+
253
+
254
+ def _env_blocked_details(
255
+ checks: Mapping[str, Mapping[str, object]], results: Sequence[Mapping[str, object]]
256
+ ) -> list[dict[str, object]]:
257
+ details: list[dict[str, object]] = []
258
+ seen: set[str] = set()
259
+ for item in _individual_results(results):
260
+ if item.get("status") != "env_blocked":
261
+ continue
262
+ check_id = str(item.get("check_id") or item.get("id"))
263
+ if check_id in seen:
264
+ continue
265
+ seen.add(check_id)
266
+ check = checks.get(check_id, {})
267
+ details.append({
268
+ "check_id": check_id,
269
+ "owner": str(item.get("owner", "") or ""),
270
+ "command": check.get("command", item.get("command")),
271
+ "missing": [dict(entry) for entry in item.get("missing", [])],
272
+ })
273
+ return details
274
+
275
+
276
+ def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
277
+ by_id = {str(item.get("id")): item for item in items}
278
+ pending = set(by_id)
279
+ ordered: list[Mapping[str, object]] = []
280
+ while pending:
281
+ ready: list[str] = []
282
+ for item_id in by_id:
283
+ if item_id not in pending:
284
+ continue
285
+ deps = by_id[item_id].get("depends_on", [])
286
+ dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
287
+ unknown = [dep for dep in dep_ids if dep not in by_id]
288
+ if unknown:
289
+ raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
290
+ if all(dep not in pending for dep in dep_ids):
291
+ ready.append(item_id)
292
+ if not ready:
293
+ raise ValueError("acceptance scenario dependency cycle")
294
+ ordered.extend(by_id[item_id] for item_id in ready)
295
+ pending.difference_update(ready)
296
+ return ordered
297
+
298
+
299
+ def _executable(items: list[Mapping[str, object]]) -> bool:
300
+ """Draft completeness vs execution gate: at least one scenario must be
301
+ executable — a functional scenario with a registered command, or a
302
+ manual / e2e scenario with its own confirmation/deferral path. Zero
303
+ executable scenarios must block execution acceptance, never pass it."""
304
+ for item in items:
305
+ command = item.get("command")
306
+ if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
307
+ return True
308
+ checks = item.get("checks")
309
+ if isinstance(checks, list) and checks and all(isinstance(value, str) for value in checks):
310
+ return True
311
+ if str(item.get("kind", "")) in ("manual", "e2e"):
312
+ return True
313
+ return False
314
+
315
+
316
+ def _identity_key(
317
+ item: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool
318
+ ) -> Optional[str]:
319
+ """Execution identity for within-run dedup, or None when not configured."""
320
+ command = item.get("command")
321
+ if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
322
+ return None
323
+ key = execution_key(command, str(root / str(item.get("cwd", "."))), float(item.get("timeout", 60)))
324
+ return key + json.dumps([item.get("kind", "functional"), include_e2e, only_e2e])
325
+
326
+
327
+ def _group_key(
328
+ item: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool
329
+ ) -> Optional[str]:
330
+ """Schema2 compatibility: identical independent commands share one run."""
331
+ if item.get("depends_on") or not _included(item, include_e2e, only_e2e):
332
+ return None
333
+ return _identity_key(item, root, include_e2e, only_e2e)
334
+
335
+
336
+ def _unit_dependencies(
337
+ units: list[dict[str, object]], scenario_unit: Mapping[str, str],
338
+ by_id: Mapping[str, dict[str, object]],
339
+ ) -> None:
340
+ """Serialize a check when a scenario or the check itself declares depends_on."""
341
+ aggregate_units: dict[str, list[str]] = {}
342
+ for unit in units:
343
+ for item in unit["scenarios"]:
344
+ ref = item.get("scenario_ref") if isinstance(item, Mapping) else None
345
+ if isinstance(ref, str) and ref:
346
+ aggregate_units.setdefault(ref, []).append(str(unit["id"]))
347
+ for unit in units:
348
+ wanted: list[str] = []
349
+ check = unit.get("check")
350
+ raw = check.get("depends_on", []) if isinstance(check, Mapping) else []
351
+ if isinstance(raw, list):
352
+ wanted.extend(str(dep) for dep in raw)
353
+ for item in unit["scenarios"]:
354
+ deps = item.get("depends_on", [])
355
+ if isinstance(deps, list):
356
+ wanted.extend(str(dep) for dep in deps)
357
+ resolved: list[str] = []
358
+ for dep in wanted:
359
+ target = by_id.get(dep)
360
+ if target is not None:
361
+ targets = [str(target["id"])]
362
+ elif dep in scenario_unit:
363
+ targets = [scenario_unit[dep]]
364
+ elif dep in aggregate_units:
365
+ targets = aggregate_units[dep]
366
+ else:
367
+ targets = []
368
+ for target_id in targets:
369
+ if target_id != unit["id"] and target_id not in resolved:
370
+ resolved.append(target_id)
371
+ unit["deps"] = resolved
372
+
373
+
374
+ def _build_units(
375
+ items: Sequence[Mapping[str, object]], root: Path, check_map: Mapping[str, Mapping[str, object]],
376
+ include_e2e: bool, only_e2e: bool,
377
+ ) -> tuple[list[dict[str, object]], dict[str, str]]:
378
+ """Group scenarios into check units (v3) or command-identity units (schema2)."""
379
+ units: list[dict[str, object]] = []
380
+ by_id: dict[str, dict[str, object]] = {}
381
+ command_units: dict[str, dict[str, object]] = {}
382
+ scenario_unit: dict[str, str] = {}
383
+ for item in items:
384
+ scenario_id = str(item.get("id"))
385
+ check_id = item.get("check")
386
+ if isinstance(check_id, str) and check_id in check_map:
387
+ unit = by_id.get(check_id)
388
+ if unit is None:
389
+ unit = {"id": check_id, "check": check_map[check_id], "scenarios": [], "deps": []}
390
+ by_id[check_id] = unit
391
+ units.append(unit)
392
+ unit["scenarios"].append(item)
393
+ scenario_unit[scenario_id] = check_id
394
+ continue
395
+ key = _group_key(item, root, include_e2e, only_e2e)
396
+ unit = command_units.get(key) if key is not None else None
397
+ if unit is None:
398
+ identity = (
399
+ "command:" + hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
400
+ if key is not None else f"scenario:{scenario_id}"
401
+ )
402
+ unit = {"id": identity, "check": None, "scenarios": [], "deps": []}
403
+ units.append(unit)
404
+ by_id[identity] = unit
405
+ if key is not None:
406
+ command_units[key] = unit
407
+ unit["scenarios"].append(item)
408
+ scenario_unit[scenario_id] = str(unit["id"])
409
+ _unit_dependencies(units, scenario_unit, by_id)
410
+ return units, scenario_unit
411
+
412
+
413
+ def _unit_layers(units: Sequence[Mapping[str, object]]) -> list[list[Mapping[str, object]]]:
414
+ """Stable dependency levels: every layer can run concurrently."""
415
+ remaining = list(units)
416
+ done: set[str] = set()
417
+ layers: list[list[Mapping[str, object]]] = []
418
+ while remaining:
419
+ ready = [
420
+ unit for unit in remaining
421
+ if all(str(dep) in done for dep in unit.get("deps", []))
422
+ ]
423
+ if not ready:
424
+ raise ValueError("acceptance check dependency cycle")
425
+ layers.append(ready)
426
+ done.update(str(unit["id"]) for unit in ready)
427
+ remaining = [unit for unit in remaining if str(unit["id"]) not in done]
428
+ return layers
429
+
430
+
431
+ def _unit_active(unit: Mapping[str, object], include_e2e: bool, only_e2e: bool) -> list[Mapping[str, object]]:
432
+ return [
433
+ item for item in unit["scenarios"]
434
+ if _included(item, include_e2e, only_e2e)
435
+ ]
436
+
437
+
438
+ def _evidence_within_ttl(check: Mapping[str, object], record: Mapping[str, object]) -> bool:
439
+ ttl = check.get("evidence_ttl_seconds")
440
+ if ttl is None:
441
+ return True
442
+ try:
443
+ timestamp = datetime.fromisoformat(str(record.get("executed_at", "")).replace("Z", "+00:00"))
444
+ if timestamp.tzinfo is None:
445
+ return False
446
+ age = (datetime.now(timezone.utc) - timestamp).total_seconds()
447
+ return 0 <= age <= float(ttl)
448
+ except (TypeError, ValueError, OverflowError):
449
+ return False
450
+
451
+
452
+ def _checkpoint_not_superseded(entry: Mapping[str, object], active: Sequence[Mapping[str, object]],
453
+ rows: Mapping[str, Mapping[str, object]]) -> bool:
454
+ for item in active:
455
+ row = rows.get(str(item.get("scenario_ref") or item.get("id")), {})
456
+ evidence = row.get("evidence")
457
+ if not isinstance(evidence, Mapping):
458
+ if row.get("runs"):
459
+ return False
460
+ continue
461
+ latest = check_outcome(evidence, str(item.get("check"))) if item.get("scenario_ref") else evidence
462
+ if latest.get("status") == "passed" and latest.get("exit_code") == 0:
463
+ continue
464
+ try:
465
+ checkpoint_at = datetime.fromisoformat(str(entry.get("executed_at", "")).replace("Z", "+00:00"))
466
+ evidence_at = datetime.fromisoformat(str(evidence.get("executed_at", "")).replace("Z", "+00:00"))
467
+ if checkpoint_at.tzinfo is None or evidence_at.tzinfo is None or evidence_at >= checkpoint_at:
468
+ return False
469
+ except (TypeError, ValueError):
470
+ return False
471
+ return True
472
+
473
+
474
+ def _unit_reusable(
475
+ unit: Mapping[str, object], rows: Mapping[str, Mapping[str, object]], fingerprint: str,
476
+ use_cache: bool, include_e2e: bool, only_e2e: bool, checkpoint: _Checkpoint,
477
+ environment_hash: str,
478
+ ) -> bool:
479
+ """A check is reusable only when every active scenario is still fresh."""
480
+ if not use_cache or not fingerprint or unit.get("deps"):
481
+ return False
482
+ check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
483
+ if str(check.get("env", "none")).lower() in ("external", "staging", "production") and not check.get("environment_revision"):
484
+ return False
485
+ active = _unit_active(unit, include_e2e, only_e2e)
486
+ if not active:
487
+ return False
488
+ entry = checkpoint.entries.get(str(unit.get("id")), {})
489
+ if (check.get("evidence_ttl_seconds") is None and checkpoint.reusable(unit)
490
+ and entry.get("environment_hash") == environment_hash
491
+ and _evidence_within_ttl(check, entry)
492
+ and _checkpoint_not_superseded(entry, active, rows)):
493
+ return True
494
+ return all(
495
+ _reusable(rows.get(str(item.get("scenario_ref") or item.get("id"))), item, fingerprint)
496
+ and _evidence_within_ttl(check, (rows.get(str(item.get("scenario_ref") or item.get("id"))) or {}).get("evidence", {}))
497
+ and _item_environment_matches(rows.get(str(item.get("scenario_ref") or item.get("id"))), item, environment_hash)
498
+ for item in active
499
+ )
500
+
501
+
502
+ def _item_environment_matches(row: Optional[Mapping[str, object]], item: Mapping[str, object], expected: str) -> bool:
503
+ evidence = row.get("evidence") if isinstance(row, Mapping) else None
504
+ if not isinstance(evidence, Mapping):
505
+ return False
506
+ outcome = check_outcome(evidence, str(item.get("check"))) if item.get("scenario_ref") else evidence
507
+ return outcome.get("environment_hash") == expected
508
+
509
+
510
+ def _reuse_results(
511
+ unit: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool, deadline: Optional[float],
512
+ checkpoint: _Checkpoint, rows: Mapping[str, Mapping[str, object]], environment_hash: str,
513
+ ) -> list[dict[str, object]]:
514
+ check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
515
+ entry = checkpoint.entries.get(str(unit.get("id")), {})
516
+ checkpoint_reused = (check.get("evidence_ttl_seconds") is None and checkpoint.reusable(unit)
517
+ and entry.get("environment_hash") == environment_hash
518
+ and _checkpoint_not_superseded(entry, _unit_active(unit, include_e2e, only_e2e), rows))
519
+ results: list[dict[str, object]] = []
520
+ for item in unit["scenarios"]:
521
+ if not _included(item, include_e2e, only_e2e):
522
+ results.append(_run(item, root, include_e2e, deadline, only_e2e))
523
+ continue
524
+ results.append({
525
+ "id": str(item.get("id", "unknown")),
526
+ "kind": str(item.get("kind", "functional")),
527
+ "status": "passed",
528
+ "exit_code": 0,
529
+ "cache_reused": True,
530
+ "checkpoint_reused": checkpoint_reused,
531
+ })
532
+ return results
533
+
534
+
535
+ def _apply_outcome(
536
+ unit: Mapping[str, object], outcome: Mapping[str, object], root: Path,
537
+ include_e2e: bool, only_e2e: bool, deadline: Optional[float],
538
+ ) -> list[dict[str, object]]:
539
+ """Project one check outcome onto every scenario that declares it."""
540
+ check_id = str(unit["id"]) if unit.get("check") is not None else ""
541
+ status = str(outcome.get("status"))
542
+ results: list[dict[str, object]] = []
543
+ for item in unit["scenarios"]:
544
+ if not _included(item, include_e2e, only_e2e):
545
+ results.append(_run(item, root, include_e2e, deadline, only_e2e))
546
+ continue
547
+ if status == "env_blocked":
548
+ results.append(_env_blocked_result(item, check_id, outcome.get("missing", [])))
549
+ continue
550
+ result = {"id": str(item.get("id", "unknown")), "kind": str(item.get("kind", "functional"))}
551
+ result.update({key: value for key, value in outcome.items() if key not in ("id", "kind")})
552
+ if status == "failed":
553
+ risk = _accepted_risk(unit.get("check"), item)
554
+ if risk is not None:
555
+ result["status"] = "accepted_risk"
556
+ result["risk"] = risk
557
+ if check_id:
558
+ result["check_id"] = check_id
559
+ results.append(result)
560
+ return results
561
+
562
+
563
+ def _unit_preflight(
564
+ check: Mapping[str, object], root: Path, env: Optional[Mapping[str, str]], deadline: Optional[float]
565
+ ) -> Optional[dict[str, object]]:
566
+ missing = _preflight_failures(check, root, env, deadline)
567
+ if not missing:
568
+ return None
569
+ if any(str(entry.get("reason")) == "deadline_exceeded" for entry in missing):
570
+ return {"status": "incomplete", "error": "deadline_exceeded"}
571
+ return {"status": "env_blocked", "missing": missing}
572
+
573
+
574
+ def _execute_check(
575
+ check: Mapping[str, object], root: Path, env: Mapping[str, str], deadline: Optional[float]
576
+ ) -> dict[str, object]:
577
+ gated = _unit_preflight(check, root, env, deadline)
578
+ if gated is not None:
579
+ return gated
580
+ return _command_outcome(check, root, deadline, env)
581
+
582
+
583
+ def _execute_unit(
584
+ unit: Mapping[str, object], root: Path, include_e2e: bool, only_e2e: bool,
585
+ deadline: Optional[float], namespace: Optional[Mapping[str, str]],
586
+ ) -> dict[str, object]:
587
+ check = unit.get("check")
588
+ if check is None:
589
+ return _command_outcome(unit["scenarios"][0], root, deadline, None)
590
+ if not _unit_active(unit, include_e2e, only_e2e):
591
+ return {"status": "not_included"}
592
+ env = check_environment(check, str(root), namespace)
593
+ if is_isolated(check):
594
+ return _execute_check(check, root, env, deadline)
595
+ # Shared services cannot be partitioned: every worktree serializes on the
596
+ # same lock, with a stale timeout so a crashed owner cannot stall a batch.
597
+ remaining = remaining_seconds(deadline)
598
+ if remaining is not None and remaining <= 0:
599
+ return {"status": "incomplete", "error": "deadline_exceeded"}
600
+ try:
601
+ with shared_env_lock(str(root), wait=remaining):
602
+ return _execute_check(check, root, env, deadline)
603
+ except SharedEnvLockTimeout as exc:
604
+ return {"status": "incomplete", "error": "shared_env_lock_timeout", "detail": str(exc)}
605
+
606
+
607
+ def _parallel_safe(unit: Mapping[str, object]) -> bool:
608
+ check = unit.get("check")
609
+ return isinstance(check, Mapping) and effective_parallel_safe(check)
610
+
611
+
612
+ def _unit_command(unit: Mapping[str, object]) -> tuple[list[str], str, float]:
613
+ """Command identity used to validate a checkpoint entry on resume."""
614
+ check = unit.get("check")
615
+ source = check if isinstance(check, Mapping) else unit["scenarios"][0]
616
+ command = source.get("command")
617
+ argv = [str(value) for value in command] if isinstance(command, list) else []
618
+ cwd = source.get("cwd", ".")
619
+ return argv, cwd if isinstance(cwd, str) else ".", _scenario_timeout(source)
620
+
621
+
622
+ def _unit_execution_identity(unit: Mapping[str, object], root: Path, fingerprint: str,
623
+ namespace: Optional[Mapping[str, str]]) -> str:
624
+ command, cwd, timeout = _unit_command(unit)
625
+ check = unit.get("check")
626
+ extra = check_environment(check, str(root), namespace) if isinstance(check, Mapping) else {}
627
+ payload = {"command": command, "cwd": str((root / cwd).resolve()), "timeout": timeout,
628
+ "environment": {**os.environ, **extra}, "inputs": fingerprint,
629
+ "registration": check if isinstance(check, Mapping) else unit.get("scenarios", [])}
630
+ return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()
631
+
632
+
633
+ def _atomic_checkpoint(path: Path, payload: Mapping[str, object]) -> None:
634
+ """Checkpoint files are replaced atomically, like every other evidence file."""
635
+ path.parent.mkdir(parents=True, exist_ok=True)
636
+ cf_fs.atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
637
+
638
+
639
+ def _checkpoint_path(manifest_file: str, root: str) -> Path:
640
+ from codeflow.evidence.cf_evidence_store import requirement_key
641
+ digest = hashlib.sha256(str(Path(manifest_file).resolve()).encode("utf-8")).hexdigest()[:16]
642
+ requirement = requirement_key(Path(manifest_file).resolve().parent)
643
+ return EvidenceStore(root, requirement).directory.parent / "_checkpoints" / f"acceptance-{digest}.json"
644
+
645
+
646
+ class _Checkpoint:
647
+ """Per-check completion sidecar, separate from the user's manifest.
648
+
649
+ The manifest is only rewritten when a run finishes, so an interrupt cannot
650
+ leave partially-verified rows. Resume reads passed checks whose command
651
+ identity and worktree freshness still match and reuses them.
652
+ """
653
+
654
+ def __init__(self, path: Path, fingerprint: str, enabled: bool, resume: bool,
655
+ fingerprints: Optional[Mapping[str, str]] = None) -> None:
656
+ self.path = path
657
+ self.fingerprint = fingerprint
658
+ self.fingerprints = dict(fingerprints or {})
659
+ self.enabled = enabled
660
+ self.entries: dict[str, Mapping[str, object]] = {}
661
+ if not enabled:
662
+ return
663
+ if resume:
664
+ self.entries = self._read()
665
+ else:
666
+ self._write() # a fresh run must not inherit an older run's progress
667
+
668
+ def _read(self) -> dict[str, Mapping[str, object]]:
669
+ try:
670
+ text = cf_fs.read_text(self.path)
671
+ if text is None:
672
+ return {}
673
+ data = json.loads(text)
674
+ except (OSError, ValueError):
675
+ return {}
676
+ checks = data.get("checks") if isinstance(data, Mapping) else None
677
+ if not isinstance(checks, Mapping):
678
+ return {}
679
+ return {
680
+ str(key): value for key, value in checks.items() if isinstance(value, Mapping)
681
+ }
682
+
683
+ def _write(self) -> None:
684
+ if not self.enabled:
685
+ return
686
+ payload = {
687
+ "schema": 1,
688
+ "fingerprint": self.fingerprint,
689
+ "fingerprints": self.fingerprints,
690
+ "updated_at": datetime.now(timezone.utc).isoformat(),
691
+ "checks": self.entries,
692
+ }
693
+ _atomic_checkpoint(self.path, payload)
694
+
695
+ def fingerprint_for(self, unit: Mapping[str, object]) -> str:
696
+ if unit.get("check") is not None:
697
+ return self.fingerprints.get(str(unit.get("id")), self.fingerprint)
698
+ return self.fingerprint
699
+
700
+ def reusable(self, unit: Mapping[str, object]) -> bool:
701
+ if not self.enabled:
702
+ return False
703
+ entry = self.entries.get(str(unit.get("id")))
704
+ if not isinstance(entry, Mapping):
705
+ return False
706
+ command, cwd, timeout = _unit_command(unit)
707
+ try:
708
+ recorded_timeout = float(entry.get("timeout", 60))
709
+ except (TypeError, ValueError):
710
+ return False
711
+ return (
712
+ entry.get("status") == "passed"
713
+ and entry.get("fingerprint") == self.fingerprint_for(unit)
714
+ and entry.get("command") == command
715
+ and str(entry.get("cwd", ".")) == cwd
716
+ and recorded_timeout == timeout
717
+ )
718
+
719
+ def record(self, unit: Mapping[str, object], outcome: Mapping[str, object], environment_hash: str) -> None:
720
+ if not self.enabled:
721
+ return
722
+ if outcome.get("status") != "passed":
723
+ self.forget(unit)
724
+ return
725
+ command, cwd, timeout = _unit_command(unit)
726
+ self.entries[str(unit["id"])] = {
727
+ "status": "passed",
728
+ "fingerprint": self.fingerprint_for(unit),
729
+ "command": command,
730
+ "cwd": cwd,
731
+ "timeout": timeout,
732
+ "executed_at": datetime.now(timezone.utc).isoformat(),
733
+ "environment_hash": environment_hash,
734
+ }
735
+ self._write()
736
+
737
+ def forget(self, unit: Mapping[str, object]) -> None:
738
+ if not self.enabled:
739
+ return
740
+ if self.entries.pop(str(unit["id"]), None) is not None:
741
+ self._write()
742
+
743
+
744
+ def _failure(status: str) -> bool:
745
+ return status in ("failed", "not_configured")
746
+
747
+
748
+ class _UnitEngine:
749
+ """Schedule check units, checkpointing each completion for resume."""
750
+
751
+ def __init__(
752
+ self, units: Sequence[Mapping[str, object]], root: Path, include_e2e: bool, only_e2e: bool,
753
+ deadline: Optional[float], namespace: Optional[Mapping[str, str]],
754
+ rows: Mapping[str, Mapping[str, object]], fingerprint: str, use_cache: bool,
755
+ max_failures: int, checkpoint: _Checkpoint, jobs: int,
756
+ fingerprints: Optional[Mapping[str, str]] = None,
757
+ ) -> None:
758
+ self.units = list(units)
759
+ self.root = root
760
+ self.include_e2e = include_e2e
761
+ self.only_e2e = only_e2e
762
+ self.deadline = deadline
763
+ self.namespace = namespace
764
+ self.rows = rows
765
+ self.fingerprint = fingerprint
766
+ self.fingerprints = dict(fingerprints or {})
767
+ self.use_cache = use_cache
768
+ self.max_failures = max_failures
769
+ self.checkpoint = checkpoint
770
+ self.jobs = max(1, jobs or DEFAULT_JOBS)
771
+ self.results_by_id: dict[str, dict[str, object]] = {}
772
+ self.completed: set[str] = set()
773
+ self.failures = 0
774
+ self.executed = 0
775
+ self.reused = 0
776
+ self.execution_ms = 0.0
777
+ self.execution_identities: list[str] = []
778
+ self.stop_reason = ""
779
+ self.interrupted = False
780
+
781
+ def run(self) -> tuple[dict[str, dict[str, object]], dict[str, object]]:
782
+ try:
783
+ for layer in _unit_layers(self.units):
784
+ if self.stop_reason:
785
+ break
786
+ self._run_layer(layer)
787
+ except KeyboardInterrupt:
788
+ self.interrupted = True
789
+ self.stop_reason = "interrupted"
790
+ self._fill_unfinished()
791
+ state = {
792
+ "executed": self.executed,
793
+ "reused": self.reused,
794
+ "execution_ms": self.execution_ms,
795
+ "execution_identities": self.execution_identities,
796
+ "interrupted": self.interrupted,
797
+ "stopped_reason": self.stop_reason,
798
+ }
799
+ return self.results_by_id, state
800
+
801
+ def _run_layer(self, layer: Sequence[Mapping[str, object]]) -> None:
802
+ placeholders = [unit for unit in layer if not self._active(unit)]
803
+ for unit in placeholders:
804
+ self._record(unit, self._placeholder_results(unit))
805
+ parallel = [unit for unit in layer if self._active(unit) and _parallel_safe(unit)]
806
+ serial = [unit for unit in layer if self._active(unit) and not _parallel_safe(unit)]
807
+ self._run_parallel(parallel)
808
+ for unit in serial:
809
+ self._run_one(unit)
810
+
811
+ def _active(self, unit: Mapping[str, object]) -> bool:
812
+ return bool(_unit_active(unit, self.include_e2e, self.only_e2e))
813
+
814
+ def _can_start(self) -> bool:
815
+ if self.stop_reason:
816
+ return False
817
+ if self.max_failures and self.failures >= self.max_failures:
818
+ self._stop("max_failures_exceeded")
819
+ return False
820
+ if self._deadline_expired():
821
+ self._stop("deadline_exceeded")
822
+ return False
823
+ return True
824
+
825
+ def _stop(self, reason: str) -> None:
826
+ if not self.stop_reason:
827
+ self.stop_reason = reason
828
+
829
+ def _deadline_expired(self) -> bool:
830
+ remaining = remaining_seconds(self.deadline)
831
+ return remaining is not None and remaining <= 0
832
+
833
+ def _run_one(self, unit: Mapping[str, object]) -> None:
834
+ if not self._can_start():
835
+ return
836
+ self._run_unit(unit)
837
+
838
+ def _run_unit(self, unit: Mapping[str, object]) -> None:
839
+ fingerprint = self.checkpoint.fingerprint_for(unit)
840
+ check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
841
+ environment_hash = execution_environment_hash(check_environment(check, str(self.root), self.namespace))
842
+ if _unit_reusable(unit, self.rows, fingerprint, self.use_cache,
843
+ self.include_e2e, self.only_e2e, self.checkpoint, environment_hash):
844
+ results = _reuse_results(unit, self.root, self.include_e2e, self.only_e2e,
845
+ self.deadline, self.checkpoint, self.rows, environment_hash)
846
+ self.reused += len(_unit_active(unit, self.include_e2e, self.only_e2e))
847
+ self._record(unit, self._with_input_hash(unit, results))
848
+ return
849
+ if unit.get("deps"):
850
+ invalidate_executions()
851
+ outcome = _execute_unit(unit, self.root, self.include_e2e, self.only_e2e,
852
+ self.deadline, self.namespace)
853
+ self.executed += 1
854
+ self._complete(unit, outcome)
855
+
856
+ def _with_input_hash(self, unit: Mapping[str, object],
857
+ results: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
858
+ value = self.checkpoint.fingerprint_for(unit)
859
+ check_id = str(unit.get("id")) if unit.get("check") is not None else ""
860
+ check_config = unit.get("check") if isinstance(unit.get("check"), Mapping) else None
861
+ environment_hash = execution_environment_hash(check_environment(check_config or {}, str(self.root), self.namespace))
862
+ return [
863
+ {
864
+ **dict(result), "input_hash": value, "environment_hash": environment_hash,
865
+ **({"check_id": check_id} if check_id else {}),
866
+ **({"check_config": dict(check_config)} if check_config else {}),
867
+ }
868
+ for result in results
869
+ ]
870
+
871
+ def _complete(self, unit: Mapping[str, object], outcome: Mapping[str, object]) -> None:
872
+ self.execution_ms += float(outcome.get("execution_ms", 0))
873
+ if float(outcome.get("execution_ms", 0)) > 0:
874
+ self.execution_identities.append(_unit_execution_identity(
875
+ unit, self.root, self.checkpoint.fingerprint_for(unit), self.namespace))
876
+ results = _apply_outcome(unit, outcome, self.root, self.include_e2e,
877
+ self.only_e2e, self.deadline)
878
+ self._record(unit, self._with_input_hash(unit, results))
879
+ check = unit.get("check") if isinstance(unit.get("check"), Mapping) else {}
880
+ environment_hash = execution_environment_hash(check_environment(check, str(self.root), self.namespace))
881
+ self.checkpoint.record(unit, outcome, environment_hash)
882
+ status = str(outcome.get("status"))
883
+ if _failure(status):
884
+ self.failures += 1
885
+ if self.max_failures and self.failures >= self.max_failures:
886
+ self._stop("max_failures_exceeded")
887
+ if status == "incomplete" and outcome.get("error") == "deadline_exceeded":
888
+ self._stop("deadline_exceeded")
889
+
890
+ def _run_parallel(self, units: Sequence[Mapping[str, object]]) -> None:
891
+ runnable = []
892
+ for unit in units:
893
+ if not self._can_start():
894
+ break
895
+ runnable.append(unit)
896
+ if not runnable:
897
+ return
898
+ if len(runnable) == 1:
899
+ self._run_unit(runnable[0])
900
+ return
901
+ workers = min(len(runnable), self.jobs)
902
+ pool = ThreadPoolExecutor(max_workers=workers)
903
+ pending: dict[Future, Mapping[str, object]] = {}
904
+ cursor = 0
905
+ interrupted = False
906
+ try:
907
+ while cursor < len(runnable) and len(pending) < workers:
908
+ pending[pool.submit(self._execute, runnable[cursor])] = runnable[cursor]
909
+ cursor += 1
910
+ while pending:
911
+ done, _ = wait(tuple(pending), return_when=FIRST_COMPLETED)
912
+ for future in done:
913
+ unit = pending.pop(future)
914
+ self.executed += 1
915
+ self._complete(unit, future.result())
916
+ while (not self.stop_reason and cursor < len(runnable)
917
+ and len(pending) < workers and self._can_start()):
918
+ unit = runnable[cursor]
919
+ cursor += 1
920
+ pending[pool.submit(self._execute, unit)] = unit
921
+ except KeyboardInterrupt:
922
+ self._drain_done(pending)
923
+ interrupted = True
924
+ raise
925
+ finally:
926
+ pool.shutdown(wait=not interrupted, cancel_futures=interrupted)
927
+
928
+ def _drain_done(self, pending: dict[Future, Mapping[str, object]]) -> None:
929
+ """A signal can arrive after a check finished but before it was recorded."""
930
+ for future in tuple(pending):
931
+ if not future.done() or future.cancelled():
932
+ continue
933
+ if future.exception() is not None:
934
+ continue
935
+ unit = pending.pop(future)
936
+ self.executed += 1
937
+ self._complete(unit, future.result())
938
+
939
+ def _execute(self, unit: Mapping[str, object]) -> dict[str, object]:
940
+ return _execute_unit(unit, self.root, self.include_e2e, self.only_e2e,
941
+ self.deadline, self.namespace)
942
+
943
+ def _placeholder_results(self, unit: Mapping[str, object]) -> list[dict[str, object]]:
944
+ return [
945
+ _run(item, self.root, self.include_e2e, self.deadline, self.only_e2e)
946
+ for item in unit["scenarios"]
947
+ ]
948
+
949
+ def _record(self, unit: Mapping[str, object], results: Sequence[Mapping[str, object]]) -> None:
950
+ for result in results:
951
+ self.results_by_id[str(result.get("id"))] = dict(result)
952
+ self.completed.add(str(unit["id"]))
953
+
954
+ def _fill_unfinished(self) -> None:
955
+ reason = self.stop_reason or "incomplete"
956
+ outcome = {"status": "incomplete", "error": reason}
957
+ for unit in self.units:
958
+ if str(unit["id"]) in self.completed:
959
+ continue
960
+ results = _apply_outcome(unit, outcome, self.root, self.include_e2e,
961
+ self.only_e2e, self.deadline)
962
+ for result in results:
963
+ self.results_by_id.setdefault(str(result.get("id")), dict(result))
964
+
965
+
966
+ def _ordered_results(
967
+ items: Sequence[Mapping[str, object]], results_by_id: Mapping[str, Mapping[str, object]]
968
+ ) -> list[dict[str, object]]:
969
+ """Stable output order independent of parallel completion order."""
970
+ results: list[dict[str, object]] = []
971
+ for item in items:
972
+ scenario_id = str(item.get("id", "unknown"))
973
+ result = results_by_id.get(scenario_id)
974
+ if result is None:
975
+ result = {"id": scenario_id, "kind": str(item.get("kind", "functional")),
976
+ "status": "incomplete", "error": "not_scheduled"}
977
+ results.append(dict(result))
978
+ return results
979
+
980
+
981
+ def _check_summary(checks: Sequence[Mapping[str, object]], scenarios: Sequence[Mapping[str, object]],
982
+ results: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
983
+ """Roll scenario results up to their executable checks (v3 manifests)."""
984
+ if not checks:
985
+ return []
986
+ by_id = {str(item.get("id")): item for item in results}
987
+ grouped: dict[str, list[str]] = {}
988
+ for scenario in scenarios:
989
+ scenario_id = str(scenario.get("id"))
990
+ for check_id in scenario_check_ids(scenario):
991
+ grouped.setdefault(check_id, []).append(scenario_id)
992
+ summary: list[dict[str, object]] = []
993
+ for check in checks:
994
+ check_id = str(check.get("id", ""))
995
+ scenario_ids = grouped.get(check_id, [])
996
+ statuses = [str(check_outcome(by_id.get(scenario_id, {}), check_id).get("status", "not_included"))
997
+ for scenario_id in scenario_ids]
998
+ if not statuses:
999
+ status = "not_included"
1000
+ elif all(value == "passed" for value in statuses):
1001
+ status = "passed"
1002
+ elif any(value in ("failed", "not_configured") for value in statuses):
1003
+ status = "failed"
1004
+ elif any(value == "incomplete" for value in statuses):
1005
+ status = "incomplete"
1006
+ elif any(value == "env_blocked" for value in statuses):
1007
+ status = "env_blocked"
1008
+ elif any(value == "accepted_risk" for value in statuses):
1009
+ status = "accepted_risk"
1010
+ elif any(value == "e2e_deferred" for value in statuses):
1011
+ status = "deferred"
1012
+ else:
1013
+ status = "included"
1014
+ summary.append({
1015
+ "id": check_id,
1016
+ "status": status,
1017
+ "scenarios": scenario_ids,
1018
+ "env_scope": env_scope(check),
1019
+ "parallel_safe": effective_parallel_safe(check),
1020
+ })
1021
+ return summary
1022
+
1023
+
1024
+ def _check_plan(data: Mapping[str, object]) -> tuple[list[Mapping[str, object]], dict[str, Mapping[str, object]]]:
1025
+ """Manifest v3 checks (schema2 manifests have none)."""
1026
+ value = data.get("checks")
1027
+ checks = value if isinstance(value, list) else []
1028
+ mapping = {
1029
+ str(check["id"]): check
1030
+ for check in checks
1031
+ if isinstance(check, Mapping) and "id" in check
1032
+ }
1033
+ return checks, mapping
1034
+
1035
+
1036
+ def _check_input_fingerprints(
1037
+ root: str, check_map: Mapping[str, Mapping[str, object]], fallback: str
1038
+ ) -> dict[str, dict[str, str]]:
1039
+ """Per-check content identity; checks without inputs inherit repo scope."""
1040
+ outcomes = check_input_hashes(root, check_map, fallback)
1041
+ result: dict[str, dict[str, str]] = {}
1042
+ for check_id, check in check_map.items():
1043
+ outcome = outcomes.get(check_id, {})
1044
+ result[check_id] = {
1045
+ "input_hash": str(outcome.get("input_hash") or fallback),
1046
+ "scope": str(outcome.get("scope") or ("repo" if not check.get("inputs") else "empty")),
1047
+ }
1048
+ return result
1049
+
1050
+
1051
+ def _scenario_fingerprints(
1052
+ data: Mapping[str, object], fingerprints: Mapping[str, str], fallback: str
1053
+ ) -> dict[str, str]:
1054
+ result: dict[str, str] = {}
1055
+ for scenario in data.get("scenarios", []) if isinstance(data.get("scenarios"), list) else []:
1056
+ if not isinstance(scenario, Mapping):
1057
+ continue
1058
+ scenario_id = scenario.get("id")
1059
+ if not isinstance(scenario_id, str):
1060
+ continue
1061
+ raw = scenario.get("checks")
1062
+ check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
1063
+ if not check_ids and isinstance(scenario.get("check"), str):
1064
+ check_ids = [str(scenario.get("check"))]
1065
+ values = [(check_id, fingerprints.get(check_id, fallback)) for check_id in check_ids]
1066
+ if not values:
1067
+ result[scenario_id] = fallback
1068
+ elif len(values) == 1:
1069
+ result[scenario_id] = values[0][1] or fallback
1070
+ else:
1071
+ payload = "|".join(f"{key}={value}" for key, value in sorted(values))
1072
+ result[scenario_id] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
1073
+ return result
1074
+
1075
+
1076
+ def _scenario_check_ids(scenario: Mapping[str, object]) -> list[str]:
1077
+ return scenario_check_ids(scenario)
1078
+
1079
+
1080
+ def _run_namespace(
1081
+ root: str, check_map: Mapping[str, Mapping[str, object]], owner: str,
1082
+ items: Sequence[Mapping[str, object]], run_key: str,
1083
+ ) -> Optional[dict[str, str]]:
1084
+ """Namespace is only materialized when at least one check is isolated."""
1085
+ if not any(is_isolated(check) for check in check_map.values()):
1086
+ return None
1087
+ return worktree_namespace(str(root), task_id=_task_identity(owner, items), run_id=run_key)
1088
+
1089
+
1090
+ def _task_identity(owner: str, items: Sequence[Mapping[str, object]]) -> str:
1091
+ """Owner wins; a single-owner manifest still exposes CF_TASK_ID."""
1092
+ if owner:
1093
+ return owner
1094
+ owners = {
1095
+ str(item.get("owner")) for item in items
1096
+ if isinstance(item.get("owner"), str) and item.get("owner")
1097
+ }
1098
+ return owners.pop() if len(owners) == 1 else ""
1099
+
1100
+
1101
+ def _decision(results: Sequence[Mapping[str, object]]) -> tuple[str, str]:
1102
+ allowed = ("passed", "manual_pending", "e2e_deferred", "not_included", "accepted_risk")
1103
+ if all(item["status"] in allowed for item in results):
1104
+ return "pass", ""
1105
+ pending = sorted({str(item["id"]) for item in results if item["status"] == "incomplete"})
1106
+ env_blocked = sorted({
1107
+ str(item.get("check_id") or item["id"]) for item in results if item["status"] == "env_blocked"
1108
+ })
1109
+ failed = sorted({
1110
+ str(item["id"]) for item in results
1111
+ if item["status"] not in (*allowed, "incomplete", "env_blocked")
1112
+ })
1113
+ segments = (
1114
+ ["incomplete:" + ",".join(pending)] if pending else []
1115
+ ) + (
1116
+ ["env_blocked:" + ",".join(env_blocked)] if env_blocked else []
1117
+ ) + (
1118
+ ["failed:" + ",".join(failed)] if failed else []
1119
+ )
1120
+ return "block", ",".join(segments) or "acceptance scenario failed"
1121
+
1122
+
1123
+ def _run_counts(results: Sequence[Mapping[str, object]]) -> tuple[int, list[str]]:
1124
+ incomplete = sum(1 for item in results if item["status"] == "incomplete")
1125
+ failed = sorted(str(item["id"]) for item in results if item["status"] in ("failed", "not_configured"))
1126
+ return incomplete, failed
1127
+
1128
+
1129
+ def _payload(
1130
+ items: Sequence[Mapping[str, object]], check_list: Sequence[Mapping[str, object]],
1131
+ check_map: Mapping[str, Mapping[str, object]], results: Sequence[Mapping[str, object]],
1132
+ state: Mapping[str, object],
1133
+ ) -> dict[str, object]:
1134
+ decision, error = _decision(results)
1135
+ if state.get("interrupted") and decision == "pass":
1136
+ decision, error = "block", "interrupted"
1137
+ incomplete, failed = _run_counts(results)
1138
+ outcome: dict[str, object] = {
1139
+ "decision": decision,
1140
+ "results": list(results),
1141
+ "executed": int(state.get("executed", 0)),
1142
+ "reused": int(state.get("reused", 0)),
1143
+ "incomplete": incomplete,
1144
+ "failed": failed,
1145
+ }
1146
+ if state.get("interrupted"):
1147
+ outcome["interrupted"] = True
1148
+ if state.get("stopped_reason"):
1149
+ outcome["stopped_reason"] = str(state["stopped_reason"])
1150
+ if check_list:
1151
+ outcome["checks"] = _check_summary(check_list, items, results)
1152
+ env_blocked = sum(1 for item in results if item["status"] == "env_blocked")
1153
+ outcome["env_blocked"] = env_blocked
1154
+ accepted = [item for item in results if item["status"] == "accepted_risk"]
1155
+ if accepted:
1156
+ outcome["accepted_risk"] = len(accepted)
1157
+ outcome["accepted_risk_details"] = [
1158
+ {
1159
+ "scenario": item.get("id"),
1160
+ "check_id": item.get("check_id") or item.get("id"),
1161
+ "risk": item.get("risk"),
1162
+ }
1163
+ for item in accepted
1164
+ ]
1165
+ if env_blocked:
1166
+ outcome["env_blocked_details"] = _env_blocked_details(check_map, results)
1167
+ if error:
1168
+ outcome["error"] = error
1169
+ return outcome
1170
+
1171
+
1172
+ def _scenario_rows(scenarios: Sequence[Mapping[str, object]]) -> dict[str, Mapping[str, object]]:
1173
+ return {
1174
+ str(row.get("id")): row
1175
+ for row in scenarios
1176
+ if isinstance(row, Mapping) and isinstance(row.get("id"), str)
1177
+ }
1178
+
1179
+
1180
+ def _make_checkpoint(
1181
+ manifest_file: str, root: str, fingerprint: str, use_cache: bool, resume: bool,
1182
+ fingerprints: Optional[Mapping[str, str]] = None,
1183
+ ) -> _Checkpoint:
1184
+ return _Checkpoint(
1185
+ _checkpoint_path(manifest_file, root), fingerprint,
1186
+ enabled=use_cache and bool(fingerprint), resume=resume,
1187
+ fingerprints=fingerprints,
1188
+ )
1189
+
1190
+
1191
+ def _expand_multi_checks(items: Sequence[Mapping[str, object]]) -> tuple[list[dict[str, object]], dict[str, list[str]]]:
1192
+ """Expand scenarios with a checks list into one pseudo-scenario per check."""
1193
+ expanded: list[dict[str, object]] = []
1194
+ aggregates: dict[str, list[str]] = {}
1195
+ for item in items:
1196
+ checks = item.get("checks")
1197
+ if not isinstance(checks, list) or not checks:
1198
+ expanded.append(dict(item))
1199
+ continue
1200
+ scenario_id = str(item.get("id"))
1201
+ ids: list[str] = []
1202
+ for check_id in checks:
1203
+ pseudo = dict(item)
1204
+ pseudo["id"] = f"{scenario_id}::{check_id}"
1205
+ pseudo["scenario_ref"] = scenario_id
1206
+ pseudo["check"] = str(check_id)
1207
+ pseudo.pop("checks", None)
1208
+ expanded.append(pseudo)
1209
+ ids.append(pseudo["id"])
1210
+ aggregates[scenario_id] = ids
1211
+ return expanded, aggregates
1212
+
1213
+
1214
+ def _multi_status(states: Sequence[str]) -> str:
1215
+ if states and all(state in ("passed", "verified") for state in states):
1216
+ return "passed"
1217
+ if any(state in ("failed", "not_configured") for state in states):
1218
+ return "failed"
1219
+ return next((state for state in ("env_blocked", "incomplete", "accepted_risk", "e2e_deferred")
1220
+ if state in states), "not_included")
1221
+
1222
+
1223
+ def _multi_check_metadata(members: Sequence[Mapping[str, object]]) -> dict[str, object]:
1224
+ checks: list[str] = []
1225
+ hashes: dict[str, str] = {}
1226
+ reused: dict[str, bool] = {}
1227
+ checkpointed: dict[str, bool] = {}
1228
+ configs: dict[str, object] = {}
1229
+ outcomes: dict[str, object] = {}
1230
+ for member in members:
1231
+ check_id = str(member.get("check_id") or "")
1232
+ if not check_id and "::" in str(member.get("id")):
1233
+ check_id = str(member.get("id")).split("::", 1)[1]
1234
+ if not check_id:
1235
+ continue
1236
+ checks.append(check_id)
1237
+ reused[check_id] = member.get("cache_reused") is True
1238
+ checkpointed[check_id] = member.get("checkpoint_reused") is True
1239
+ outcomes[check_id] = {key: member[key] for key in (
1240
+ "status", "exit_code", "risk", "error", "output", "missing", "owner", "detail", "environment_hash",
1241
+ ) if key in member}
1242
+ if isinstance(member.get("check_config"), Mapping):
1243
+ configs[check_id] = dict(member["check_config"])
1244
+ value = member.get("input_hash")
1245
+ if isinstance(value, str) and value:
1246
+ hashes[check_id] = value
1247
+ record: dict[str, object] = {}
1248
+ if checks:
1249
+ record.update(check_ids=checks, check_reused=reused, check_results=outcomes,
1250
+ checkpoint_reused_checks=checkpointed)
1251
+ if configs:
1252
+ record["check_configs"] = configs
1253
+ if hashes:
1254
+ record["check_hashes"] = hashes
1255
+ payload = "|".join(f"{key}={hashes[key]}" for key in sorted(hashes))
1256
+ record["input_hash"] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
1257
+ return record
1258
+
1259
+
1260
+ def _collapse_multi_result(scenario_id: str, members: Sequence[Mapping[str, object]],
1261
+ expected: int) -> dict[str, object]:
1262
+ states = [str(member.get("status")) for member in members]
1263
+ status = _multi_status(states) if len(members) == expected else "failed"
1264
+ record = {"id": scenario_id, "status": status, **_multi_check_metadata(members)}
1265
+ record["cache_reused"] = all(member.get("cache_reused") is True for member in members)
1266
+ record["checkpoint_reused"] = all(member.get("checkpoint_reused") is True for member in members)
1267
+ if status == "accepted_risk":
1268
+ risky = next((member for member in members if isinstance(member.get("risk"), Mapping)), None)
1269
+ if risky is not None:
1270
+ record["risk"] = dict(risky["risk"])
1271
+ if risky.get("check_id"):
1272
+ record["check_id"] = str(risky["check_id"])
1273
+ if status == "passed":
1274
+ record["exit_code"] = 0
1275
+ else:
1276
+ record["error"] = "checks_missing" if len(members) != expected else ",".join(states)
1277
+ return record
1278
+
1279
+
1280
+ def _aggregate_multi_results(results: Sequence[Mapping[str, object]],
1281
+ aggregates: Mapping[str, Sequence[str]]) -> list[dict[str, object]]:
1282
+ """Keep individual outcomes while deriving the scenario's AND verdict."""
1283
+ if not aggregates:
1284
+ return [dict(item) for item in results]
1285
+ by_id = {str(item.get("id")): item for item in results}
1286
+ pseudo_to_scenario = {
1287
+ pseudo: scenario_id for scenario_id, pseudos in aggregates.items() for pseudo in pseudos
1288
+ }
1289
+ collapsed: list[dict[str, object]] = []
1290
+ emitted: set[str] = set()
1291
+ for result in results:
1292
+ result_id = str(result.get("id"))
1293
+ scenario_id = pseudo_to_scenario.get(result_id)
1294
+ if scenario_id is None:
1295
+ collapsed.append(dict(result))
1296
+ continue
1297
+ if scenario_id in emitted:
1298
+ continue
1299
+ emitted.add(scenario_id)
1300
+ members = [by_id[pseudo] for pseudo in aggregates[scenario_id] if pseudo in by_id]
1301
+ if members:
1302
+ collapsed.append(_collapse_multi_result(scenario_id, members, len(aggregates[scenario_id])))
1303
+ else:
1304
+ collapsed.append({"id": scenario_id, "status": "failed", "error": "checks_missing"})
1305
+ for scenario_id in aggregates:
1306
+ if scenario_id not in emitted:
1307
+ collapsed.append({"id": scenario_id, "status": "failed", "error": "checks_missing"})
1308
+ return collapsed
1309
+
1310
+
1311
+ def _prepare_manifest(manifest_file: str, root: str, profile: str, force_ids: Sequence[str], owner: str,
1312
+ only_scenarios: Optional[Sequence[str]] = None
1313
+ ) -> tuple[dict[str, object], dict[str, object], list[Mapping[str, object]], Optional[dict[str, object]]]:
1314
+ if profile not in ("smoke", "changed", "full"):
1315
+ raise ValueError(f"invalid verify profile: {profile}")
1316
+ full_data = load_manifest(Path(manifest_file))
1317
+ data = _select_profile(full_data, root, profile, force_ids)
1318
+ validate_execution_baseline(Path(manifest_file), full_data)
1319
+ _ordered(data["scenarios"]) # Validate the complete DAG before owner filtering.
1320
+ if only_scenarios is not None:
1321
+ allowed = set(only_scenarios)
1322
+ data["scenarios"] = [item for item in data["scenarios"] if item.get("id") in allowed]
1323
+ items = _owned(data["scenarios"], owner)
1324
+ noop = None
1325
+ if not items:
1326
+ if owner:
1327
+ noop = {"decision": "pass", "results": [], "reason": "no_owned_scenarios"}
1328
+ elif profile != "full" and full_data.get("scenarios"):
1329
+ noop = {"decision": "pass", "results": [], "reason": "no_affected_scenarios",
1330
+ "profile": profile}
1331
+ return full_data, data, items, noop
1332
+
1333
+
1334
+ def _previous_check_hashes(data: Mapping[str, object]) -> dict[str, set[str]]:
1335
+ result: dict[str, set[str]] = {}
1336
+ for row in data.get("scenarios", []):
1337
+ if not isinstance(row, Mapping):
1338
+ continue
1339
+ for check_id in scenario_check_ids(row):
1340
+ value = recorded_check_hash(row, check_id)
1341
+ if value:
1342
+ result.setdefault(check_id, set()).add(value)
1343
+ return result
1344
+
1345
+
1346
+ def _input_change_categories(checks: Sequence[str], fingerprints: Mapping[str, Mapping[str, str]],
1347
+ previous: Mapping[str, set[str]]) -> dict[str, str]:
1348
+ result = {}
1349
+ for check_id in checks:
1350
+ current = fingerprints.get(check_id, {}).get("input_hash", "")
1351
+ old = previous.get(check_id, set())
1352
+ result[check_id] = ("unproven" if not current else "no_baseline" if not old else
1353
+ "unchanged" if old == {current} else "inputs_changed")
1354
+ return result
1355
+
1356
+
1357
+ def _add_run_reporting(outcome: dict[str, object], state: Mapping[str, object],
1358
+ fingerprints: Mapping[str, Mapping[str, str]], data: Mapping[str, object],
1359
+ started: float, planning_ms: float, preflight_ms: float,
1360
+ evidence_ms: float, profile: str, owner: str,
1361
+ previous: Mapping[str, set[str]]) -> None:
1362
+ scope = {"profile": profile, "owner": owner}
1363
+ if data.get("missing_input_patterns"):
1364
+ scope["missing_input_patterns"] = data["missing_input_patterns"]
1365
+ outcome.update({"selection_reason": data.get("selection_reason", "profile_and_owner"), "scope": scope,
1366
+ "reuse_reason": "matching_evidence" if state.get("reused") else "no_reused_results",
1367
+ "summary": f"acceptance: executed {int(state.get('executed', 0))}, reused {int(state.get('reused', 0))}",
1368
+ "execution_identities": list(state.get("execution_identities", [])),
1369
+ "selected_check_ids": sorted(str(check["id"]) for check in outcome.get("checks", [])
1370
+ if check.get("status") != "not_included"),
1371
+ "timings": {"planning_ms": planning_ms, "preflight_ms": preflight_ms,
1372
+ "execution_ms": float(state.get("execution_ms", 0)),
1373
+ "evidence_ms": evidence_ms, "elapsed_ms": (time.monotonic() - started) * 1000}})
1374
+ outcome["input_change_category"] = _input_change_categories(outcome["selected_check_ids"], fingerprints, previous)
1375
+ for check in outcome.get("checks", []):
1376
+ identity = fingerprints.get(str(check.get("id")))
1377
+ if identity:
1378
+ check["input_hash"] = identity["input_hash"]
1379
+ check["input_scope"] = identity["scope"]
1380
+ if data.get("schema") == 2:
1381
+ outcome["schema2_compat"] = True
1382
+
1383
+
1384
+ def _finalize_manifest(outcome: dict[str, object], state: Mapping[str, object],
1385
+ items: Sequence[Mapping[str, object]], results: list[dict[str, object]]) -> dict[str, object]:
1386
+ if state.get("interrupted"):
1387
+ raise AcceptanceInterrupted(outcome)
1388
+ if not _executable(items):
1389
+ return {"decision": "block", "results": results, "error": "no_executable_scenarios"}
1390
+ record_report({"kind": "acceptance", **{key: outcome[key] for key in
1391
+ ("selection_reason", "scope", "reuse_reason", "timings", "selected_check_ids", "execution_identities", "input_change_category",
1392
+ "executed", "reused") if key in outcome}})
1393
+ return outcome
1394
+
1395
+
1396
+ def run_manifest(manifest_file: str, root: str, write_evidence: bool = False,
1397
+ include_e2e: bool = False, owner: str = "", deadline: Optional[float] = None,
1398
+ only_e2e: bool = False, use_cache: bool = True, run_id: str = "", resume: bool = False,
1399
+ max_failures: int = 0, fail_fast: bool = False, jobs: int = 0,
1400
+ profile: str = "full", force_ids: Sequence[str] = (),
1401
+ only_scenarios: Optional[Sequence[str]] = None) -> dict[str, object]:
1402
+ started = time.monotonic()
1403
+ full_data, data, items, noop = _prepare_manifest(manifest_file, root, profile, force_ids, owner, only_scenarios)
1404
+ if noop is not None:
1405
+ return noop
1406
+ ordered = _ordered(items)
1407
+ expanded, aggregates = _expand_multi_checks(ordered)
1408
+ rows = _scenario_rows(data["scenarios"])
1409
+ previous = _previous_check_hashes(data)
1410
+ check_list, check_map = _check_plan(data)
1411
+ from codeflow.acceptance.cf_acceptance_manifest_core import kind
1412
+ for item in expanded:
1413
+ check = check_map.get(str(item.get("check", "")), {})
1414
+ level = check.get("level")
1415
+ if isinstance(level, str):
1416
+ item["level"], item["kind"] = level, kind(level)
1417
+ for item in items:
1418
+ if isinstance(item, dict):
1419
+ item["check_config"] = check_map.get(str(item.get("check", "")), {})
1420
+ run_key = resolve_run_id(run_id)
1421
+ namespace = _run_namespace(str(root), check_map, owner, items, run_key)
1422
+ preflight_started = time.monotonic()
1423
+ planning_ms = (preflight_started - started) * 1000
1424
+ fingerprint = worktree_fingerprint(str(root)) if use_cache else ""
1425
+ check_fingerprints = _check_input_fingerprints(str(root), check_map, fingerprint) if use_cache else {}
1426
+ fingerprints = {check_id: value["input_hash"] for check_id, value in check_fingerprints.items()}
1427
+ scenario_fingerprints = _scenario_fingerprints(data, fingerprints, fingerprint)
1428
+ engine_fingerprints = {**fingerprints, **scenario_fingerprints}
1429
+ limit = 1 if fail_fast else max(0, int(max_failures))
1430
+ checkpoint = _make_checkpoint(manifest_file, root, fingerprint, use_cache, resume, fingerprints)
1431
+ units, _ = _build_units(expanded, Path(root), check_map, include_e2e or only_e2e, only_e2e)
1432
+ engine = _UnitEngine(units, Path(root), include_e2e or only_e2e, only_e2e, deadline,
1433
+ namespace, rows, fingerprint, use_cache, limit, checkpoint, jobs,
1434
+ fingerprints=engine_fingerprints)
1435
+ preflight_ms = (time.monotonic() - preflight_started) * 1000
1436
+ results_by_id, state = engine.run()
1437
+ results = _ordered_results(expanded if aggregates else ordered, results_by_id)
1438
+ results = _aggregate_multi_results(results, aggregates)
1439
+ for result, item in zip(results, ordered):
1440
+ if isinstance(result, dict) and isinstance(item, dict) and item.get("check_config"):
1441
+ result["check_config"] = item.get("check_config")
1442
+ evidence_started = time.monotonic()
1443
+ if not state.get("interrupted") and (write_evidence or resume):
1444
+ persist_results(Path(manifest_file), full_data, results, fingerprint=fingerprint, run_id=run_key)
1445
+ evidence_ms = (time.monotonic() - evidence_started) * 1000
1446
+ outcome = _payload(items, check_list, check_map, results, state)
1447
+ _add_run_reporting(outcome, state, check_fingerprints, data,
1448
+ started, planning_ms, preflight_ms, evidence_ms, profile, owner, previous)
1449
+ return _finalize_manifest(outcome, state, items, results)
1450
+
1451
+
1452
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
1453
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None)
1454
+ parser.add_argument("--manifest", required=True)
1455
+ parser.add_argument("--root", required=True)
1456
+ parser.add_argument("--write-evidence", action="store_true")
1457
+ parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
1458
+ parser.add_argument("--only-e2e", action="store_true", help="Execute only E2E scenarios; functional/manual are reported not_included")
1459
+ parser.add_argument("--owner", default="", help="Only execute scenarios owned by this TASK (plus ownerless legacy ones)")
1460
+ parser.add_argument("--deadline", type=float, default=0.0, help="Absolute monotonic deadline propagated from the entry gate (0 = unbounded)")
1461
+ parser.add_argument("--no-cache", action="store_true", help="Force execution; skip content-fingerprint reuse of already-verified scenarios")
1462
+ parser.add_argument("--resume", action="store_true", help="Continue from the first unfinished/failed check using persisted checkpoints")
1463
+ parser.add_argument("--max-failures", type=int, default=0, help="Stop scheduling after N failed checks (0 = run all)")
1464
+ failure_mode = parser.add_mutually_exclusive_group()
1465
+ failure_mode.add_argument("--fail-fast", dest="fail_fast", action="store_true", help="Stop after the first failed check")
1466
+ failure_mode.add_argument("--continue", dest="fail_fast", action="store_false", help="Keep running after failures (default)")
1467
+ parser.set_defaults(fail_fast=False)
1468
+ parser.add_argument("--jobs", type=int, default=0, help="Maximum concurrent parallel-safe checks (0 = auto)")
1469
+ args = parser.parse_args(argv)
1470
+ try:
1471
+ result = run_manifest(
1472
+ args.manifest, args.root, args.write_evidence, args.include_e2e, args.owner,
1473
+ args.deadline or None, args.only_e2e, use_cache=not args.no_cache,
1474
+ resume=args.resume, max_failures=args.max_failures, fail_fast=args.fail_fast,
1475
+ jobs=args.jobs,
1476
+ )
1477
+ stdout.write(json.dumps(redact_sensitive_payload(result), ensure_ascii=False))
1478
+ if result.get("interrupted"):
1479
+ return 130
1480
+ return 0 if result["decision"] == "pass" else 3
1481
+ except AcceptanceInterrupted as exc:
1482
+ stdout.write(json.dumps(redact_sensitive_payload(exc.payload), ensure_ascii=False))
1483
+ return 130
1484
+ except KeyboardInterrupt:
1485
+ stdout.write(json.dumps({"decision": "block", "error": "interrupted", "interrupted": True},
1486
+ ensure_ascii=False))
1487
+ return 130
1488
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1489
+ stdout.write(json.dumps(redact_sensitive_payload({"decision": "block", "error": str(exc)}), ensure_ascii=False))
1490
+ return 2