@michaelxwb/code-flow 0.6.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +47 -3
  2. package/docs/USAGE.md +1529 -0
  3. package/docs/acceptance-contract.md +217 -0
  4. package/docs/ci/acceptance-evidence.md +77 -0
  5. package/docs/migrations/runtime-0.7.0.md +41 -0
  6. package/docs/migrations/runtime-0.8.0.md +154 -0
  7. package/docs/migrations/runtime-0.9.0.md +40 -0
  8. package/docs/spec-injection-hardening.md +54 -0
  9. package/docs/spec-injection-performance.json +73 -0
  10. package/docs/spec-optimization.md +12 -0
  11. package/package.json +18 -3
  12. package/src/adapters/claude/CLAUDE.md +1 -1
  13. package/src/adapters/claude/commands/cf-init.md +1 -3
  14. package/src/adapters/claude/commands/cf-spec.md +23 -3
  15. package/src/adapters/claude/commands/cf-stats.md +11 -1
  16. package/src/adapters/claude/commands/cf-sync.md +20 -6
  17. package/src/adapters/claude/commands/cf-task/align.md +25 -3
  18. package/src/adapters/claude/commands/cf-task/archive.md +36 -42
  19. package/src/adapters/claude/commands/cf-task/block.md +15 -2
  20. package/src/adapters/claude/commands/cf-task/graph.md +13 -1
  21. package/src/adapters/claude/commands/cf-task/note.md +13 -1
  22. package/src/adapters/claude/commands/cf-task/plan.md +137 -23
  23. package/src/adapters/claude/commands/cf-task/prd.md +13 -2
  24. package/src/adapters/claude/commands/cf-task/start.md +52 -39
  25. package/src/adapters/claude/commands/cf-task/status.md +12 -0
  26. package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
  27. package/src/adapters/claude/commands/cf-validate.md +30 -5
  28. package/src/adapters/codex/AGENTS.md +1 -1
  29. package/src/adapters/codex/hooks.json +14 -10
  30. package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
  31. package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
  32. package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
  33. package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
  34. package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
  35. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
  36. package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
  37. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
  38. package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
  39. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
  40. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
  41. package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
  42. package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
  43. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
  44. package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
  45. package/src/adapters/commands.json +115 -0
  46. package/src/adapters/costrict/CLAUDE.md +1 -1
  47. package/src/adapters/costrict/commands/cf-init.md +1 -3
  48. package/src/adapters/costrict/commands/cf-spec.md +23 -3
  49. package/src/adapters/costrict/commands/cf-stats.md +11 -1
  50. package/src/adapters/costrict/commands/cf-sync.md +20 -6
  51. package/src/adapters/costrict/commands/cf-task/align.md +25 -3
  52. package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
  53. package/src/adapters/costrict/commands/cf-task/block.md +15 -2
  54. package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
  55. package/src/adapters/costrict/commands/cf-task/note.md +13 -1
  56. package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
  57. package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
  58. package/src/adapters/costrict/commands/cf-task/start.md +52 -39
  59. package/src/adapters/costrict/commands/cf-task/status.md +12 -0
  60. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
  61. package/src/adapters/costrict/commands/cf-validate.md +30 -5
  62. package/src/adapters/install-plan.json +100 -0
  63. package/src/adapters/opencode/AGENTS.md +1 -1
  64. package/src/adapters/opencode/commands/cf-init.md +7 -7
  65. package/src/adapters/opencode/commands/cf-learn.md +6 -6
  66. package/src/adapters/opencode/commands/cf-spec.md +34 -2
  67. package/src/adapters/opencode/commands/cf-stats.md +17 -7
  68. package/src/adapters/opencode/commands/cf-sync.md +21 -6
  69. package/src/adapters/opencode/commands/cf-task/align.md +25 -8
  70. package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
  71. package/src/adapters/opencode/commands/cf-task/block.md +16 -2
  72. package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
  73. package/src/adapters/opencode/commands/cf-task/note.md +14 -1
  74. package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
  75. package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
  76. package/src/adapters/opencode/commands/cf-task/start.md +53 -45
  77. package/src/adapters/opencode/commands/cf-task/status.md +13 -0
  78. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
  79. package/src/adapters/opencode/commands/cf-validate.md +35 -9
  80. package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
  81. package/src/adapters/platform-bindings.json +370 -0
  82. package/src/cli.js +369 -316
  83. package/src/core/code-flow/config.yml +4 -1
  84. package/src/core/code-flow/runtime-commands.json +259 -0
  85. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
  86. package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
  87. package/src/core/code-flow/scripts/cf_audit.py +10 -0
  88. package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
  89. package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
  90. package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
  91. package/src/core/code-flow/scripts/cf_feedback.py +5 -51
  92. package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
  93. package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
  94. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
  95. package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
  96. package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
  97. package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
  98. package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
  99. package/src/core/code-flow/scripts/cf_stats.py +5 -501
  100. package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
  101. package/src/core/code-flow/scripts/cf_sync.py +5 -135
  102. package/src/core/code-flow/scripts/cf_task_index.py +5 -143
  103. package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
  104. package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
  105. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
  106. package/src/core/code-flow/scripts/cf_validation.py +6 -77
  107. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  108. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  109. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
  110. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
  111. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
  112. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
  113. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
  114. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
  115. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
  116. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
  117. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
  118. package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
  119. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
  120. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
  121. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
  122. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
  123. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  124. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
  125. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
  126. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
  127. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
  128. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
  129. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
  130. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
  131. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
  132. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
  133. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
  134. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
  135. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
  136. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
  137. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
  138. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
  139. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
  140. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  141. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
  142. package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
  143. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
  144. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
  145. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
  146. package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
  147. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
  148. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
  149. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
  150. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
  151. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
  152. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
  153. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
  154. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
  155. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  156. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
  157. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  158. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
  159. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
  160. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
  161. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
  162. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
  163. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
  164. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  165. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
  166. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
  167. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
  168. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
  169. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
  170. package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
  171. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
  172. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
  173. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
  174. package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
  175. package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
  176. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
  177. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
  178. package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
  179. package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
  180. package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
  181. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
  182. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
  183. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
  184. package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
  185. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  186. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
  187. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
  188. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
  189. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
  190. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
  191. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
  192. package/src/core/code-flow/scripts/runtime-resources.json +29 -0
  193. package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
  194. package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
  195. package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
  196. package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
  197. package/src/core/code-flow/validation.yml +9 -3
  198. package/src/migrate/runtime.js +213 -0
  199. package/src/migrate/spec-workflow.js +16 -1
  200. package/src/runtime.js +219 -0
  201. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  202. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  203. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  204. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  205. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  206. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  207. /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only invariant audit for design acceptance data, manifest and evidence.
