@michaelxwb/code-flow 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,158 +0,0 @@
1
- version: 1
2
-
3
- spec_workflow:
4
- schema_version: 1
5
- enforcement: required # required=全门禁 | warn=门禁仅记录不阻断 | inject=只注入不门禁
6
- drift_check: true
7
- scope_expansion_check: true
8
- metrics: true
9
- catalog:
10
- dedup_window: 5
11
-
12
- budget:
13
- total: 2500
14
- l0_max: 800
15
- l1_max: 1700
16
- map_max: 400
17
- # catalog 模式下 spec 目录的 token 上限(超出按 Tier1 优先截断并告警)
18
- catalog_max: 200
19
-
20
- # 质量闭环(v0.5):合规反馈 + 学习采集。
21
- # enabled 仅 literal true 启用(缺失即关闭,升级用户行为不变);
22
- # 子开关缺省跟随 enabled,仅 literal false 单独关闭。
23
- # 数据仅存本地 .code-flow/(.session-log.jsonl / .check-state.json)。
24
- quality_loop:
25
- enabled: true
26
- post_check: true # PostToolUse 违规反馈(只提示不阻断)
27
- stop_check: true # Stop 收尾按 validation.yml 校验(heavy 项跳过)
28
- finish_check: true # 任务 finish 时全量执行 validation.yml(含 heavy,每任务一次)
29
- correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
30
- # 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
31
- # 缺省视为 true;仅显式 false 关闭
32
- compress: true
33
- compress_reminder: true # 会话过长每 N 轮提醒 /compact(per-session 计数,缺省 true)
34
- code_extensions:
35
- - ".py"
36
- - ".ts"
37
- - ".tsx"
38
- - ".js"
39
- - ".jsx"
40
- - ".go"
41
- - ".java"
42
- - ".rs"
43
- - ".rb"
44
- - ".vue"
45
- - ".svelte"
46
- skip_extensions:
47
- - ".md"
48
- - ".txt"
49
- - ".json"
50
- - ".yml"
51
- - ".yaml"
52
- - ".toml"
53
- - ".lock"
54
- - ".csv"
55
- - ".xml"
56
- - ".svg"
57
- - ".png"
58
- - ".jpg"
59
- - ".jpeg"
60
- - ".gif"
61
- - ".ico"
62
- - ".pdf"
63
- - ".zip"
64
- - ".gz"
65
- - ".tar"
66
- skip_paths:
67
- - "docs/**"
68
- - "*.config.*"
69
- - ".code-flow/**"
70
- - ".claude/**"
71
- - ".codex/**"
72
- - ".costrict/**"
73
- - ".opencode/**"
74
- - "node_modules/**"
75
- - "dist/**"
76
- - "build/**"
77
- - "out/**"
78
- - "coverage/**"
79
- - ".next/**"
80
- - ".cache/**"
81
- - ".venv/**"
82
- - "venv/**"
83
- - "__pycache__/**"
84
- - ".git/**"
85
-
86
- path_mapping:
87
- shared:
88
- # 共享模板供 cf-task:align / cf-task:prd 命令显式 Read 使用,
89
- # 不是约束规范,禁止通配符自动注入。
90
- # 仅 _map.md 保持 "*" 作为 Tier 0 导航。
91
- specs:
92
- - path: "shared/_map.md"
93
- tags: ["*"]
94
- tier: 0
95
- - path: "shared/prd-template.md"
96
- tags: [] # 永不自动注入,仅 cf-task:prd 命令显式 Read
97
- tier: 1
98
- - path: "shared/design/design-lite.md"
99
- tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
100
- tier: 1
101
- - path: "shared/design/design-full.md"
102
- tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
103
- tier: 1
104
- - path: "shared/design/design-frontend.md"
105
- tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read(前端设计模板)
106
- tier: 1
107
- frontend:
108
- patterns:
109
- - "src/components/**"
110
- - "src/pages/**"
111
- - "src/hooks/**"
112
- - "src/composables/**"
113
- - "src/services/**"
114
- - "src/styles/**"
115
- - "**/*.tsx"
116
- - "**/*.jsx"
117
- - "**/*.css"
118
- - "**/*.scss"
119
- specs:
120
- - path: "frontend/_map.md"
121
- tags: ["*"]
122
- tier: 0
123
- - path: "frontend/directory-structure.md"
124
- tags: ["directory", "structure", "folder", "route", "page", "layout"]
125
- tier: 1
126
- - path: "frontend/quality-standards.md"
127
- tags: ["quality", "type", "lint", "error", "test", "state", "strict"]
128
- tier: 1
129
- - path: "frontend/component-specs.md"
130
- tags: ["component", "prop", "hook", "render", "ui", "style"]
131
- tier: 1
132
- backend:
133
- patterns:
134
- - "services/**"
135
- - "api/**"
136
- - "models/**"
137
- - "*.py"
138
- - "**/*.py"
139
- - "**/*.go"
140
- specs:
141
- - path: "backend/_map.md"
142
- tags: ["*"]
143
- tier: 0
144
- - path: "backend/directory-structure.md"
145
- tags: ["directory", "structure", "folder", "module", "layout"]
146
- tier: 1
147
- - path: "backend/logging.md"
148
- tags: ["log", "logging", "debug", "trace", "monitor", "observe"]
149
- tier: 1
150
- - path: "backend/database.md"
151
- tags: ["database", "db", "sql", "orm", "model", "migration", "query", "table", "schema"]
152
- tier: 1
153
- - path: "backend/platform-rules.md"
154
- tags: ["api", "deploy", "config", "version", "compatibility", "release", "flag"]
155
- tier: 1
156
- - path: "backend/code-quality-performance.md"
157
- tags: ["quality", "performance", "error", "exception", "test", "timeout", "retry", "cache"]
158
- tier: 1
@@ -1,100 +0,0 @@
1
- """Append execution history without erasing the user's acceptance contract."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import re
6
- from datetime import datetime, timezone
7
- from pathlib import Path
8
- from typing import Mapping, Sequence
9
- from uuid import uuid4
10
-
11
- STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "failed", "incomplete", "not_configured", "e2e_deferred", "manual_pending"))
12
-
13
-
14
- def scenario_line(line: str, scenario_id: str) -> bool:
15
- return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
16
-
17
-
18
- def line_status(line: str, scenario_id: str) -> str:
19
- if not scenario_line(line, scenario_id):
20
- return ""
21
- if line.lstrip().startswith("|"):
22
- cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
23
- return next((cell for cell in reversed(cells) if cell in STATES), "")
24
- match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
25
- return match.group(1).lower() if match and match.group(1).lower() in STATES else ""
26
-
27
-
28
- def _status_line(line: str, scenario_id: str, status: str) -> str:
29
- if not scenario_line(line, scenario_id):
30
- return line
31
- if line.lstrip().startswith("|"):
32
- cells = line.split("|")
33
- for index in range(len(cells) - 1, 0, -1):
34
- if cells[index].strip().lower() in STATES:
35
- cells[index] = f" {status} "
36
- return "|".join(cells)
37
- return line.rstrip().rstrip("|") + f"| {status} |"
38
- prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
39
- if line_status(line, scenario_id):
40
- return re.sub(prefix + r"(?:" + "|".join(sorted(STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
41
- return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
42
-
43
-
44
- def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
45
- contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
46
- if contract:
47
- body = "\n".join(_status_line(line, scenario_id, status) for line in contract.group(1).splitlines()) + "\n"
48
- section = section[:contract.start(1)] + body + section[contract.end(1):]
49
- evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
50
- if evidence:
51
- body = evidence.group(1)
52
- if record not in body.splitlines():
53
- body = body.rstrip() + "\n" + record + "\n\n"
54
- section = section[:evidence.start(1)] + body + section[evidence.end(1):]
55
- else:
56
- section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
57
- return section
58
-
59
-
60
- def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> None:
61
- text = task.read_text(encoding="utf-8")
62
- task_pattern = re.escape(owner) if owner else r"TASK-\d+"
63
- match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
64
- if match is None:
65
- raise ValueError(f"acceptance owner missing: {owner}")
66
- record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
67
- section = _patch_section(match.group(0), scenario_id, status, record)
68
- text = text[:match.start()] + section + text[match.end():]
69
- coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
70
- if coverage:
71
- body = "\n".join(_status_line(line, scenario_id, status) for line in coverage.group(1).splitlines()) + "\n"
72
- text = text[:coverage.start(1)] + body + text[coverage.end(1):]
73
- from cf_spec_context import _atomic_text
74
- _atomic_text(task, text)
75
-
76
-
77
- def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]]) -> None:
78
- rows = {row["id"]: row for row in data["scenarios"]}
79
- run_id, executed_at = uuid4().hex, datetime.now(timezone.utc).isoformat()
80
- updates = []
81
- for result in results:
82
- state = str(result["status"])
83
- if state in ("not_included", "manual_pending"):
84
- continue
85
- row = rows[result["id"]]
86
- row["revision"] = int(row.get("revision", 0)) + 1
87
- status = "verified" if state == "passed" else state
88
- row["status"] = status
89
- record = {**result, "revision": row["revision"], "run_id": run_id, "executed_at": executed_at}
90
- row["evidence"] = record
91
- row.setdefault("runs", []).append(record)
92
- updates.append((row, record, status))
93
- from cf_spec_context import _atomic_text
94
- _atomic_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
95
- task_name = data.get("task_file")
96
- if isinstance(task_name, str) and task_name:
97
- task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
98
- for row, record, status in updates:
99
- detail = f"automated command {record['status']}; run_id={run_id}"
100
- sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)
@@ -1,294 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Lock and validate plan Acceptance Coverage entries."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import hashlib
8
- import json
9
- import shlex
10
- from pathlib import Path
11
- import re
12
- import sys
13
- from typing import IO, Callable, Mapping, Optional, Sequence
14
-
15
- from cf_acceptance_schema import load_manifest, validate_scenarios
16
-
17
-
18
- _ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
19
-
20
- _MANIFEST_SCHEMA = 2
21
-
22
-
23
- def _kind(level: str) -> str:
24
- """Map test level to execution kind.
25
-
26
- - functional: auto-executed in Done Gate (unit, integration)
27
- - e2e: deferred until explicit --verify-e2e (needs external dependencies)
28
- - manual: user confirmation required, never auto-executed
29
-
30
- 层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
31
- 取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
32
- """
33
- head = level.strip().lower()
34
- for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
35
- head = head.split(separator, 1)[0]
36
- head = head.strip()
37
- if head in ("manual", "手工", "手动"):
38
- return "manual"
39
- if head in ("e2e", "端到端", "end"):
40
- return "e2e"
41
- return "functional"
42
-
43
-
44
- def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
45
- """Parse the optional 命令 column: argv JSON array preferred, else shell words.
46
-
47
- Empty / `-` / `planned` → None (not yet registered, not executable).
48
- """
49
- text = (cell or "").strip()
50
- if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
51
- return None
52
- if text.startswith("["):
53
- try:
54
- value = json.loads(text)
55
- except json.JSONDecodeError as exc:
56
- raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
57
- if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
58
- raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
59
- return list(value)
60
- return shlex.split(text)
61
-
62
-
63
- def _parse_float(cell: str, default: float) -> float:
64
- try:
65
- return float((cell or "").strip() or default)
66
- except (TypeError, ValueError):
67
- return default
68
-
69
-
70
- def _manifest_hash(rows: list[dict[str, object]]) -> str:
71
- immutable = [
72
- {key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")}
73
- for item in rows
74
- ]
75
- payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
76
- return hashlib.sha256(payload).hexdigest()
77
-
78
-
79
- def _legacy_kind(level: str) -> str:
80
- """v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
81
- normalized = level.strip().lower()
82
- if normalized == "manual":
83
- return "manual"
84
- if normalized == "e2e":
85
- return "e2e"
86
- return "functional"
87
-
88
-
89
- def _extract_rows(task_file: str, kind_fn: Callable[[str], str]) -> dict[str, object]:
90
- path = Path(task_file)
91
- text = path.read_text(encoding="utf-8")
92
- rows: list[dict[str, object]] = []
93
- in_table = False
94
- seen: set[str] = set()
95
- for line in text.splitlines():
96
- if line.startswith("## Acceptance Coverage"):
97
- in_table = True
98
- continue
99
- if in_table and line.startswith("## "):
100
- break
101
- if not in_table:
102
- continue
103
- match = _ROW_RE.match(line)
104
- if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
105
- continue
106
- fields = [item.strip() for item in match.group(2).split("|")]
107
- if match.group(1) in seen:
108
- raise ValueError(f"duplicate scenario id: {match.group(1)}")
109
- if len(fields) < 5:
110
- raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
111
- seen.add(match.group(1))
112
- depends = [item.strip() for item in (fields[8] if len(fields) > 8 else "").split(",") if item.strip()]
113
- rows.append({
114
- "id": match.group(1),
115
- "source": fields[0],
116
- "level": fields[1],
117
- "kind": kind_fn(fields[1]),
118
- "boundary": fields[2],
119
- "owner": fields[3],
120
- "status": fields[4],
121
- "command": _parse_command(fields[5], match.group(1)) if len(fields) > 5 else None,
122
- "cwd": (fields[6] if len(fields) > 6 else "").strip() or ".",
123
- "timeout": _parse_float(fields[7], 60.0) if len(fields) > 7 else 60.0,
124
- "depends_on": depends,
125
- })
126
- if not rows:
127
- raise ValueError("Acceptance Coverage 缺失或为空")
128
- validate_scenarios(rows)
129
- return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
130
-
131
-
132
- def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = _kind) -> dict[str, object]:
133
- return _extract_rows(task_file, kind_fn)
134
-
135
-
136
- def write_manifest(task_file: str, output: str) -> dict[str, object]:
137
- manifest = extract_manifest(task_file)
138
- target = Path(output)
139
- try:
140
- manifest["task_file"] = str(Path(task_file).resolve().relative_to(target.parent.resolve()))
141
- except ValueError:
142
- manifest["task_file"] = str(Path(task_file).resolve())
143
- target.parent.mkdir(parents=True, exist_ok=True)
144
- target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
145
- return manifest
146
-
147
-
148
- _IMMUTABLE_SCENARIO_FIELDS = (
149
- "id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on",
150
- )
151
-
152
-
153
- def _heal_legacy_kinds(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
154
- """旧版 `_kind` 精确匹配把带注解的 E2E 锁成 functional;任务内容未变时
155
- 原地升级 kind 并重锁哈希,保留 execution evidence 等运行态字段。"""
156
- by_id: dict[str, dict[str, object]] = {}
157
- scenarios = expected.get("scenarios")
158
- if isinstance(scenarios, list):
159
- for item in scenarios:
160
- if isinstance(item, dict) and isinstance(item.get("id"), str):
161
- by_id[item["id"]] = item
162
- current = manifest.get("scenarios")
163
- if isinstance(current, list):
164
- for item in current:
165
- if not isinstance(item, dict):
166
- continue
167
- target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
168
- if target is not None:
169
- item["kind"] = target.get("kind")
170
- manifest["task_sha256"] = expected["task_sha256"]
171
- manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
172
-
173
-
174
- def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
175
- try:
176
- manifest = load_manifest(Path(manifest_file))
177
- expected = extract_manifest(task_file)
178
- except (OSError, ValueError, json.JSONDecodeError) as exc:
179
- return False, f"acceptance_manifest_invalid: {exc}"
180
- if manifest.get("schema") != _MANIFEST_SCHEMA:
181
- return False, "acceptance_manifest_drift"
182
- if manifest.get("task_sha256") != expected["task_sha256"]:
183
- legacy = extract_manifest(task_file, _legacy_kind)
184
- if manifest.get("task_sha256") != legacy["task_sha256"]:
185
- return False, "acceptance_manifest_drift"
186
- _heal_legacy_kinds(Path(manifest_file), manifest, expected)
187
- stored_task = manifest.get("task_file", "")
188
- if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
189
- return False, "acceptance_manifest_task_mismatch"
190
- if not isinstance(manifest.get("scenarios"), list):
191
- return False, "acceptance_manifest_scenarios_changed"
192
- stored = {
193
- item.get("id"): item
194
- for item in manifest["scenarios"]
195
- if isinstance(item, Mapping) and isinstance(item.get("id"), str)
196
- }
197
- required = {item["id"] for item in expected["scenarios"]}
198
- if set(stored) != required:
199
- return False, "acceptance_manifest_scenarios_changed"
200
- for item in expected["scenarios"]:
201
- current = stored[item["id"]]
202
- for field in _IMMUTABLE_SCENARIO_FIELDS:
203
- if current.get(field) != item.get(field):
204
- return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
205
- text = Path(task_file).read_text(encoding="utf-8")
206
- for scenario in expected["scenarios"]:
207
- owner = scenario["owner"]
208
- section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
209
- refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
210
- if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
211
- return False, "acceptance_manifest_owner_missing"
212
- return True, ""
213
-
214
-
215
- def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: str, evidence: str) -> None:
216
- if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
217
- raise ValueError("manual evidence must be confirmed by a user")
218
- path = Path(manifest_file)
219
- data = load_manifest(path)
220
- scenarios = data.get("scenarios")
221
- if not isinstance(scenarios, list):
222
- raise ValueError("invalid manifest scenarios")
223
- found = False
224
- owner = ""
225
- task_file = data.get("task_file")
226
- for item in scenarios:
227
- if isinstance(item, dict) and item.get("id") == scenario_id:
228
- if item.get("kind") != "manual":
229
- raise ValueError("scenario is not manual")
230
- item["status"] = "verified"
231
- item["evidence"] = {"confirmed_by": confirmed_by, "evidence": evidence}
232
- found = True
233
- owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
234
- if not found:
235
- raise ValueError("scenario not found")
236
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
237
- if isinstance(task_file, str) and task_file:
238
- task_path = Path(task_file)
239
- if not task_path.is_absolute():
240
- task_path = path.parent / task_path
241
- _sync_task_evidence(task_path, scenario_id, confirmed_by, evidence, owner)
242
-
243
-
244
- def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
245
- from cf_acceptance_evidence import sync_task_evidence
246
- sync_task_evidence(task_file, scenario_id, confirmed_by, evidence, owner)
247
-
248
-
249
- def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
250
- parser = argparse.ArgumentParser()
251
- parser.add_argument("--task-file", required=True)
252
- parser.add_argument("--output", default="")
253
- parser.add_argument("--verify-plan", action="store_true")
254
- parser.add_argument("--task-dir", default="")
255
- parser.add_argument("--record-manual", action="store_true")
256
- parser.add_argument("--scenario-id", default="")
257
- parser.add_argument("--confirmed-by", default="")
258
- parser.add_argument("--evidence", default="")
259
- args = parser.parse_args(argv)
260
- try:
261
- if args.record_manual:
262
- if not args.output or not args.scenario_id or not args.evidence:
263
- raise ValueError("--record-manual requires --output, --scenario-id and --evidence")
264
- record_manual_evidence(args.output, args.scenario_id, args.confirmed_by, args.evidence)
265
- stdout.write(json.dumps({"ok": True, "scenario": args.scenario_id}, ensure_ascii=False))
266
- return 0
267
- if args.verify_plan:
268
- if not args.task_dir:
269
- raise ValueError("--verify-plan requires --task-dir")
270
- from cf_spec_context import load_context
271
- from cf_spec_gate import result_to_data, validate_plan_coverage, validate_stage
272
-
273
- context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
274
- gate = validate_stage(context, "plan")
275
- coverage = validate_plan_coverage(context, args.task_file)
276
- result = {
277
- "decision": "block" if gate.decision == "block" or coverage.decision == "block" else "pass",
278
- "gate": result_to_data(gate),
279
- "coverage": result_to_data(coverage),
280
- }
281
- stdout.write(json.dumps(result, ensure_ascii=False))
282
- return 0 if result["decision"] == "pass" else 3
283
- if not args.output:
284
- raise ValueError("--output is required unless --verify-plan is used")
285
- manifest = write_manifest(args.task_file, args.output)
286
- stdout.write(json.dumps({"ok": True, "scenarios": len(manifest["scenarios"])}, ensure_ascii=False))
287
- return 0
288
- except (OSError, ValueError) as exc:
289
- stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
290
- return 2
291
-
292
-
293
- if __name__ == "__main__":
294
- raise SystemExit(main())