@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,607 @@
1
+ #!/usr/bin/env python3
2
+ """Import CI JUnit / Playwright reports as central acceptance evidence.
3
+
4
+ CI owns full E2E execution; the local workflow imports the report instead of
5
+ re-running it. Imports are fail-closed:
6
+
7
+ - ``--commit`` must resolve to the current ``HEAD``; a different commit means
8
+ the report does not describe this worktree and is rejected.
9
+ - The worktree must be clean (ignoring ``.code-flow/``): CI proves committed
10
+ content, not uncommitted local edits.
11
+ - ``run_url``, ``input_hash``, ``exit_code`` and ``source=ci`` are mandatory
12
+ provenance; ``input_hash`` binds evidence to the exact worktree content.
13
+ - Every test case must map to a manifest check/scenario. Unmapped cases are
14
+ rejected instead of being silently attributed or dropped.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ from dataclasses import dataclass
21
+ import hashlib
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from typing import IO, Mapping, Optional, Sequence
29
+ from urllib.parse import urlparse
30
+ import xml.etree.ElementTree as ET
31
+
32
+ from codeflow.acceptance.cf_acceptance_evidence import persist_results
33
+ from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
34
+ from codeflow.acceptance.cf_check_inputs import check_input_hashes
35
+ from codeflow.evidence.cf_evidence_store import EvidenceStoreError, discover_project_root
36
+ from codeflow.core.cf_exec_base import execution_environment_hash, worktree_fingerprint
37
+ from codeflow.core.cf_worktree_env import check_environment
38
+
39
+ MANIFEST_NAME = ".acceptance-manifest.json"
40
+ FORMATS = ("junit", "playwright")
41
+ _PASSED, _FAILED, _SKIPPED = "passed", "failed", "skipped"
42
+ _FAILED_RESULTS = ("failed", "timedOut", "interrupted")
43
+
44
+
45
+ class EvidenceImportError(ValueError):
46
+ """Stable rejection for an import that cannot prove its claims."""
47
+
48
+ def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.field = field
52
+ self.message = message
53
+ self.path = path
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ReportCase:
58
+ """One executable test case from a CI report."""
59
+
60
+ name: str
61
+ classname: str
62
+ file: str
63
+ line: Optional[int]
64
+ status: str
65
+ message: str = ""
66
+
67
+
68
+ def _local(tag: str) -> str:
69
+ return tag.rsplit("}", 1)[-1]
70
+
71
+
72
+ def _text(value: object) -> str:
73
+ return value.strip() if isinstance(value, str) else ""
74
+
75
+
76
+ def _line(value: object) -> Optional[int]:
77
+ text = _text(value)
78
+ return int(text) if text.isdigit() else None
79
+
80
+
81
+ def _junit_case(node: ET.Element) -> ReportCase:
82
+ tags = {_local(child.tag) for child in node}
83
+ if tags & {"failure", "error"}:
84
+ status = _FAILED
85
+ elif "skipped" in tags:
86
+ status = _SKIPPED
87
+ else:
88
+ status = _PASSED
89
+ detail = next((child for child in node if _local(child.tag) in {"failure", "error"}), None)
90
+ message = _text(detail.get("message")) if detail is not None else ""
91
+ return ReportCase(
92
+ name=_text(node.get("name")),
93
+ classname=_text(node.get("classname")),
94
+ file=_text(node.get("file")),
95
+ line=_line(node.get("line")),
96
+ status=status,
97
+ message=message,
98
+ )
99
+
100
+
101
+ def _parse_junit(path: Path) -> list[ReportCase]:
102
+ try:
103
+ root = ET.parse(str(path)).getroot()
104
+ except (OSError, UnicodeError, ET.ParseError) as exc:
105
+ raise EvidenceImportError("invalid_report", "file", f"invalid JUnit XML: {exc}", str(path)) from exc
106
+ cases = [_junit_case(node) for node in root.iter() if _local(node.tag) == "testcase"]
107
+ if not cases:
108
+ raise EvidenceImportError("empty_report", "file", "JUnit report has no testcase", str(path))
109
+ return cases
110
+
111
+
112
+ def _playwright_status(test: Mapping[str, object]) -> str:
113
+ results = test.get("results")
114
+ last = ""
115
+ if isinstance(results, list) and results and isinstance(results[-1], Mapping):
116
+ last = _text(results[-1].get("status"))
117
+ status = _text(test.get("status"))
118
+ if last in _FAILED_RESULTS or status == "unexpected":
119
+ return _FAILED
120
+ if status == "skipped" or last == "skipped":
121
+ return _SKIPPED
122
+ return _PASSED if last == "passed" else _SKIPPED
123
+
124
+
125
+ def _playwright_spec_status(tests: Sequence[object]) -> str:
126
+ statuses = [_playwright_status(test) for test in tests if isinstance(test, Mapping)]
127
+ if _FAILED in statuses:
128
+ return _FAILED
129
+ if statuses and all(status == _PASSED for status in statuses):
130
+ return _PASSED
131
+ return _SKIPPED
132
+
133
+
134
+ def _walk_playwright_suite(suite: Mapping[str, object], parent_file: str, cases: list[ReportCase]) -> None:
135
+ file = _text(suite.get("file")) or parent_file
136
+ specs = suite.get("specs")
137
+ for spec in specs if isinstance(specs, list) else []:
138
+ if not isinstance(spec, Mapping):
139
+ continue
140
+ tests = spec.get("tests")
141
+ cases.append(
142
+ ReportCase(
143
+ name=_text(spec.get("title")),
144
+ classname=_text(suite.get("title")),
145
+ file=_text(spec.get("file")) or file,
146
+ line=_line(spec.get("line")),
147
+ status=_playwright_spec_status(tests if isinstance(tests, list) else []),
148
+ )
149
+ )
150
+ children = suite.get("suites")
151
+ for child in children if isinstance(children, list) else []:
152
+ if isinstance(child, Mapping):
153
+ _walk_playwright_suite(child, file, cases)
154
+
155
+
156
+ def _parse_playwright(path: Path) -> list[ReportCase]:
157
+ try:
158
+ loaded = json.loads(path.read_text(encoding="utf-8"))
159
+ except (OSError, UnicodeError, ValueError) as exc:
160
+ raise EvidenceImportError("invalid_report", "file", f"invalid Playwright JSON: {exc}", str(path)) from exc
161
+ suites = loaded.get("suites") if isinstance(loaded, Mapping) else None
162
+ if not isinstance(suites, list):
163
+ raise EvidenceImportError("invalid_report", "file", "Playwright report needs a suites list", str(path))
164
+ cases: list[ReportCase] = []
165
+ for suite in suites:
166
+ if isinstance(suite, Mapping):
167
+ _walk_playwright_suite(suite, "", cases)
168
+ if not cases:
169
+ raise EvidenceImportError("empty_report", "file", "Playwright report has no spec", str(path))
170
+ return cases
171
+
172
+
173
+ def load_report(path: object, fmt: str) -> list[ReportCase]:
174
+ report = Path(path)
175
+ if fmt == "junit":
176
+ return _parse_junit(report)
177
+ if fmt == "playwright":
178
+ return _parse_playwright(report)
179
+ raise EvidenceImportError("unsupported_format", "format", f"unsupported report format: {fmt}")
180
+
181
+
182
+ def _resolve_manifest(root: Path, manifest: str, task_dir: str) -> Path:
183
+ if manifest:
184
+ path = Path(manifest).expanduser()
185
+ return path if path.is_absolute() else root / path
186
+ if task_dir:
187
+ path = Path(task_dir).expanduser()
188
+ base = path if path.is_absolute() else root / path
189
+ return base / MANIFEST_NAME
190
+ marker = root / ".code-flow" / ".active-task.json"
191
+ try:
192
+ loaded = json.loads(marker.read_text(encoding="utf-8"))
193
+ except (OSError, UnicodeError, ValueError) as exc:
194
+ raise EvidenceImportError(
195
+ "manifest_unresolved", "manifest", "pass --manifest/--task-dir or start a task first", str(marker)
196
+ ) from exc
197
+ task = loaded.get("task_dir") if isinstance(loaded, Mapping) else None
198
+ if not isinstance(task, str) or not task.strip():
199
+ raise EvidenceImportError("manifest_unresolved", "manifest", "active task has no task_dir", str(marker))
200
+ base = Path(task)
201
+ base = base if base.is_absolute() else root / base
202
+ return base / MANIFEST_NAME
203
+
204
+
205
+ def _load_plan(path: Path) -> tuple[dict[str, object], dict[str, object], dict[str, str], list[tuple[str, str]]]:
206
+ try:
207
+ data = load_manifest(path)
208
+ except (OSError, ValueError) as exc:
209
+ raise EvidenceImportError("manifest_invalid", "manifest", str(exc), str(path)) from exc
210
+ checks = {
211
+ str(item["id"]): item
212
+ for item in data.get("checks", [])
213
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
214
+ }
215
+ scenarios = {
216
+ str(item["id"]): item
217
+ for item in data.get("scenarios", [])
218
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
219
+ }
220
+ linked: dict[str, str] = {}
221
+ for scenario_id, item in scenarios.items():
222
+ ids = scenario_check_ids(item)
223
+ if len(ids) == 1 and ids[0] in checks:
224
+ linked[scenario_id] = ids[0]
225
+ if not checks or not any(scenario_check_ids(row) for row in scenarios.values()):
226
+ raise EvidenceImportError(
227
+ "manifest_schema_unsupported", "manifest", "CI import needs manifest v3 checks linked to scenarios", str(path)
228
+ )
229
+ assertions: list[tuple[str, str]] = []
230
+ for scenario_id, item in scenarios.items():
231
+ raw = item.get("assertions")
232
+ for value in raw if isinstance(raw, list) else []:
233
+ if isinstance(value, str) and value.strip():
234
+ assertions.append((scenario_id, value.strip()))
235
+ return data, checks, linked, assertions
236
+
237
+
238
+ def _has_token(text: str, token: str) -> bool:
239
+ return re.search(rf"(?<![0-9A-Za-z_-]){re.escape(token)}(?![0-9A-Za-z_-])", text) is not None
240
+
241
+
242
+ def _normalize_path(value: str) -> str:
243
+ text = value.replace("\\", "/").strip()
244
+ while text.startswith("./"):
245
+ text = text[2:]
246
+ return text.strip("/")
247
+
248
+
249
+ def _assertion_matches(assertion: str, case: ReportCase) -> bool:
250
+ if not case.file:
251
+ return False
252
+ target, line = assertion, None
253
+ if "::" in target:
254
+ target = target.split("::", 1)[0]
255
+ head, separator, tail = target.rpartition(":")
256
+ if separator and tail.isdigit():
257
+ target, line = head, int(tail)
258
+ expected, actual = _normalize_path(target), _normalize_path(case.file)
259
+ if not expected or not actual:
260
+ return False
261
+ if not (expected == actual or actual.endswith("/" + expected) or expected.endswith("/" + actual)):
262
+ return False
263
+ return line is None or case.line is None or line == case.line
264
+
265
+
266
+ def _match_case(
267
+ case: ReportCase, override: str, checks: Mapping[str, object], linked: Mapping[str, str],
268
+ assertions: Sequence[tuple[str, str]],
269
+ ) -> str:
270
+ if override:
271
+ return override
272
+ text = " ".join(part for part in (case.classname, case.name, case.file) if part)
273
+ candidates = [candidate for candidate in checks if _has_token(text, candidate)]
274
+ if len(candidates) > 1:
275
+ raise EvidenceImportError("ambiguous_test_case", "file",
276
+ f"test case must identify one check: {_case_label(case)}")
277
+ if candidates:
278
+ return candidates[0]
279
+ scenario_id = next((candidate for candidate in linked if _has_token(text, candidate)), "")
280
+ if scenario_id:
281
+ return linked[scenario_id]
282
+ for candidate, assertion in assertions:
283
+ if candidate in linked and _assertion_matches(assertion, case):
284
+ return linked[candidate]
285
+ return ""
286
+
287
+
288
+ def _case_label(case: ReportCase) -> str:
289
+ name = case.name or "<unnamed>"
290
+ return f"{case.classname}::{name}" if case.classname else name
291
+
292
+
293
+ def _map_cases(
294
+ cases: Sequence[ReportCase], override: str, checks: Mapping[str, object],
295
+ linked: Mapping[str, str], assertions: Sequence[tuple[str, str]],
296
+ ) -> dict[str, list[str]]:
297
+ mapped: dict[str, list[str]] = {}
298
+ unmapped: list[str] = []
299
+ for case in cases:
300
+ check_id = _match_case(case, override, checks, linked, assertions)
301
+ if not check_id:
302
+ unmapped.append(_case_label(case))
303
+ continue
304
+ mapped.setdefault(check_id, []).append(case.status)
305
+ if unmapped:
306
+ raise EvidenceImportError(
307
+ "unmapped_test_cases", "file", "unmapped test cases: " + ", ".join(unmapped[:10])
308
+ )
309
+ if not mapped:
310
+ raise EvidenceImportError("no_matching_tests", "file", "report does not cover any manifest check")
311
+ return mapped
312
+
313
+
314
+ def _check_outcomes(mapped: Mapping[str, Sequence[str]]) -> dict[str, tuple[str, int]]:
315
+ outcomes: dict[str, tuple[str, int]] = {}
316
+ for check_id, statuses in mapped.items():
317
+ if _FAILED in statuses:
318
+ outcomes[check_id] = ("failed", 1)
319
+ elif not statuses or any(status != _PASSED for status in statuses):
320
+ outcomes[check_id] = ("incomplete", 1)
321
+ else:
322
+ outcomes[check_id] = ("passed", 0)
323
+ return outcomes
324
+
325
+
326
+ def _git(root: Path, *arguments: str) -> str:
327
+ try:
328
+ completed = subprocess.run(
329
+ ("git", *arguments), cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False
330
+ )
331
+ except (OSError, UnicodeError):
332
+ return ""
333
+ return completed.stdout.strip() if completed.returncode == 0 else ""
334
+
335
+
336
+ def _resolve_commit(root: Path, value: str) -> str:
337
+ text = _text(value)
338
+ if not text:
339
+ raise EvidenceImportError("missing_commit", "commit", "CI evidence requires --commit")
340
+ resolved = _git(root, "rev-parse", "--verify", "--quiet", f"{text}^{{commit}}")
341
+ if not resolved:
342
+ raise EvidenceImportError("commit_invalid", "commit", f"unknown commit: {text}")
343
+ head = _git(root, "rev-parse", "HEAD")
344
+ if not head:
345
+ raise EvidenceImportError("git_unavailable", "commit", "cannot read HEAD; CI import needs a git worktree")
346
+ if resolved != head:
347
+ raise EvidenceImportError("commit_mismatch", "commit", f"CI commit {resolved} != HEAD {head}")
348
+ return head
349
+
350
+
351
+ def _changed_paths(root: Path) -> list[str]:
352
+ try:
353
+ completed = subprocess.run(
354
+ ("git", "status", "--porcelain", "--untracked-files=all"),
355
+ cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
356
+ )
357
+ except (OSError, UnicodeError) as exc:
358
+ raise EvidenceImportError("git_unavailable", "worktree", str(exc)) from exc
359
+ if completed.returncode != 0:
360
+ raise EvidenceImportError("git_unavailable", "worktree", completed.stderr.strip())
361
+ paths: list[str] = []
362
+ for line in completed.stdout.splitlines():
363
+ path = line[3:].strip()
364
+ if " -> " in path:
365
+ path = path.split(" -> ", 1)[1].strip()
366
+ if not path or path.startswith(".code-flow/"):
367
+ continue
368
+ paths.append(path)
369
+ return paths
370
+
371
+
372
+ def _require_clean(root: Path) -> None:
373
+ paths = _changed_paths(root)
374
+ if paths:
375
+ raise EvidenceImportError(
376
+ "worktree_dirty", "worktree", "CI evidence requires a clean worktree: " + ", ".join(paths[:5])
377
+ )
378
+
379
+
380
+ def _require_run_url(value: str) -> str:
381
+ text = _text(value)
382
+ if not text:
383
+ raise EvidenceImportError("missing_run_url", "run_url", "CI evidence requires --run-url")
384
+ parsed = urlparse(text)
385
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
386
+ raise EvidenceImportError("invalid_run_url", "run_url", f"not an http(s) run URL: {text}")
387
+ return text
388
+
389
+
390
+ def _report_digest(path: Path) -> str:
391
+ try:
392
+ return hashlib.sha256(path.read_bytes()).hexdigest()
393
+ except (OSError, UnicodeError) as exc:
394
+ raise EvidenceImportError("invalid_report", "file", str(exc), str(path)) from exc
395
+
396
+
397
+ def _scenario_results(
398
+ data: Mapping[str, object], outcomes: Mapping[str, tuple[str, int]], *,
399
+ commit: str, run_url: str, fmt: str, report_sha256: str,
400
+ ) -> list[dict[str, object]]:
401
+ results: list[dict[str, object]] = []
402
+ for row in data.get("scenarios", []):
403
+ if not isinstance(row, Mapping) or str(row.get("kind", "functional")) == "manual":
404
+ continue
405
+ check_ids = scenario_check_ids(row)
406
+ if not check_ids or any(check_id not in outcomes for check_id in check_ids):
407
+ continue
408
+ statuses = [outcomes[check_id][0] for check_id in check_ids]
409
+ if any(status == "failed" for status in statuses):
410
+ status = "failed"
411
+ elif any(status != "passed" for status in statuses):
412
+ status = "incomplete"
413
+ else:
414
+ status = "passed"
415
+ exit_code = max(outcomes[check_id][1] for check_id in check_ids)
416
+ results.append({
417
+ "id": str(row["id"]),
418
+ "kind": str(row.get("kind", "functional")),
419
+ "status": status,
420
+ "exit_code": exit_code,
421
+ "check_ids": check_ids,
422
+ "check_results": {check_id: {"status": outcomes[check_id][0],
423
+ "exit_code": outcomes[check_id][1]}
424
+ for check_id in check_ids},
425
+ "source": "ci",
426
+ "commit": commit,
427
+ "run_url": run_url,
428
+ "ci_format": fmt,
429
+ "ci_report_sha256": report_sha256,
430
+ })
431
+ return results
432
+
433
+
434
+ def _already_imported(data: Mapping[str, object], results: Sequence[Mapping[str, object]], run_id: str) -> bool:
435
+ rows = {
436
+ str(row["id"]): row
437
+ for row in data.get("scenarios", [])
438
+ if isinstance(row, Mapping) and isinstance(row.get("id"), str)
439
+ }
440
+ for result in results:
441
+ evidence = rows.get(str(result["id"]), {}).get("evidence")
442
+ if not isinstance(evidence, Mapping):
443
+ return False
444
+ if evidence.get("run_id") != run_id or evidence.get("source") != "ci":
445
+ return False
446
+ return True
447
+
448
+
449
+ def _checks_payload(mapped: Mapping[str, Sequence[str]], outcomes: Mapping[str, tuple[str, int]]) -> list[dict[str, object]]:
450
+ return [
451
+ {"check_id": check_id, "status": outcomes[check_id][0],
452
+ "exit_code": outcomes[check_id][1], "cases": len(mapped[check_id])}
453
+ for check_id in sorted(outcomes)
454
+ ]
455
+
456
+
457
+ def _payload(
458
+ status: str, decision: str, results: Sequence[Mapping[str, object]], mapped: Mapping[str, Sequence[str]],
459
+ outcomes: Mapping[str, tuple[str, int]], *, fmt: str, head: str, run_url: str,
460
+ fingerprint: str, run_id: str, report_sha256: str,
461
+ ) -> dict[str, object]:
462
+ return {
463
+ "status": status,
464
+ "decision": decision,
465
+ "source": "ci",
466
+ "format": fmt,
467
+ "commit": head,
468
+ "run_url": run_url,
469
+ "input_hash": fingerprint,
470
+ "run_id": run_id,
471
+ "imported": len(results),
472
+ "checks": _checks_payload(mapped, outcomes),
473
+ "scenarios": sorted(str(result["id"]) for result in results),
474
+ "report_sha256": report_sha256,
475
+ }
476
+
477
+
478
+ def _attach_check_hashes(
479
+ data: Mapping[str, object], results: Sequence[dict[str, object]],
480
+ per_check: Mapping[str, Mapping[str, object]], fallback: str,
481
+ ) -> None:
482
+ rows = {
483
+ str(item.get("id")): item for item in data.get("scenarios", [])
484
+ if isinstance(item, Mapping) and item.get("id")
485
+ }
486
+ for result in results:
487
+ row = rows.get(str(result.get("id")))
488
+ if not row:
489
+ continue
490
+ raw = result.get("check_ids")
491
+ check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
492
+ if not check_ids:
493
+ raw = row.get("checks")
494
+ check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
495
+ if not check_ids and isinstance(row.get("check"), str):
496
+ check_ids = [str(row.get("check"))]
497
+ if not check_ids:
498
+ result["input_hash"] = fallback
499
+ continue
500
+ hashes = {
501
+ check_id: str(per_check.get(check_id, {}).get("input_hash") or fallback)
502
+ for check_id in check_ids
503
+ }
504
+ result["check_ids"] = check_ids
505
+ result["check_hashes"] = hashes
506
+ if len(hashes) == 1:
507
+ result["input_hash"] = next(iter(hashes.values()))
508
+ else:
509
+ payload = "|".join(f"{key}={hashes[key]}" for key in sorted(hashes))
510
+ result["input_hash"] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
511
+
512
+
513
+ def import_report(
514
+ root: object, *, report: object, fmt: str, commit: str, run_url: str,
515
+ manifest: str = "", task_dir: str = "", check: str = "", exit_code: Optional[int] = None,
516
+ ) -> dict[str, object]:
517
+ """Import one CI report; returns the imported evidence summary."""
518
+ project = discover_project_root(root)
519
+ report_path = Path(report)
520
+ plan_path = _resolve_manifest(project, manifest, task_dir)
521
+ if not plan_path.is_file():
522
+ raise EvidenceImportError("manifest_missing", "manifest", "acceptance manifest not found", str(plan_path))
523
+ data, checks, linked, assertions = _load_plan(plan_path)
524
+ url = _require_run_url(run_url)
525
+ if check and check not in checks:
526
+ raise EvidenceImportError("unknown_check", "check", f"check not in manifest: {check}")
527
+ if check and not any(check in scenario_check_ids(row) for row in data["scenarios"]):
528
+ raise EvidenceImportError("check_without_scenarios", "check", f"check has no scenario: {check}")
529
+ cases = load_report(report_path, fmt)
530
+ mapped = _map_cases(cases, check, checks, linked, assertions)
531
+ head = _resolve_commit(project, commit)
532
+ outcomes = _check_outcomes(mapped)
533
+ derived_exit = 0 if all(status == "passed" for status, _ in outcomes.values()) else 1
534
+ if exit_code is not None and exit_code != derived_exit:
535
+ raise EvidenceImportError(
536
+ "exit_code_mismatch", "exit_code", f"CI exit code {exit_code} != report exit code {derived_exit}"
537
+ )
538
+ _require_clean(project)
539
+ fingerprint = worktree_fingerprint(str(project))
540
+ if not fingerprint:
541
+ raise EvidenceImportError("input_hash_unavailable", "input_hash", "cannot fingerprint the worktree")
542
+ check_hashes = check_input_hashes(str(project), checks, fingerprint)
543
+ digest = _report_digest(report_path)
544
+ run_id = hashlib.sha256(
545
+ json.dumps([head, fmt, digest, url], ensure_ascii=False, sort_keys=True).encode("utf-8")
546
+ ).hexdigest()[:32]
547
+ results = _scenario_results(
548
+ data, outcomes, commit=head, run_url=url, fmt=fmt, report_sha256=digest
549
+ )
550
+ for result in results:
551
+ for check_id, outcome in result.get("check_results", {}).items():
552
+ check = checks[check_id]
553
+ outcome["environment_hash"] = execution_environment_hash(check_environment(check, str(project)))
554
+ if len(result.get("check_results", {})) == 1:
555
+ result["environment_hash"] = next(iter(result["check_results"].values()))["environment_hash"]
556
+ _attach_check_hashes(data, results, check_hashes, fingerprint)
557
+ if not results:
558
+ raise EvidenceImportError("check_without_scenarios", "check", "report checks have no scenario")
559
+ decision = "pass" if derived_exit == 0 else "block"
560
+ if _already_imported(data, results, run_id):
561
+ return _payload("unchanged", decision, results, mapped, outcomes, fmt=fmt, head=head,
562
+ run_url=url, fingerprint=fingerprint, run_id=run_id, report_sha256=digest)
563
+ persist_results(plan_path, data, results, fingerprint=fingerprint, run_id=run_id)
564
+ return _payload("imported", decision, results, mapped, outcomes, fmt=fmt, head=head,
565
+ run_url=url, fingerprint=fingerprint, run_id=run_id, report_sha256=digest)
566
+
567
+
568
+ def _rejection(code: str, field: str, message: str, path: str = "") -> dict[str, object]:
569
+ return {"status": "rejected", "decision": "block", "error_code": code,
570
+ "field": field, "error": message, "message": message, "path": path}
571
+
572
+
573
+ def _parser() -> argparse.ArgumentParser:
574
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or "code-flow evidence")
575
+ actions = parser.add_subparsers(dest="action", required=True)
576
+ importer = actions.add_parser("import", help="Import a CI JUnit/Playwright report as evidence")
577
+ importer.add_argument("--format", required=True, choices=FORMATS)
578
+ importer.add_argument("--file", required=True)
579
+ importer.add_argument("--commit", required=True)
580
+ importer.add_argument("--run-url", required=True)
581
+ importer.add_argument("--root", default=".")
582
+ importer.add_argument("--manifest", default="")
583
+ importer.add_argument("--task-dir", default="")
584
+ importer.add_argument("--check", default="", help="Report belongs to one manifest check")
585
+ importer.add_argument("--exit-code", type=int, default=None, help="Cross-check the CI job exit code")
586
+ importer.add_argument("--json", action="store_true")
587
+ return parser
588
+
589
+
590
+ def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
591
+ args = _parser().parse_args(argv)
592
+ try:
593
+ result = import_report(
594
+ args.root, report=args.file, fmt=args.format, commit=args.commit, run_url=args.run_url,
595
+ manifest=args.manifest, task_dir=args.task_dir, check=args.check, exit_code=args.exit_code,
596
+ )
597
+ except EvidenceImportError as exc:
598
+ stdout.write(json.dumps(_rejection(exc.code, exc.field, exc.message, exc.path), ensure_ascii=False))
599
+ return 2
600
+ except EvidenceStoreError as exc:
601
+ stdout.write(json.dumps(_rejection(exc.code, exc.field, exc.message, exc.path), ensure_ascii=False))
602
+ return 2
603
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
604
+ stdout.write(json.dumps(_rejection("import_failed", "file", str(exc)), ensure_ascii=False))
605
+ return 2
606
+ stdout.write(json.dumps(result, ensure_ascii=False))
607
+ return 0
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """cf_feedback: mark a check as false positive (FEAT-02 对话即反馈).
3
+
4
+ The violation feedback text tells the user "误报可直接告诉我"; the agent then
5
+ runs this CLI on the user's behalf — no new slash command, no config editing.
6
+
7
+ Usage: python3 cf_feedback.py ignore <check-id>
8
+ Exit codes: 0 = recorded / 1 = usage error / 2 = unknown check id.
9
+ stdout: one JSON line {"check_id", "fp_count", "hit_count", "disabled"}.
10
+ """
11
+ import json
12
+ import argparse
13
+ import os
14
+ import sys
15
+
16
+ from codeflow.acceptance.cf_checks import load_check_state, load_spec_checks, record_false_positive
17
+ from codeflow.core.cf_core import build_effective_mapping, load_config, normalize_spec_entry
18
+ from codeflow.core.cf_log import append_event
19
+
20
+
21
+ def known_check_ids(project_root: str) -> set[str]:
22
+ """All check ids: declared in any configured spec, or already in state."""
23
+ ids = set(load_check_state(project_root).keys())
24
+ config = load_config(project_root)
25
+ mapping = build_effective_mapping(
26
+ project_root, (config or {}).get("path_mapping") or {}
27
+ )
28
+ specs_root = os.path.join(project_root, ".code-flow", "specs")
29
+ for domain_cfg in mapping.values():
30
+ for entry in (domain_cfg or {}).get("specs") or []:
31
+ rel = normalize_spec_entry(entry).get("path")
32
+ if not rel:
33
+ continue
34
+ checks, _ = load_spec_checks(os.path.join(specs_root, rel))
35
+ ids.update(check["id"] for check in checks)
36
+ return ids
37
+
38
+
39
+ def main(argv: list[str]) -> int:
40
+ parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_feedback.py"))
41
+ parser.add_argument("action", choices=("ignore",), metavar=os.environ.get("CF_RUNTIME_ACTION") or None)
42
+ parser.add_argument("check_id")
43
+ check_id = parser.parse_args(argv).check_id
44
+ project_root = os.getcwd()
45
+ if check_id not in known_check_ids(project_root):
46
+ sys.stderr.write(f"unknown check id: {check_id}\n")
47
+ return 2
48
+ append_event(
49
+ project_root, "false_positive", {"check_id": check_id}, "cf-feedback"
50
+ )
51
+ result = record_false_positive(project_root, check_id)
52
+ sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
53
+ return 0