@michaelxwb/code-flow 0.8.1 → 1.0.0

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 (268) hide show
  1. package/README.md +73 -152
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +156 -1417
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +18 -21
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/acceptance-contract.md +0 -217
  54. package/docs/ci/acceptance-evidence.md +0 -77
  55. package/docs/migrations/runtime-0.7.0.md +0 -41
  56. package/docs/migrations/runtime-0.8.0.md +0 -154
  57. package/docs/migrations/runtime-0.9.0.md +0 -40
  58. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  59. package/docs/spec-injection-hardening.md +0 -54
  60. package/docs/spec-injection-performance.json +0 -73
  61. package/docs/spec-optimization.md +0 -12
  62. package/src/adapters/claude/CLAUDE.md +0 -45
  63. package/src/adapters/claude/commands/cf-init.md +0 -262
  64. package/src/adapters/claude/commands/cf-learn.md +0 -291
  65. package/src/adapters/claude/commands/cf-spec.md +0 -87
  66. package/src/adapters/claude/commands/cf-stats.md +0 -96
  67. package/src/adapters/claude/commands/cf-sync.md +0 -39
  68. package/src/adapters/claude/commands/cf-task/align.md +0 -331
  69. package/src/adapters/claude/commands/cf-task/archive.md +0 -155
  70. package/src/adapters/claude/commands/cf-task/block.md +0 -49
  71. package/src/adapters/claude/commands/cf-task/graph.md +0 -92
  72. package/src/adapters/claude/commands/cf-task/note.md +0 -248
  73. package/src/adapters/claude/commands/cf-task/plan.md +0 -477
  74. package/src/adapters/claude/commands/cf-task/prd.md +0 -173
  75. package/src/adapters/claude/commands/cf-task/start.md +0 -317
  76. package/src/adapters/claude/commands/cf-task/status.md +0 -71
  77. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
  78. package/src/adapters/claude/commands/cf-validate.md +0 -100
  79. package/src/adapters/claude/settings.local.json +0 -50
  80. package/src/adapters/codex/AGENTS.md +0 -45
  81. package/src/adapters/codex/config.toml +0 -3
  82. package/src/adapters/codex/hooks.json +0 -54
  83. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  84. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  85. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
  86. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
  87. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
  88. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
  89. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
  90. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
  91. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
  92. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
  93. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
  94. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
  95. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
  96. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
  97. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
  98. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
  99. package/src/adapters/commands.json +0 -115
  100. package/src/adapters/costrict/CLAUDE.md +0 -45
  101. package/src/adapters/costrict/commands/cf-init.md +0 -262
  102. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  103. package/src/adapters/costrict/commands/cf-spec.md +0 -87
  104. package/src/adapters/costrict/commands/cf-stats.md +0 -96
  105. package/src/adapters/costrict/commands/cf-sync.md +0 -39
  106. package/src/adapters/costrict/commands/cf-task/align.md +0 -331
  107. package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
  108. package/src/adapters/costrict/commands/cf-task/block.md +0 -49
  109. package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
  110. package/src/adapters/costrict/commands/cf-task/note.md +0 -248
  111. package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
  112. package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
  113. package/src/adapters/costrict/commands/cf-task/start.md +0 -317
  114. package/src/adapters/costrict/commands/cf-task/status.md +0 -71
  115. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
  116. package/src/adapters/costrict/commands/cf-validate.md +0 -100
  117. package/src/adapters/costrict/settings.local.json +0 -50
  118. package/src/adapters/install-plan.json +0 -100
  119. package/src/adapters/opencode/AGENTS.md +0 -45
  120. package/src/adapters/opencode/commands/cf-init.md +0 -281
  121. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  122. package/src/adapters/opencode/commands/cf-spec.md +0 -91
  123. package/src/adapters/opencode/commands/cf-stats.md +0 -100
  124. package/src/adapters/opencode/commands/cf-sync.md +0 -44
  125. package/src/adapters/opencode/commands/cf-task/align.md +0 -331
  126. package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
  127. package/src/adapters/opencode/commands/cf-task/block.md +0 -54
  128. package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
  129. package/src/adapters/opencode/commands/cf-task/note.md +0 -253
  130. package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
  131. package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
  132. package/src/adapters/opencode/commands/cf-task/start.md +0 -317
  133. package/src/adapters/opencode/commands/cf-task/status.md +0 -76
  134. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
  135. package/src/adapters/opencode/commands/cf-validate.md +0 -105
  136. package/src/adapters/opencode/opencode.json +0 -3
  137. package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
  138. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  139. package/src/adapters/platform-bindings.json +0 -370
  140. package/src/cli.js +0 -1076
  141. package/src/core/code-flow/config.yml +0 -161
  142. package/src/core/code-flow/runtime-commands.json +0 -259
  143. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
  144. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
  145. package/src/core/code-flow/scripts/cf_audit.py +0 -10
  146. package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
  147. package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
  148. package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
  149. package/src/core/code-flow/scripts/cf_feedback.py +0 -10
  150. package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
  151. package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
  152. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
  153. package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
  154. package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
  155. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
  156. package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
  157. package/src/core/code-flow/scripts/cf_stats.py +0 -10
  158. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
  159. package/src/core/code-flow/scripts/cf_sync.py +0 -10
  160. package/src/core/code-flow/scripts/cf_task_index.py +0 -10
  161. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
  162. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
  163. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
  164. package/src/core/code-flow/scripts/cf_validation.py +0 -10
  165. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  166. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  167. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
  168. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
  169. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
  170. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
  171. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
  172. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
  173. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
  174. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
  175. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
  176. package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
  177. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
  178. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
  179. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
  180. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
  181. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  182. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
  183. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
  184. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
  185. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
  186. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
  187. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
  188. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
  189. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
  190. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
  191. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
  192. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
  193. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
  194. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
  195. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
  196. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
  197. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
  198. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  199. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
  200. package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
  201. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
  202. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
  203. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
  204. package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
  205. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
  206. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
  207. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
  208. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
  209. package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
  210. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
  211. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
  212. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
  213. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
  214. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  215. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
  216. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  217. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
  218. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
  219. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
  220. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
  221. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
  222. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
  223. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  224. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
  225. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
  226. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
  227. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
  228. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
  229. package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
  230. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
  231. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
  232. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
  233. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
  234. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
  235. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
  236. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
  237. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
  238. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
  239. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
  240. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
  241. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
  242. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
  243. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
  244. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  245. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
  246. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
  247. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
  248. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
  249. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
  250. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
  251. package/src/core/code-flow/scripts/runtime-resources.json +0 -29
  252. package/src/core/code-flow/specs/backend/_map.md +0 -56
  253. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  254. package/src/core/code-flow/specs/backend/database.md +0 -51
  255. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  256. package/src/core/code-flow/specs/backend/logging.md +0 -50
  257. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  258. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  259. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  260. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  261. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  262. package/src/core/code-flow/specs/shared/_map.md +0 -57
  263. package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
  264. package/src/core/code-flow/tasks/.gitkeep +0 -0
  265. package/src/core/code-flow/validation.yml +0 -59
  266. package/src/migrate/runtime.js +0 -213
  267. package/src/migrate/spec-workflow.js +0 -253
  268. package/src/runtime.js +0 -219