3
+
4
+ The report is derived, never hand-maintained:
5
+
6
+ - scenario / rule / risk counts come from the design §2.5.1, §2.5.2 and §5 tables;
7
+ - owners come from the manifest ``owner`` field, never from prose;
8
+ - verified evidence is resolved from the central evidence sidecar first, with
9
+ the manifest run record as a compatibility fallback for pre-sidecar runs;
10
+ - findings always carry ``scenario_id`` and ``rule_ref`` so they can be fixed
11
+ without guessing which row or invariant failed.
12
+
13
+ The audit only reads files. It never rewrites the manifest, task document,
14
+ design document or evidence sidecar.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+ import re
24
+ import sys
25
+ from typing import IO, Mapping, Optional, Sequence
26
+
27
+ from codeflow.acceptance.cf_acceptance_schema import (check_outcome, load_manifest, recorded_check_hash,
28
+ scenario_check_ids, verified_evidence)
29
+ from codeflow.acceptance.cf_acceptance_risk import risk_counts
30
+ from codeflow.evidence.cf_evidence_store import EvidenceStore
31
+ from codeflow.core.cf_markdown import column_index, is_separator_row, table_cells
32
+ from codeflow.acceptance.cf_traceability import build_traceability, build_traceability_many, build_traceability_from_scenarios
33
+
34
+ _SCENARIO_RE = re.compile(r"\b[SEB]-\d+\b")
35
+ _RULE_RE = re.compile(r"^RULE-\d+$")
36
+ _TASK_RE = re.compile(r"^TASK-\d+$")
37
+
38
+ _TRACE_FINDINGS = {
39
+ "unknown_scenario": ("unknown_scenario", "scenario-source", "coverage_ids", "Coverage 引用了场景源中不存在的场景"),
40
+ "scenario_not_in_coverage": ("scenario_not_in_coverage", "scenario-source", "coverage_ids", "场景源场景未进入 Acceptance Coverage"),
41
+ "duplicate_scenario_owner": ("duplicate_scenario_owner", "scenario-source", "unique_owner", "同一场景存在多个 owner"),
42
+ "missing_check": ("missing_check", "scenario-source", "checks_exist", "场景引用了不存在的 check"),
43
+ "design_scenario_duplicate": ("design_scenario_duplicate", "scenario-source", "design_references_only", "design 仍重复定义验收场景"),
44
+ "missing_in_manifest": (
45
+ "design_scenario_missing_in_manifest",
46
+ "RULE-10",
47
+ "design_manifest_consistency",
48
+ "design 已声明但 manifest 未登记",
49
+ ),
50
+ "extra_in_manifest": (
51
+ "manifest_scenario_missing_in_design",
52
+ "RULE-10",
53
+ "design_manifest_consistency",
54
+ "manifest 已登记但 design 未声明",
55
+ ),
56
+ "unbound_scenario": (
57
+ "scenario_unbound",
58
+ "RULE-02",
59
+ "scenario_binding",
60
+ "场景没有可执行 check,且未显式标为 aspirational",
61
+ ),
62
+ }
63
+
64
+
65
+ def _section_body(text: str, heading: str) -> str:
66
+ match = re.search(rf"(?m)^(#{{2,4}})\s+{heading}(?:\s+[^\n]*)?$", text)
67
+ if match is None:
68
+ return ""
69
+ level = len(match.group(1))
70
+ tail = text[match.end():]
71
+ end = re.search(rf"(?m)^#{{1,{level}}}\s+", tail)
72
+ return tail[:end.start()] if end else tail
73
+
74
+
75
+ def _first_table(text: str) -> tuple[list[str], list[list[str]]]:
76
+ header: Optional[list[str]] = None
77
+ rows: list[list[str]] = []
78
+ for line in text.splitlines():
79
+ cells = table_cells(line)
80
+ if not cells:
81
+ if header is not None:
82
+ break
83
+ continue
84
+ if is_separator_row(cells):
85
+ continue
86
+ if header is None:
87
+ header = list(cells)
88
+ else:
89
+ rows.append(list(cells))
90
+ return header or [], rows
91
+
92
+
93
+ def _cell(row: Sequence[str], index: int) -> str:
94
+ return row[index].strip() if 0 <= index < len(row) else ""
95
+
96
+
97
+ def parse_design_rules(design_file: str) -> list[dict[str, object]]:
98
+ """Read RULE-* rows from design §2.5.1 (IDs and verified scenarios)."""
99
+ text = Path(design_file).read_text(encoding="utf-8")
100
+ header, rows = _first_table(_section_body(text, r"2\.5\.1"))
101
+ id_index = column_index(header, "id")
102
+ description_index = column_index(header, "描述", "description")
103
+ scenario_index = column_index(header, "验证场景", "场景")
104
+ rules: list[dict[str, object]] = []
105
+ for row in rows:
106
+ rule_id = _cell(row, id_index) if id_index >= 0 else _cell(row, 0)
107
+ if not _RULE_RE.match(rule_id):
108
+ continue
109
+ rules.append({
110
+ "id": rule_id,
111
+ "description": _cell(row, description_index),
112
+ "scenarios": _SCENARIO_RE.findall(_cell(row, scenario_index)),
113
+ })
114
+ return rules
115
+
116
+
117
+ def parse_design_risks(design_file: str) -> list[dict[str, object]]:
118
+ """Read §5 risk rows; risk IDs are synthesized from their derived order."""
119
+ text = Path(design_file).read_text(encoding="utf-8")
120
+ header, rows = _first_table(_section_body(text, r"5\."))
121
+ level_index = column_index(header, "等级", "level")
122
+ risks: list[dict[str, object]] = []
123
+ for index, row in enumerate(rows):
124
+ title = _cell(row, 0)
125
+ if not title or title in {"风险", "-"}:
126
+ continue
127
+ risks.append({
128
+ "id": f"RISK-{index + 1:02d}",
129
+ "title": title,
130
+ "level": _cell(row, level_index) if level_index >= 0 else _cell(row, 1),
131
+ "details": {name: _cell(row, column) for column, name in enumerate(header)},
132
+ })
133
+ return risks
134
+
135
+
136
+ def _finding(code: str, scenario_id: str, rule_ref: str, invariant: str,
137
+ message: str, **extra: object) -> dict[str, object]:
138
+ item: dict[str, object] = {
139
+ "code": code,
140
+ "scenario_id": scenario_id,
141
+ "rule_ref": rule_ref,
142
+ "invariant": invariant,
143
+ "message": message,
144
+ }
145
+ item.update(extra)
146
+ return item
147
+
148
+
149
+ def _trace_findings(trace: Mapping[str, object]) -> list[dict[str, object]]:
150
+ findings: list[dict[str, object]] = []
151
+ for violation in trace.get("violations") or ():
152
+ if not isinstance(violation, Mapping):
153
+ continue
154
+ known = _TRACE_FINDINGS.get(str(violation.get("code")))
155
+ if known is None:
156
+ continue
157
+ code, rule_ref, invariant, message = known
158
+ findings.append(_finding(
159
+ code,
160
+ str(violation.get("scenario") or ""),
161
+ rule_ref,
162
+ invariant,
163
+ f"{message}: {violation.get('message', '')}",
164
+ ))
165
+ return findings
166
+
167
+
168
+ def _owner_findings(scenarios: Mapping[str, Mapping[str, object]]) -> list[dict[str, object]]:
169
+ """Each scenario must declare exactly one owner in its manifest field."""
170
+ findings: list[dict[str, object]] = []
171
+ for scenario_id in sorted(scenarios):
172
+ scenario = scenarios[scenario_id]
173
+ owner = scenario.get("owner")
174
+ if not isinstance(owner, str) or not owner.strip():
175
+ findings.append(_finding(
176
+ "owner_missing", scenario_id, "RULE-10", "owner_unique",
177
+ f"{scenario_id} 的 owner 字段为空;请登记唯一负责任务。",
178
+ fields=["owner"],
179
+ ))
180
+ continue
181
+ tokens = re.findall(r"TASK-\d+", owner)
182
+ if len(tokens) != 1 or _TASK_RE.match(owner.strip()) is None:
183
+ findings.append(_finding(
184
+ "owner_not_unique", scenario_id, "RULE-10", "owner_unique",
185
+ f"{scenario_id} 的 owner 字段必须是单个 TASK ID,当前为 {owner!r}。",
186
+ fields=["owner"],
187
+ ))
188
+ return findings
189
+
190
+
191
+ def _scenario_evidence(scenario: Mapping[str, object], store: EvidenceStore
192
+ ) -> list[tuple[str, Mapping[str, object], str]]:
193
+ """Resolve every required check; partial sidecars cannot borrow a pass."""
194
+ ids = scenario_check_ids(scenario)
195
+ sidecars = {check_id: store.read_latest(check_id) for check_id in ids}
196
+ if any(item is not None for item in sidecars.values()):
197
+ return [(check_id, item or {}, "sidecar" if item is not None else "missing")
198
+ for check_id, item in sidecars.items()]
199
+ value = scenario.get("evidence")
200
+ evidence = value if isinstance(value, Mapping) else {}
201
+ if not ids:
202
+ return [("", evidence, "manifest" if evidence else "missing")]
203
+ return [(check_id, {**check_outcome(evidence, check_id),
204
+ "input_hash": recorded_check_hash(scenario, check_id)},
205
+ "manifest" if evidence else "missing") for check_id in ids]
206
+
207
+
208
+ def _evidence_fields(evidence: Mapping[str, object], expected_hash: str) -> list[str]:
209
+ """Check the recorded proof, not the current workspace of a historical audit."""
210
+ missing: list[str] = []
211
+ if not isinstance(evidence.get("run_id"), str) or not str(evidence["run_id"]).strip():
212
+ missing.append("run_id")
213
+ if type(evidence.get("exit_code")) is not int or evidence["exit_code"] != 0:
214
+ missing.append("exit_code")
215
+ # An explicit empty per-check hash must not fall back to the scenario hash.
216
+ input_hash = evidence.get("input_hash", evidence.get("fingerprint"))
217
+ if not isinstance(input_hash, str) or not input_hash.strip():
218
+ missing.append("input_hash")
219
+ elif expected_hash and input_hash != expected_hash:
220
+ missing.append("input_hash_mismatch")
221
+ if evidence.get("status") not in ("passed", "verified"):
222
+ missing.append("status")
223
+ return missing
224
+
225
+
226
+ def _verified_findings(scenarios: Mapping[str, Mapping[str, object]],
227
+ store: EvidenceStore) -> list[dict[str, object]]:
228
+ findings: list[dict[str, object]] = []
229
+ for scenario_id in sorted(scenarios):
230
+ scenario = scenarios[scenario_id]
231
+ if scenario.get("status") != "verified":
232
+ continue
233
+ if scenario.get("kind") == "manual" or scenario.get("exec") == "manual":
234
+ if not verified_evidence(scenario):
235
+ findings.append(_finding(
236
+ "manual_verified_evidence_missing", scenario_id, "RULE-01",
237
+ "verified_evidence", f"{scenario_id} 手写 verified 但缺少用户确认记录。",
238
+ fields=["confirmed_by", "evidence"],
239
+ ))
240
+ continue
241
+ for check_id, evidence, source in _scenario_evidence(scenario, store):
242
+ missing = _evidence_fields(evidence, recorded_check_hash(scenario, check_id))
243
+ if not missing:
244
+ continue
245
+ findings.append(_finding(
246
+ "verified_evidence_incomplete", scenario_id, "RULE-01",
247
+ "verified_evidence",
248
+ f"{scenario_id}/{check_id or 'scenario'} 已 verified,但证据无效: {', '.join(missing)}。",
249
+ check_id=check_id,
250
+ source=source,
251
+ fields=missing,
252
+ ))
253
+ return findings
254
+
255
+
256
+ def _scenario_rows(trace: Mapping[str, object],
257
+ scenarios: Mapping[str, Mapping[str, object]]) -> list[dict[str, object]]:
258
+ rows: list[dict[str, object]] = []
259
+ for item in trace.get("scenarios") or ():
260
+ if not isinstance(item, Mapping):
261
+ continue
262
+ scenario_id = str(item.get("id") or "")
263
+ scenario = scenarios.get(scenario_id)
264
+ owner = str(scenario.get("owner") or "") if scenario is not None else str(item.get("owner") or "")
265
+ rows.append({
266
+ "id": scenario_id,
267
+ "owner": owner,
268
+ "status": str(scenario.get("status") or item.get("status") or "") if scenario is not None else "",
269
+ "check": next(iter(scenario_check_ids(scenario)), "-") if scenario is not None else "-",
270
+ "checks": scenario_check_ids(scenario) if scenario is not None else [],
271
+ "binding": str(item.get("binding") or ""),
272
+ })
273
+ return rows
274
+
275
+
276
+ def _dedupe(findings: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
277
+ unique: dict[tuple[str, str, str], dict[str, object]] = {}
278
+ for item in findings:
279
+ key = (str(item.get("scenario_id", "")), str(item.get("code", "")), str(item.get("message", "")))
280
+ unique[key] = dict(item)
281
+ return sorted(unique.values(), key=lambda item: (
282
+ str(item.get("scenario_id", "")), str(item.get("code", "")), str(item.get("message", ""))
283
+ ))
284
+
285
+
286
+ def _scenario_source_findings(scenarios_file: str, manifest_path: Path,
287
+ design_paths: Sequence[Path], manifest: Mapping[str, object]) -> list[dict[str, str]]:
288
+ from codeflow.cli.cf_scenarios import audit_scenario_source, parse_scenarios
289
+ from codeflow.acceptance.cf_traceability import parse_design_scenarios
290
+ source = parse_scenarios(scenarios_file)
291
+ coverage: list[dict[str, object]] = []
292
+ task_name = manifest.get("task_file")
293
+ if isinstance(task_name, str) and task_name:
294
+ task = manifest_path.parent / task_name
295
+ try:
296
+ from codeflow.cli.cf_acceptance_manifest import extract_manifest
297
+ coverage = list(extract_manifest(str(task))["scenarios"])
298
+ except (OSError, ValueError):
299
+ coverage = []
300
+ manifest_ids = [str(item.get("id")) for item in manifest.get("scenarios", []) if isinstance(item, Mapping)]
301
+ report = audit_scenario_source(source.get("scenarios", []), coverage, manifest_ids)
302
+ violations: list[dict[str, str]] = []
303
+ for code, key in (("unknown_scenario", "unknown"), ("scenario_not_in_coverage", "missing"),
304
+ ("duplicate_scenario_owner", "duplicate_owner"),
305
+ ("missing_in_manifest", "manifest_missing"),
306
+ ("extra_in_manifest", "manifest_unknown")):
307
+ for scenario_id in report.get(key, []):
308
+ violations.append({"code": code, "scenario": str(scenario_id), "message": f"{scenario_id}: {key}"})
309
+ paths = [design_paths] if isinstance(design_paths, Path) else list(design_paths)
310
+ design_ids: set[str] = set()
311
+ for path in paths:
312
+ try:
313
+ design_ids.update(item.id for item in parse_design_scenarios(str(path)))
314
+ except (OSError, ValueError):
315
+ continue
316
+ for scenario_id in sorted(design_ids):
317
+ violations.append({"code": "design_scenario_duplicate", "scenario": scenario_id,
318
+ "message": f"{scenario_id} 仍在 design 中定义;场景源已接管"})
319
+ return violations
320
+
321
+
322
+ def _merge_design_rules(design_paths: Sequence[Path]) -> list[dict[str, object]]:
323
+ merged: dict[str, dict[str, object]] = {}
324
+ for path in design_paths:
325
+ for rule in parse_design_rules(str(path)):
326
+ rule_id = str(rule.get("id"))
327
+ if rule_id in merged and merged[rule_id] != rule:
328
+ raise ValueError(f"duplicate design rule id with conflicting definition: {rule_id}")
329
+ merged[rule_id] = rule
330
+ return sorted(merged.values(), key=lambda item: str(item.get("id")))
331
+
332
+
333
+ def _merge_design_risks(design_paths: Sequence[Path]) -> list[dict[str, object]]:
334
+ merged: list[dict[str, object]] = []
335
+ seen: set[tuple[str, str]] = set()
336
+ for path in design_paths:
337
+ for risk in parse_design_risks(str(path)):
338
+ key = (str(path.resolve()), str(risk["id"]))
339
+ if key in seen:
340
+ continue
341
+ seen.add(key)
342
+ item = dict(risk)
343
+ item["source"] = path.name
344
+ item["source_ref"] = f"{path.resolve().as_posix()}#{risk['id']}"
345
+ merged.append(item)
346
+ return merged
347
+
348
+
349
+ def _audit_summary(trace: Mapping[str, object], manifest: Mapping[str, object],
350
+ rows: Sequence[Mapping[str, object]], rules: Sequence[Mapping[str, object]],
351
+ risks: Sequence[Mapping[str, object]], findings: Sequence[Mapping[str, object]]) -> dict[str, object]:
352
+ owners: dict[str, int] = {}
353
+ for row in rows:
354
+ owner = str(row.get("owner") or "")
355
+ if owner:
356
+ owners[owner] = owners.get(owner, 0) + 1
357
+ risk_levels: dict[str, int] = {}
358
+ for risk in risks:
359
+ level = str(risk.get("level") or "unknown")
360
+ risk_levels[level] = risk_levels.get(level, 0) + 1
361
+ summary = dict(trace.get("summary") or {})
362
+ summary.update({
363
+ "checks": len(manifest.get("checks", [])),
364
+ "verified": sum(1 for item in manifest.get("scenarios", []) if item.get("status") == "verified"),
365
+ "owners": owners,
366
+ "unique_owners": len(owners),
367
+ "rules": len(rules),
368
+ "risks": len(risks),
369
+ **risk_counts(manifest),
370
+ "risk_levels": risk_levels,
371
+ "findings": len(findings),
372
+ })
373
+ return summary
374
+
375
+
376
+ def _audit_trace(design_paths: Sequence[Path], manifest_path: Path,
377
+ manifest: Mapping[str, object], scenarios_file: str) -> dict[str, object]:
378
+ if scenarios_file:
379
+ trace = build_traceability_from_scenarios(scenarios_file, str(manifest_path))
380
+ extra_findings = _scenario_source_findings(scenarios_file, manifest_path, design_paths, manifest)
381
+ trace.setdefault("violations", []).extend(extra_findings)
382
+ return trace
383
+ if len(design_paths) > 1:
384
+ return build_traceability_many([str(path) for path in design_paths], str(manifest_path))
385
+ return build_traceability(str(design_paths[0]), str(manifest_path))
386
+
387
+
388
+ def build_audit(design_file: str, manifest_file: str, root: str,
389
+ requirement: str = "", scenarios_file: str = "",
390
+ design_files: Sequence[str] = ()) -> dict[str, object]:
391
+ """Build the read-only invariant report; multiple designs merge by global ID."""
392
+ design_paths = [Path(item) for item in design_files] or [Path(design_file)]
393
+ manifest_path = Path(manifest_file)
394
+ if any(not path.is_file() for path in design_paths):
395
+ raise ValueError(f"design_file_missing: {', '.join(str(path) for path in design_paths)}")
396
+ if not manifest_path.is_file():
397
+ raise ValueError(f"manifest_file_missing: {manifest_path}")
398
+ rules = _merge_design_rules(design_paths)
399
+ risks = _merge_design_risks(design_paths)
400
+ manifest = load_manifest(manifest_path)
401
+ trace = _audit_trace(design_paths, manifest_path, manifest, scenarios_file)
402
+ scenarios = {
403
+ str(item["id"]): item
404
+ for item in manifest.get("scenarios") or ()
405
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
406
+ }
407
+ from codeflow.evidence.cf_evidence_store import requirement_key
408
+ store = EvidenceStore(str(root), requirement or requirement_key(manifest_path.parent))
409
+ findings = [
410
+ *_trace_findings(trace),
411
+ *_owner_findings(scenarios),
412
+ *_verified_findings(scenarios, store),
413
+ ]
414
+ findings = _dedupe(findings)
415
+ rows = _scenario_rows(trace, scenarios)
416
+ summary = _audit_summary(trace, manifest, rows, rules, risks, findings)
417
+ return {
418
+ "decision": "block" if findings else "pass",
419
+ "read_only": True,
420
+ "generated": True,
421
+ "design_file": str(design_paths[0]),
422
+ "design_files": [str(path) for path in design_paths],
423
+ "source": trace.get("source", ""),
424
+ "deprecated": not bool(scenarios_file),
425
+ "manifest_file": str(manifest_path),
426
+ "evidence_dir": str(store.directory),
427
+ "summary": summary,
428
+ "rules": rules,
429
+ "risks": risks,
430
+ "scenarios": rows,
431
+ "findings": findings,
432
+ }
433
+
434
+
435
+ def _md(value: object) -> str:
436
+ text = "" if value is None else str(value)
437
+ if not text:
438
+ return "-"
439
+ return text.replace("|", "\\|").replace("\n", " ")
440
+
441
+
442
+ def render_markdown(report: Mapping[str, object]) -> str:
443
+ """Render a human report from build_audit output; no file is written."""
444
+ summary = report.get("summary") if isinstance(report.get("summary"), Mapping) else {}
445
+ lines = [
446
+ "# code-flow audit",
447
+ "",
448
+ f"> Read-only audit of `{_md(report.get('manifest_file'))}` against "
449
+ f"`{_md(report.get('design_file'))}`.",
450
+ "",
451
+ "| Metric | Value |",
452
+ "|--------|-------|",
453
+ f"| decision | {_md(report.get('decision'))} |",
454
+ f"| design scenarios | {_md(summary.get('design_scenarios'))} |",
455
+ f"| manifest scenarios | {_md(summary.get('manifest_scenarios'))} |",
456
+ f"| checks | {_md(summary.get('checks'))} |",
457
+ f"| bound / aspirational / unbound / missing | "
458
+ f"{_md(summary.get('bound'))} / {_md(summary.get('aspirational'))} / "
459
+ f"{_md(summary.get('unbound'))} / {_md(summary.get('missing'))} |",
460
+ f"| verified | {_md(summary.get('verified'))} |",
461
+ f"| rules | {_md(summary.get('rules'))} |",
462
+ f"| risks | {_md(summary.get('risks'))} |",
463
+ f"| accepted risks | {_md(summary.get('accepted_risks'))} |",
464
+ f"| findings | {_md(summary.get('findings'))} |",
465
+ ]
466
+ findings = report.get("findings") or ()
467
+ if findings:
468
+ lines.extend([
469
+ "",
470
+ "## Findings",
471
+ "",
472
+ "| Rule | Invariant | Scenario | Code | Message |",
473
+ "|------|-----------|----------|------|---------|",
474
+ ])
475
+ for item in findings:
476
+ if not isinstance(item, Mapping):
477
+ continue
478
+ lines.append(
479
+ f"| {_md(item.get('rule_ref'))} | {_md(item.get('invariant'))} | "
480
+ f"{_md(item.get('scenario_id'))} | {_md(item.get('code'))} | {_md(item.get('message'))} |"
481
+ )
482
+ else:
483
+ lines.extend(["", "No invariant findings."])
484
+ return "\n".join(lines) + "\n"
485
+
486
+
487
+ def _resolve_paths(root: str, task_dir: str, design: str,
488
+ manifest: str) -> tuple[Path, Path, Path, list[Path]]:
489
+ root_path = Path(root).expanduser().resolve()
490
+ directory = Path(task_dir).expanduser()
491
+ directory = directory.resolve() if directory.is_absolute() else (root_path / directory).resolve()
492
+ if design:
493
+ design_paths = [
494
+ Path(item).expanduser().resolve()
495
+ for item in design.split(",") if item.strip()
496
+ ]
497
+ else:
498
+ design_paths = sorted(directory.glob("*.design.md"))
499
+ if not design_paths:
500
+ raise ValueError(f"design_file_missing: {directory}")
501
+ manifest_path = Path(manifest).expanduser().resolve() if manifest else directory / ".acceptance-manifest.json"
502
+ return root_path, design_paths[0], manifest_path, design_paths
503
+
504
+
505
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
506
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or None)
507
+ parser.add_argument("--task-dir", required=True)
508
+ parser.add_argument("--root", default=os.getcwd())
509
+ parser.add_argument("--design", default="")
510
+ parser.add_argument("--manifest", default="")
511
+ parser.add_argument("--json", action="store_true")
512
+ args = parser.parse_args(argv)
513
+ try:
514
+ root, design, manifest, design_paths = _resolve_paths(
515
+ args.root, args.task_dir, args.design, args.manifest
516
+ )
517
+ resolved_dir = manifest.parent
518
+ sources = sorted(resolved_dir.glob("*.scenarios.md")) if resolved_dir.is_dir() else []
519
+ scenarios_file = str(sources[0]) if len(sources) == 1 else ""
520
+ if len(sources) > 1:
521
+ raise ValueError("scenario_source_ambiguous")
522
+ report = build_audit(
523
+ str(design), str(manifest), root=str(root), scenarios_file=scenarios_file,
524
+ design_files=[str(path) for path in design_paths],
525
+ )
526
+ except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
527
+ stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
528
+ return 2
529
+ if args.json:
530
+ stdout.write(json.dumps(report, ensure_ascii=False))
531
+ else:
532
+ stdout.write(render_markdown(report))
533
+ return 0 if report["decision"] == "pass" else 3