@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,266 @@
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
+
@@ -0,0 +1,172 @@
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()}))