@@ -1,266 +0,0 @@
1
- """Shared owner, profile and dependency selection for acceptance scenarios."""
2
- from __future__ import annotations
3
-
4
- from dataclasses import dataclass
5
- import subprocess
6
- from typing import Callable, Mapping, Optional, Sequence
7
-
8
- from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids
9
- from codeflow.acceptance.cf_check_inputs import match_path, missing_input_patterns, repo_input_files
10
-
11
-
12
- def select_owned(items: list[Mapping[str, object]], owner: str) -> list[Mapping[str, object]]:
13
- """Keep scenarios owned by the active TASK; ownerless legacy scenarios stay
14
- included so old manifests keep working. Cross-TASK depends_on edges are
15
- pruned: single-TASK ordering is enforced here, whole-demand ordering at
16
- the final (archive/E2E) gate."""
17
- if not owner:
18
- return items
19
- # 逗号分隔时按多个 owner 过滤:批次 smoke 只跑本批已并入 TASK 的场景。
20
- owners = {value.strip() for value in owner.split(",") if value.strip()}
21
- kept = [
22
- item for item in items
23
- if not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") in owners
24
- ]
25
- kept_ids = {str(item.get("id")) for item in kept}
26
- pruned: list[Mapping[str, object]] = []
27
- for item in kept:
28
- if not isinstance(item, dict):
29
- pruned.append(item)
30
- continue
31
- deps = item.get("depends_on")
32
- if isinstance(deps, list):
33
- item = {**item, "depends_on": [dep for dep in deps if str(dep) in kept_ids]}
34
- pruned.append(item)
35
- return pruned
36
-
37
-
38
- def changed_paths(root: str) -> set[str]:
39
- changed: set[str] = set()
40
- for arguments in (("diff", "--no-renames", "--name-only", "HEAD"), ("ls-files", "--others", "--exclude-standard")):
41
- completed = subprocess.run(("git", "-C", root, *arguments), text=True, capture_output=True, check=False)
42
- if completed.returncode != 0:
43
- return set()
44
- changed.update(line.strip() for line in completed.stdout.splitlines() if line.strip())
45
- return changed
46
-
47
-
48
- @dataclass(frozen=True)
49
- class _ProfileContext:
50
- """Manifest-wide facts shared by every per-scenario selection rule."""
51
-
52
- checks: Mapping[str, Mapping[str, object]]
53
- changed: frozenset[str]
54
- scenarios: tuple[Mapping[str, object], ...]
55
- explicit_priority: bool
56
- forced: frozenset[str]
57
- has_smoke_profile: bool
58
- has_e2e: bool
59
- e2e_smoke_exists: bool
60
-
61
-
62
- @dataclass(frozen=True)
63
- class _ScenarioFacts:
64
- """Per-scenario facts computed once, then consumed by the rule tables."""
65
-
66
- scenario_id: str
67
- priority: str
68
- is_e2e: bool
69
- smoke: bool
70
- check_ids: tuple[str, ...]
71
- scenario_checks: tuple[Optional[Mapping[str, object]], ...]
72
- path_patterns: tuple[str, ...]
73
- path_matches: bool
74
- repo_scoped: bool
75
- inputs_match: bool
76
- inputs_incomplete: bool
77
-
78
-
79
- _RuleTable = tuple[tuple[Callable[["_ProfileContext", "_ScenarioFacts"], bool],
80
- Callable[["_ProfileContext", "_ScenarioFacts"], bool]], ...]
81
-
82
-
83
- # Ordered (condition, decision) rules. The first matching condition decides
84
- # whether the scenario runs; later rules are not consulted. Explicit smoke
85
- # markers on E2E journeys keep smoke from silently expanding to every E2E.
86
- _SMOKE_RULES: _RuleTable = (
87
- (lambda ctx, facts: facts.is_e2e,
88
- lambda ctx, facts: facts.smoke or not ctx.e2e_smoke_exists),
89
- (lambda ctx, facts: ctx.explicit_priority,
90
- lambda ctx, facts: facts.priority.upper() == "P0" or facts.smoke),
91
- (lambda ctx, facts: ctx.has_smoke_profile,
92
- lambda ctx, facts: facts.smoke),
93
- (lambda ctx, facts: ctx.has_e2e,
94
- lambda ctx, facts: False),
95
- (lambda ctx, facts: True,
96
- lambda ctx, facts: True),
97
- )
98
-
99
- # Changed runs only path-scoped E2E journeys. An empty changed set means the
100
- # diff is unavailable, so E2E journeys fail closed and still run. Smoke E2E
101
- # without trigger paths belongs to the explicit smoke profile.
102
- _CHANGED_RULES: _RuleTable = (
103
- (lambda ctx, facts: facts.is_e2e and bool(facts.path_patterns),
104
- lambda ctx, facts: not ctx.changed or facts.path_matches or facts.inputs_incomplete or facts.inputs_match),
105
- (lambda ctx, facts: facts.is_e2e and facts.smoke,
106
- lambda ctx, facts: False),
107
- (lambda ctx, facts: True,
108
- lambda ctx, facts: (not ctx.explicit_priority
109
- or facts.priority.upper() == "P0"
110
- or facts.smoke
111
- or facts.repo_scoped
112
- or not ctx.changed
113
- or facts.inputs_match)),
114
- )
115
-
116
- _PROFILE_RULES: Mapping[str, _RuleTable] = {
117
- "smoke": _SMOKE_RULES,
118
- "changed": _CHANGED_RULES,
119
- }
120
-
121
-
122
- def _apply_rule_table(rules: _RuleTable, ctx: _ProfileContext,
123
- facts: _ScenarioFacts) -> bool:
124
- for condition, decision in rules:
125
- if condition(ctx, facts):
126
- return decision(ctx, facts)
127
- return False
128
-
129
-
130
- def _profile_context(scenarios: Sequence[Mapping[str, object]],
131
- checks: Mapping[str, Mapping[str, object]],
132
- changed: Sequence[str],
133
- force_ids: Sequence[str]) -> _ProfileContext:
134
- def _is_smoke(scenario: Mapping[str, object]) -> bool:
135
- return bool(scenario.get("smoke")) or any(
136
- str((checks.get(check_id) or {}).get("profile", "")) == "smoke"
137
- for check_id in scenario_check_ids(scenario)
138
- )
139
-
140
- return _ProfileContext(
141
- checks=checks,
142
- changed=frozenset(changed),
143
- scenarios=tuple(scenarios),
144
- explicit_priority=any(isinstance(item.get("priority"), str) for item in scenarios),
145
- forced=frozenset(str(item) for item in force_ids),
146
- has_smoke_profile=any(
147
- str((check or {}).get("profile", "")) == "smoke" for check in checks.values()),
148
- has_e2e=any(str(item.get("kind", "")) == "e2e" for item in scenarios),
149
- e2e_smoke_exists=any(
150
- str(item.get("kind", "")) == "e2e" and _is_smoke(item) for item in scenarios),
151
- )
152
-
153
-
154
- def _scenario_facts(ctx: _ProfileContext, scenario: Mapping[str, object]) -> _ScenarioFacts:
155
- check_ids = tuple(scenario_check_ids(scenario))
156
- scenario_checks = tuple(ctx.checks.get(check_id) for check_id in check_ids)
157
- path_patterns = [
158
- str(pattern) for pattern in (scenario.get("trigger_paths") or [])
159
- if str(pattern).strip()
160
- ]
161
- path_patterns.extend(
162
- str(pattern)
163
- for check in scenario_checks if isinstance(check, Mapping)
164
- for pattern in (check.get("paths") or [])
165
- if str(pattern).strip()
166
- )
167
- path_patterns = tuple(dict.fromkeys(path_patterns))
168
- inputs_by_check = [(check or {}).get("inputs") for check in scenario_checks]
169
- return _ScenarioFacts(
170
- scenario_id=str(scenario.get("id")),
171
- priority=str(scenario.get("priority", "")),
172
- is_e2e=str(scenario.get("kind", "")) == "e2e",
173
- smoke=(bool(scenario.get("smoke")) or any(
174
- str((check or {}).get("profile", "")) == "smoke" for check in scenario_checks)),
175
- check_ids=check_ids,
176
- scenario_checks=scenario_checks,
177
- path_patterns=path_patterns,
178
- path_matches=any(
179
- match_path(path, pattern) for pattern in path_patterns for path in ctx.changed),
180
- repo_scoped=not check_ids or any(not value for value in inputs_by_check)
181
- or any((check or {}).get("_inputs_incomplete") for check in scenario_checks),
182
- inputs_match=any(
183
- match_path(path, str(pattern))
184
- for value in inputs_by_check if isinstance(value, list)
185
- for pattern in value for path in ctx.changed),
186
- inputs_incomplete=any((check or {}).get("_inputs_incomplete") for check in scenario_checks),
187
- )
188
-
189
-
190
- def _scenario_selected(ctx: _ProfileContext, scenario: Mapping[str, object],
191
- profile: str) -> bool:
192
- facts = _scenario_facts(ctx, scenario)
193
- if facts.scenario_id in ctx.forced:
194
- return True
195
- return _apply_rule_table(_PROFILE_RULES[profile], ctx, facts)
196
-
197
-
198
- def _with_dependencies(scenarios: Sequence[Mapping[str, object]],
199
- selected: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
200
- selected_ids = {str(item.get("id")) for item in selected}
201
- by_scenario = {str(item.get("id")): item for item in scenarios}
202
- pending = list(selected)
203
- while pending:
204
- current = pending.pop()
205
- deps = current.get("depends_on", [])
206
- if not isinstance(deps, list):
207
- continue
208
- for dep in deps:
209
- dep_id = str(dep)
210
- if dep_id in selected_ids or dep_id not in by_scenario:
211
- continue
212
- selected.append(by_scenario[dep_id])
213
- selected_ids.add(dep_id)
214
- pending.append(by_scenario[dep_id])
215
- return selected
216
-
217
-
218
- def select_scenarios(scenarios: Sequence[Mapping[str, object]],
219
- checks: Mapping[str, Mapping[str, object]],
220
- profile: str, changed: Sequence[str],
221
- force_ids: Sequence[str] = ()) -> list[Mapping[str, object]]:
222
- ctx = _profile_context(scenarios, checks, changed, force_ids)
223
- selected = [scenario for scenario in ctx.scenarios
224
- if _scenario_selected(ctx, scenario, profile)]
225
- return _with_dependencies(ctx.scenarios, selected)
226
-
227
-
228
- def _profile_result(data: Mapping[str, object], profile: str,
229
- selected: Sequence[Mapping[str, object]]) -> dict[str, object]:
230
- selected_check_ids = {
231
- check_id for item in selected for check_id in scenario_check_ids(item)
232
- }
233
- result = dict(data)
234
- result["scenarios"] = list(selected)
235
- if "checks" in data:
236
- result["checks"] = [
237
- item for item in data.get("checks", []) or []
238
- if isinstance(item, Mapping) and str(item.get("id")) in selected_check_ids
239
- ]
240
- result["profile"] = profile
241
- return result
242
-
243
-
244
- def select_profile(data: Mapping[str, object], root: str, profile: str,
245
- force_ids: Sequence[str] = ()) -> dict[str, object]:
246
- """Return a shallow manifest view filtered by smoke/changed/full."""
247
- if profile == "full":
248
- return dict(data)
249
- checks = {
250
- str(item.get("id")): item
251
- for item in data.get("checks", []) or []
252
- if isinstance(item, Mapping) and isinstance(item.get("id"), str)
253
- }
254
- files = repo_input_files(root)
255
- missing = {key: missing_input_patterns(root, check.get("inputs", []), files)
256
- for key, check in checks.items()}
257
- checks = {key: {**check, "_inputs_incomplete": bool(missing[key])} for key, check in checks.items()}
258
- scenarios = [item for item in data.get("scenarios", []) or [] if isinstance(item, Mapping)]
259
- selected = select_scenarios(scenarios, checks, profile, changed_paths(root), force_ids)
260
- result = _profile_result(data, profile, selected)
261
- if profile == "changed" and any(missing.values()):
262
- result["selection_reason"] = "incomplete_inputs_conservative"
263
- result["missing_input_patterns"] = {key: value for key, value in missing.items() if value}
264
- return result
265
-
266
-
@@ -1,172 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Per-check input hashing: content-addressed identity for incremental verify.
3
-
4
- `check.inputs` declares the files a check depends on. Hashing only those files
5
- lets `changed` rerun affected checks instead of the whole worktree. When inputs
6
- are absent the caller falls back to the repo-wide fingerprint with scope=repo.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import json
13
- import fnmatch
14
- import re
15
- import subprocess
16
- from pathlib import Path
17
- from typing import Mapping, Optional, Sequence
18
-
19
-
20
- def glob_to_regex(pattern: str) -> re.Pattern:
21
- """Translate a git-style glob to regex; `**/` matches zero directories."""
22
- out: list[str] = []
23
- index = 0
24
- while index < len(pattern):
25
- char = pattern[index]
26
- if char == "*":
27
- if pattern[index:index + 3] == "**/":
28
- out.append("(?:.*/)?")
29
- index += 3
30
- continue
31
- if pattern[index:index + 2] == "**":
32
- out.append(".*")
33
- index += 2
34
- continue
35
- out.append("[^/]*")
36
- index += 1
37
- continue
38
- if char == "?":
39
- out.append("[^/]")
40
- else:
41
- out.append(re.escape(char))
42
- index += 1
43
- return re.compile("^" + "".join(out) + "$")
44
-
45
-
46
- def match_path(path: str, pattern: str) -> bool:
47
- normalized = path.replace("\\", "/")
48
- # Git pathspec: a pattern without a slash matches at any depth.
49
- if "/" not in pattern:
50
- return fnmatch.fnmatchcase(normalized.rsplit("/", 1)[-1], pattern)
51
- return bool(glob_to_regex(pattern).match(normalized))
52
-
53
-
54
- def _git(root: str, *arguments: str) -> Optional[str]:
55
- completed = subprocess.run(
56
- ("git", "-C", root, *arguments), text=True, encoding="utf-8", capture_output=True, check=False
57
- )
58
- return completed.stdout if completed.returncode == 0 else None
59
-
60
-
61
- def expand_inputs(root: str, patterns: Sequence[str]) -> tuple[str, ...]:
62
- """Tracked + untracked files matching the declared input globs."""
63
- if not patterns:
64
- return ()
65
- tracked = _git(root, "ls-files")
66
- untracked = _git(root, "ls-files", "--others", "--exclude-standard")
67
- if tracked is None or untracked is None:
68
- return ()
69
- paths: set[str] = set()
70
- for line in (tracked + untracked).splitlines():
71
- path = line.strip()
72
- if path and any(match_path(path, pattern) for pattern in patterns):
73
- paths.add(path)
74
- return tuple(sorted(paths))
75
-
76
-
77
- def hash_files(root: str, paths: Sequence[str]) -> Optional[dict[str, str]]:
78
- """Batch-hash worktree contents (one git process)."""
79
- if not paths:
80
- return {}
81
- completed = subprocess.run(
82
- ("git", "-C", root, "hash-object", "--stdin-paths"),
83
- input="\n".join(paths) + "\n", text=True, encoding="utf-8", capture_output=True, check=False,
84
- )
85
- if completed.returncode != 0:
86
- return None
87
- hashes = completed.stdout.splitlines()
88
- if len(hashes) != len(paths):
89
- return None
90
- return {path: sha.strip() for path, sha in zip(paths, hashes)}
91
-
92
-
93
- def check_input_hash(root: str, check: Mapping[str, object],
94
- fallback: str = "") -> dict[str, object]:
95
- """Return {"input_hash", "scope"} for one check."""
96
- return check_input_hashes(root, {"": check}, fallback)[""]
97
-
98
-
99
- def _registered_fallback(check: Mapping[str, object], fallback: str) -> str:
100
- registration = {key: check[key] for key in ("suite", "test_ids", "markers", "collect", "level", "environment_revision", "evidence_ttl_seconds") if key in check}
101
- if not fallback or not registration:
102
- return fallback
103
- payload = json.dumps([fallback, registration], sort_keys=True, separators=(",", ":"))
104
- return hashlib.sha256(payload.encode()).hexdigest()
105
-
106
-
107
- def missing_input_patterns(root: str, patterns: Sequence[str], files: Sequence[str]) -> list[str]:
108
- """Report patterns with no present file; deleted and unknown inputs are unsafe."""
109
- base = Path(root)
110
- return [pattern for pattern in patterns if not any(
111
- match_path(path, pattern) and (base / path).is_file() for path in files
112
- )]
113
-
114
-
115
- def check_input_hashes(root: str, checks: Mapping[str, Mapping[str, object]],
116
- fallback: str = "") -> dict[str, dict[str, object]]:
117
- """Hash the union of all declared inputs once, then derive per-check identities."""
118
- patterns_by_check: dict[str, list[str]] = {}
119
- for check_id, check in checks.items():
120
- value = check.get("inputs")
121
- patterns_by_check[check_id] = [str(item) for item in value] if isinstance(value, list) else []
122
- all_patterns = sorted({pattern for values in patterns_by_check.values() for pattern in values})
123
- repo_files = repo_input_files(root) if all_patterns else ()
124
- matched: dict[str, list[str]] = {}
125
- missing: dict[str, list[str]] = {}
126
- union: set[str] = set()
127
- for check_id, patterns in patterns_by_check.items():
128
- missing[check_id] = missing_input_patterns(root, patterns, repo_files)
129
- files = [path for path in repo_files if (Path(root) / path).is_file()
130
- and any(match_path(path, pattern) for pattern in patterns)]
131
- matched[check_id] = files
132
- union.update(files)
133
- hashed = hash_files(root, sorted(union))
134
- result: dict[str, dict[str, object]] = {}
135
- for check_id, patterns in patterns_by_check.items():
136
- bound_fallback = _registered_fallback(checks[check_id], fallback)
137
- if not patterns:
138
- result[check_id] = {"input_hash": bound_fallback, "scope": "repo"}
139
- continue
140
- if missing[check_id]:
141
- result[check_id] = {"input_hash": bound_fallback, "scope": "incomplete",
142
- "missing_patterns": missing[check_id]}
143
- continue
144
- if not matched[check_id]:
145
- result[check_id] = {"input_hash": bound_fallback, "scope": "empty"}
146
- continue
147
- if hashed is None:
148
- result[check_id] = {"input_hash": "", "scope": "unavailable"}
149
- continue
150
- check = checks[check_id]
151
- identity = {
152
- key: check.get(key) for key in
153
- ("command", "cwd", "timeout", "env", "env_scope", "preflight", "parallel_safe", "profile")
154
- }
155
- identity.update({key: check[key] for key in ("suite", "test_ids", "markers", "collect", "level", "environment_revision", "evidence_ttl_seconds") if key in check})
156
- payload = json.dumps(
157
- [identity, sorted((path, hashed[path]) for path in matched[check_id])],
158
- ensure_ascii=False, sort_keys=True,
159
- )
160
- result[check_id] = {
161
- "input_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
162
- "scope": "inputs",
163
- }
164
- return result
165
-
166
-
167
- def repo_input_files(root: str) -> tuple[str, ...]:
168
- tracked = _git(root, "ls-files")
169
- untracked = _git(root, "ls-files", "--others", "--exclude-standard")
170
- if tracked is None or untracked is None:
171
- return ()
172
- return tuple(sorted({line.strip() for line in (tracked + untracked).splitlines() if line.strip()}))