@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,192 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Execute manifest scenarios with deterministic ordering and evidence."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- from pathlib import Path
9
- import sys
10
- from typing import IO, Mapping, Optional, Sequence
11
-
12
- from cf_exec_base import execution_key, run_command, invalidate_executions
13
- from cf_acceptance_schema import load_manifest, validate_execution_baseline
14
- from cf_acceptance_evidence import persist_results
15
-
16
-
17
- def _run(
18
- item: Mapping[str, object], root: Path, include_e2e: bool = False, deadline: Optional[float] = None,
19
- only_e2e: bool = False,
20
- ) -> dict[str, object]:
21
- scenario_id = str(item.get("id", "unknown"))
22
- kind = str(item.get("kind", "functional"))
23
- if only_e2e and kind != "e2e":
24
- return {"id": scenario_id, "kind": kind, "status": "not_included"}
25
- if kind == "manual":
26
- return {"id": scenario_id, "kind": kind, "status": "manual_pending"}
27
- if kind == "e2e" and not include_e2e and not only_e2e:
28
- return {"id": scenario_id, "kind": kind, "status": "e2e_deferred"}
29
- command = item.get("command")
30
- if not isinstance(command, list) or not command or not all(isinstance(value, str) for value in command):
31
- return {"id": scenario_id, "kind": kind, "status": "not_configured"}
32
- timeout = item.get("timeout", 60)
33
- timeout_value = float(timeout) if isinstance(timeout, (int, float)) else 60.0
34
- cwd = item.get("cwd", ".") if isinstance(item.get("cwd", "."), str) else "."
35
- outcome = run_command(command, str(root / cwd), timeout_value, deadline)
36
- if outcome["status"] == "deadline_exceeded":
37
- return {"id": scenario_id, "kind": kind, "status": "incomplete", "error": "deadline_exceeded"}
38
- if outcome["status"] == "timeout":
39
- return {"id": scenario_id, "kind": kind, "status": "failed", "error": "timeout",
40
- "output": str(outcome.get("stdout", ""))}
41
- if outcome["status"] == "spawn_error":
42
- return {"id": scenario_id, "kind": kind, "status": "failed", "error": str(outcome.get("stderr", ""))}
43
- if outcome["returncode"] == 0:
44
- return {"id": scenario_id, "kind": kind, "status": "passed", "exit_code": 0}
45
- output = (str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-4000:]
46
- return {"id": scenario_id, "kind": kind, "status": "failed", "exit_code": outcome["returncode"], "output": output}
47
-
48
-
49
- def _ordered(items: list[Mapping[str, object]]) -> list[Mapping[str, object]]:
50
- by_id = {str(item.get("id")): item for item in items}
51
- pending = set(by_id)
52
- ordered: list[Mapping[str, object]] = []
53
- while pending:
54
- ready: list[str] = []
55
- for item_id in by_id:
56
- if item_id not in pending:
57
- continue
58
- deps = by_id[item_id].get("depends_on", [])
59
- dep_ids = [str(dep) for dep in deps] if isinstance(deps, list) else []
60
- unknown = [dep for dep in dep_ids if dep not in by_id]
61
- if unknown:
62
- raise ValueError(f"acceptance scenario dependency missing: {', '.join(unknown)}")
63
- if all(dep not in pending for dep in dep_ids):
64
- ready.append(item_id)
65
- if not ready:
66
- raise ValueError("acceptance scenario dependency cycle")
67
- ordered.extend(by_id[item_id] for item_id in ready)
68
- pending.difference_update(ready)
69
- return ordered
70
-
71
-
72
- def _executable(items: list[Mapping[str, object]]) -> bool:
73
- """Draft completeness vs execution gate: at least one scenario must be
74
- executable — a functional scenario with a registered command, or a
75
- manual / e2e scenario with its own confirmation/deferral path. Zero
76
- executable scenarios must block execution acceptance, never pass it."""
77
- for item in items:
78
- command = item.get("command")
79
- if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
80
- return True
81
- if str(item.get("kind", "")) in ("manual", "e2e"):
82
- return True
83
- return False
84
-
85
-
86
- def _owned(items: list[Mapping[str, object]], owner: str) -> list[Mapping[str, object]]:
87
- """Keep scenarios owned by the active TASK; ownerless legacy scenarios stay
88
- included so old manifests keep working. Cross-TASK depends_on edges are
89
- pruned: single-TASK ordering is enforced here, whole-demand ordering at
90
- the final (archive/E2E) gate."""
91
- if not owner:
92
- return items
93
- kept = [
94
- item for item in items
95
- if not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner
96
- ]
97
- kept_ids = {str(item.get("id")) for item in kept}
98
- pruned: list[Mapping[str, object]] = []
99
- for item in kept:
100
- if not isinstance(item, dict):
101
- pruned.append(item)
102
- continue
103
- deps = item.get("depends_on")
104
- if isinstance(deps, list):
105
- item = {**item, "depends_on": [dep for dep in deps if str(dep) in kept_ids]}
106
- pruned.append(item)
107
- return pruned
108
-
109
-
110
- def _run_unique(
111
- ordered: list[Mapping[str, object]], root: Path, include_e2e: bool, deadline: Optional[float] = None,
112
- only_e2e: bool = False,
113
- ) -> list[dict[str, object]]:
114
- """Reuse identical execution semantics within a dependency-free batch."""
115
- cache: dict[str, Mapping[str, object]] = {}
116
- results: list[dict[str, object]] = []
117
- for item in ordered:
118
- scenario_id = str(item.get("id", "unknown"))
119
- command = item.get("command")
120
- key: Optional[str] = None
121
- if isinstance(command, list) and command and all(isinstance(value, str) for value in command):
122
- key = execution_key(command, str(root / str(item.get("cwd", "."))), float(item.get("timeout", 60)))
123
- key += json.dumps([item.get("kind", "functional"), include_e2e, only_e2e])
124
- if item.get("depends_on"):
125
- cache.clear()
126
- invalidate_executions()
127
- if key is not None and key in cache:
128
- shared = dict(cache[key])
129
- shared["id"] = scenario_id
130
- results.append(shared)
131
- continue
132
- result = _run(item, root, include_e2e, deadline, only_e2e)
133
- if key is not None:
134
- cache[key] = result
135
- results.append(result)
136
- return results
137
-
138
-
139
- def run_manifest(
140
- manifest_file: str,
141
- root: str,
142
- write_evidence: bool = False,
143
- include_e2e: bool = False,
144
- owner: str = "",
145
- deadline: Optional[float] = None,
146
- only_e2e: bool = False,
147
- ) -> dict[str, object]:
148
- data = load_manifest(Path(manifest_file))
149
- validate_execution_baseline(Path(manifest_file), data)
150
- scenarios = data["scenarios"]
151
- _ordered(scenarios) # Validate the complete DAG before owner filtering.
152
- items = _owned(scenarios, owner)
153
- ordered = _ordered(items)
154
- results = _run_unique(ordered, Path(root), include_e2e or only_e2e, deadline, only_e2e)
155
- if write_evidence:
156
- persist_results(Path(manifest_file), data, results)
157
- if not _executable(items):
158
- return {"decision": "block", "results": results, "error": "no_executable_scenarios"}
159
- allowed = ("passed", "manual_pending", "e2e_deferred", "not_included")
160
- decision = "pass" if all(item["status"] in allowed for item in results) else "block"
161
- error = ""
162
- if decision == "block":
163
- pending = sorted({str(item["id"]) for item in results if item["status"] == "incomplete"})
164
- failed = sorted({str(item["id"]) for item in results if item["status"] not in (*allowed, "incomplete")})
165
- error = ",".join((["incomplete:" + ",".join(pending)] if pending else []) + (["failed:" + ",".join(failed)] if failed else [])) or "acceptance scenario failed"
166
- outcome: dict[str, object] = {"decision": decision, "results": results}
167
- if error:
168
- outcome["error"] = error
169
- return outcome
170
-
171
-
172
- def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
173
- parser = argparse.ArgumentParser()
174
- parser.add_argument("--manifest", required=True)
175
- parser.add_argument("--root", required=True)
176
- parser.add_argument("--write-evidence", action="store_true")
177
- parser.add_argument("--include-e2e", action="store_true", help="Execute E2E scenarios (deferred by default)")
178
- parser.add_argument("--only-e2e", action="store_true", help="Execute only E2E scenarios; functional/manual are reported not_included")
179
- parser.add_argument("--owner", default="", help="Only execute scenarios owned by this TASK (plus ownerless legacy ones)")
180
- parser.add_argument("--deadline", type=float, default=0.0, help="Absolute monotonic deadline propagated from the entry gate (0 = unbounded)")
181
- args = parser.parse_args(argv)
182
- try:
183
- result = run_manifest(args.manifest, args.root, args.write_evidence, args.include_e2e, args.owner, args.deadline or None, args.only_e2e)
184
- stdout.write(json.dumps(result, ensure_ascii=False))
185
- return 0 if result["decision"] == "pass" else 3
186
- except (OSError, ValueError, json.JSONDecodeError) as exc:
187
- stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
188
- return 2
189
-
190
-
191
- if __name__ == "__main__":
192
- raise SystemExit(main())
@@ -1,83 +0,0 @@
1
- """Validated acceptance records shared by execution and lifecycle gates."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import math
6
- from pathlib import Path
7
- from typing import Mapping
8
-
9
-
10
- def verified_evidence(row: Mapping[str, object]) -> bool:
11
- evidence = row.get("evidence")
12
- if row.get("status") != "verified" or not isinstance(evidence, dict):
13
- return False
14
- if row.get("kind") != "manual":
15
- return evidence.get("status") == "passed" and evidence.get("exit_code") == 0
16
- author, detail = evidence.get("confirmed_by"), evidence.get("evidence")
17
- agents = {"agent", "assistant", "codex", "claude", "opencode", "costrict"}
18
- return (isinstance(author, str) and bool(author.strip())
19
- and author.strip().lower().split(":", 1)[0] not in agents
20
- and isinstance(detail, str) and bool(detail.strip()))
21
-
22
-
23
- def validate_scenarios(value: object) -> list[dict[str, object]]:
24
- if not isinstance(value, list):
25
- raise ValueError("invalid manifest scenarios: expected list")
26
- seen: set[str] = set()
27
- for item in value:
28
- if not isinstance(item, dict):
29
- raise ValueError("invalid scenario: expected object")
30
- scenario_id = item.get("id")
31
- if not isinstance(scenario_id, str) or not scenario_id.strip():
32
- raise ValueError("scenario id missing")
33
- if scenario_id in seen:
34
- raise ValueError(f"duplicate scenario id: {scenario_id}")
35
- seen.add(scenario_id)
36
- for field in ("kind", "owner", "cwd"):
37
- if field in item and not isinstance(item[field], str):
38
- raise ValueError(f"invalid scenario {scenario_id}:{field}")
39
- command = item.get("command")
40
- if command is not None and (not isinstance(command, list) or not all(isinstance(x, str) for x in command)):
41
- raise ValueError(f"invalid scenario {scenario_id}:command")
42
- timeout = item.get("timeout", 60)
43
- if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
44
- raise ValueError(f"invalid scenario {scenario_id}:timeout")
45
- deps = item.get("depends_on", [])
46
- if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
47
- raise ValueError(f"invalid scenario {scenario_id}:depends_on")
48
- if "runs" in item and not isinstance(item["runs"], list):
49
- raise ValueError(f"invalid scenario {scenario_id}:runs")
50
- revision = item.get("revision", 0)
51
- if isinstance(revision, bool) or not isinstance(revision, int) or revision < 0:
52
- raise ValueError(f"invalid scenario {scenario_id}:revision")
53
- for item in value:
54
- unknown = set(item.get("depends_on", [])) - seen
55
- if unknown:
56
- raise ValueError(f"acceptance scenario dependency missing: {', '.join(sorted(unknown))}")
57
- return value
58
-
59
-
60
- def load_manifest(path: Path) -> dict[str, object]:
61
- data = json.loads(path.read_text(encoding="utf-8"))
62
- if not isinstance(data, dict):
63
- raise ValueError("invalid acceptance manifest: expected object")
64
- validate_scenarios(data.get("scenarios"))
65
- if "task_file" in data and not isinstance(data["task_file"], str):
66
- raise ValueError("invalid acceptance manifest: task_file")
67
- return data
68
-
69
-
70
- def validate_execution_baseline(path: Path, data: Mapping[str, object]) -> None:
71
- """Legacy standalone manifests remain usable; locked plans must match."""
72
- task_name = data.get("task_file")
73
- if not isinstance(task_name, str) or not task_name:
74
- return
75
- task = Path(task_name)
76
- task = task if task.is_absolute() else path.parent / task
77
- if not task.is_file():
78
- raise ValueError(f"acceptance task file missing: {task}")
79
- if "## Acceptance Coverage" in task.read_text(encoding="utf-8"):
80
- from cf_acceptance_manifest import validate_manifest
81
- valid, reason = validate_manifest(str(task), str(path))
82
- if not valid:
83
- raise ValueError(reason)
@@ -1,336 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Spec frontmatter checks: parse, cache and execute (quality loop FEAT-01).
3
-
4
- Contract (spec-quality-loop.design.md §2.3.2 / §3.4 / §3.5):
5
- - Checks live in spec frontmatter `checks:` so rule text and machine check
6
- stay in the same file (ADR: frontmatter over checks.yml).
7
- - Invalid entries are skipped and surfaced as parse errors (E-01) — they must
8
- never break parsing of sibling checks or spec injection.
9
- - Execution guards: per-check timeout (E-02), oversized content skipped
10
- (B-01), disabled checks filtered (RULE-04 state), matched lines per check
11
- capped so feedback stays small (RISK-05).
12
- - yaml import is lazy: specs without `checks:` never pay for it on the
13
- PostToolUse hot path (NFR-PERF-01).
14
- """
15
- import fnmatch
16
- import os
17
- import re
18
- import sys
19
- from typing import Mapping
20
- from concurrent.futures import ThreadPoolExecutor
21
- from concurrent.futures import TimeoutError as FutureTimeout
22
-
23
- from cf_core import _FRONTMATTER_RE, normalize_path
24
-
25
- CHECK_TIMEOUT_SECONDS = 2.0
26
- MAX_CONTENT_BYTES = 256 * 1024
27
- MESSAGE_MAX_LEN = 200
28
- MAX_LINES_PER_CHECK = 3
29
-
30
- _ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
31
- IMPLEMENTED_TYPES = ("regex",)
32
- RESERVED_TYPES = ("ast", "cmd") # interface reserved, implementation deferred
33
- _SEVERITIES = ("warn", "info") # no "error": feedback only, never blocking
34
-
35
- _checks_cache: dict = {}
36
-
37
- STATE_NAME = ".check-state.json"
38
- FP_DISABLE_COUNT = 3
39
- FP_DISABLE_RATE = 0.10
40
-
41
-
42
- def _log(msg: str) -> None:
43
- sys.stderr.write(f"[cf-checks] {msg}\n")
44
-
45
-
46
- def state_path(project_root: str) -> str:
47
- return os.path.join(project_root, ".code-flow", STATE_NAME)
48
-
49
-
50
- def load_check_state(project_root: str) -> dict:
51
- """Read check-state; corrupt/missing files rebuild as empty (tolerant)."""
52
- import json
53
- try:
54
- with open(state_path(project_root), "r", encoding="utf-8") as f:
55
- data = json.load(f)
56
- return data if isinstance(data, dict) else {}
57
- except Exception:
58
- return {}
59
-
60
-
61
- def save_check_state(project_root: str, state: dict) -> bool:
62
- import json
63
- try:
64
- path = state_path(project_root)
65
- os.makedirs(os.path.dirname(path), exist_ok=True)
66
- with open(path, "w", encoding="utf-8") as f:
67
- json.dump(state, f, ensure_ascii=False)
68
- return True
69
- except Exception as exc:
70
- _log(f"save_check_state failed: {exc}")
71
- return False
72
-
73
-
74
- def record_hits(project_root: str, check_ids: list) -> dict:
75
- """Increment hit_count for triggered checks (误报率分母). Returns state."""
76
- state = load_check_state(project_root)
77
- for cid in check_ids:
78
- entry = state.setdefault(cid, {})
79
- entry["hit_count"] = int(entry.get("hit_count", 0)) + 1
80
- save_check_state(project_root, state)
81
- return state
82
-
83
-
84
- def record_false_positive(project_root: str, check_id: str) -> dict:
85
- """Mark one false positive; auto-disable past RULE-04 thresholds.
86
-
87
- Returns {"check_id", "fp_count", "hit_count", "disabled"}.
88
- """
89
- state = load_check_state(project_root)
90
- entry = state.setdefault(check_id, {})
91
- entry["fp_count"] = int(entry.get("fp_count", 0)) + 1
92
- fp = entry["fp_count"]
93
- hits = int(entry.get("hit_count", 0))
94
- if not entry.get("disabled") and (
95
- fp >= FP_DISABLE_COUNT or (hits > 0 and fp / hits > FP_DISABLE_RATE)
96
- ):
97
- entry["disabled"] = True
98
- entry["disabled_reason"] = (
99
- f"auto: fp_count={fp}" if fp >= FP_DISABLE_COUNT
100
- else f"auto: fp_rate={fp}/{hits}"
101
- )
102
- save_check_state(project_root, state)
103
- return {
104
- "check_id": check_id,
105
- "fp_count": fp,
106
- "hit_count": hits,
107
- "disabled": bool(entry.get("disabled")),
108
- }
109
-
110
-
111
- def _validate_entry(entry: object, index: int, seen_ids: set) -> tuple:
112
- """Validate one raw checks entry → (check_dict | None, error | None)."""
113
- if not isinstance(entry, dict):
114
- return None, f"checks[{index}]: 不是对象,已跳过"
115
- cid = entry.get("id")
116
- if not isinstance(cid, str) or not _ID_RE.match(cid):
117
- return None, f"checks[{index}]: id 缺失或非 kebab-case,已跳过"
118
- if cid in seen_ids:
119
- return None, f"checks[{index}]: id '{cid}' 重复,已跳过"
120
- ctype = entry.get("type")
121
- if ctype in RESERVED_TYPES:
122
- return None, f"{cid}: type '{ctype}' 暂未实现(接口预留),已跳过"
123
- if ctype not in IMPLEMENTED_TYPES:
124
- return None, f"{cid}: 未知 type '{ctype}',已跳过"
125
- pattern = entry.get("pattern")
126
- if not isinstance(pattern, str) or not pattern:
127
- return None, f"{cid}: pattern 缺失,已跳过"
128
- try:
129
- compiled = re.compile(pattern)
130
- except re.error as exc:
131
- return None, f"{cid}: pattern 非法正则({exc}),已跳过"
132
-
133
- message = entry.get("message")
134
- if not isinstance(message, str) or not message.strip():
135
- return None, f"{cid}: message 缺失,已跳过"
136
- error = None
137
- if len(message) > MESSAGE_MAX_LEN:
138
- error = f"{cid}: message 超过 {MESSAGE_MAX_LEN} 字符,运行时截断"
139
- message = message[:MESSAGE_MAX_LEN]
140
-
141
- severity = entry.get("severity", "warn")
142
- if severity not in _SEVERITIES:
143
- error = error or f"{cid}: severity '{severity}' 非法,按 warn 处理"
144
- severity = "warn"
145
-
146
- # default "*": fnmatch's "*" crosses "/" so it matches any depth;
147
- # "**/*" under fnmatch would demand at least one directory level and
148
- # silently skip repo-root files (same pitfall fixed in config patterns)
149
- files = entry.get("files")
150
- if not isinstance(files, str) or not files:
151
- files = "*"
152
-
153
- return {
154
- "id": cid,
155
- "type": ctype,
156
- "pattern": pattern,
157
- "regex": compiled,
158
- "files": files,
159
- "message": message,
160
- "severity": severity,
161
- }, error
162
-
163
-
164
- def parse_spec_checks(content: str) -> tuple:
165
- """Parse frontmatter checks → (checks, errors). Both lists, never raises."""
166
- if not isinstance(content, str):
167
- return [], []
168
- match = _FRONTMATTER_RE.match(content)
169
- if not match or "checks:" not in match.group(1):
170
- return [], []
171
- try:
172
- import yaml
173
- meta = yaml.safe_load(match.group(1)) or {}
174
- except Exception as exc:
175
- return [], [f"frontmatter YAML 解析失败: {exc}"]
176
- raw = meta.get("checks")
177
- if raw is None:
178
- return [], []
179
- if not isinstance(raw, list):
180
- return [], ["checks 必须是列表"]
181
- checks: list = []
182
- errors: list = []
183
- seen_ids: set = set()
184
- for index, entry in enumerate(raw):
185
- check, error = _validate_entry(entry, index, seen_ids)
186
- if error:
187
- errors.append(error)
188
- if check:
189
- checks.append(check)
190
- seen_ids.add(check["id"])
191
- return checks, errors
192
-
193
-
194
- def load_spec_checks(spec_abs_path: str) -> tuple:
195
- """Parse a spec file's checks with mtime cache → (checks, errors)."""
196
- try:
197
- mtime = os.path.getmtime(spec_abs_path)
198
- except OSError:
199
- return [], []
200
- cached = _checks_cache.get(spec_abs_path)
201
- if cached and cached["mtime"] == mtime:
202
- return cached["checks"], cached["errors"]
203
- try:
204
- with open(spec_abs_path, "r", encoding="utf-8") as f:
205
- checks, errors = parse_spec_checks(f.read())
206
- except Exception as exc:
207
- _log(f"load_spec_checks failed {spec_abs_path}: {exc}")
208
- return [], []
209
- _checks_cache[spec_abs_path] = {
210
- "mtime": mtime, "checks": checks, "errors": errors,
211
- }
212
- return checks, errors
213
-
214
-
215
- # 纠正句式(FEAT-04 信号①):保守收录、宁漏勿误(E-06)。
216
- # 否定排除:不要紧/不要紧张、对不对、不对外/不对称。
217
- _CORRECTION_PATTERNS = [
218
- r"不要(?!紧)",
219
- r"(?<!对)不对(?![外称])",
220
- r"不是这样",
221
- r"我说过",
222
- r"改回[去来]",
223
- r"别再?用",
224
- r"写错了",
225
- r"\bdon'?t\s+use\b",
226
- r"\bthat'?s\s+wrong\b",
227
- r"\bI\s+said\b",
228
- r"\brevert\s+(that|this|it)\b",
229
- r"\bstop\s+using\b",
230
- ]
231
- _CORRECTION_RE = re.compile("|".join(_CORRECTION_PATTERNS), re.IGNORECASE)
232
-
233
-
234
- def detect_correction(prompt: str) -> dict:
235
- """Detect a correction phrase in a user prompt → {"phrase", "span"} | None.
236
-
237
- Single combined regex, first hit wins; the wordlist is deliberately
238
- conservative — a missed correction only delays learning, a false hit
239
- pollutes the candidate stream.
240
- """
241
- if not isinstance(prompt, str) or not prompt:
242
- return None
243
- match = _CORRECTION_RE.search(prompt)
244
- if not match:
245
- return None
246
- return {"phrase": match.group(0), "span": [match.start(), match.end()]}
247
-
248
-
249
- def _match_lines(regex, content: str) -> list:
250
- hits = []
251
- for line_no, line in enumerate(content.splitlines(), 1):
252
- if regex.search(line):
253
- hits.append((line_no, line.strip()))
254
- if len(hits) >= MAX_LINES_PER_CHECK:
255
- break
256
- return hits
257
-
258
-
259
- def run_checks(
260
- checks: list,
261
- rel_path: str,
262
- content: str,
263
- state: dict = None,
264
- timeout: float = CHECK_TIMEOUT_SECONDS,
265
- ) -> tuple:
266
- """Execute checks against one file's content → (violations, skipped).
267
-
268
- violations: [{check_id, file, line_no, line, message, severity}]
269
- skipped: [{check_id, reason}] — timeout / oversize, caller records
270
- degrade events (E-02 / B-01).
271
- Disabled checks (state[check_id]["disabled"]) are silently filtered.
272
- """
273
- violations: list = []
274
- skipped: list = []
275
- if not checks or not isinstance(content, str):
276
- return violations, skipped
277
- if len(content.encode("utf-8", errors="ignore")) > MAX_CONTENT_BYTES:
278
- return violations, [{"check_id": "*", "reason": "content_too_large"}]
279
-
280
- rel_path = normalize_path(rel_path)
281
- state = state or {}
282
- with ThreadPoolExecutor(max_workers=1) as pool:
283
- for check in checks:
284
- if state.get(check["id"], {}).get("disabled"):
285
- continue
286
- if not fnmatch.fnmatch(rel_path, check["files"]):
287
- continue
288
- future = pool.submit(_match_lines, check["regex"], content)
289
- try:
290
- hits = future.result(timeout=timeout)
291
- except FutureTimeout:
292
- skipped.append({"check_id": check["id"], "reason": "timeout"})
293
- future.cancel()
294
- continue
295
- except Exception as exc:
296
- skipped.append({"check_id": check["id"], "reason": f"error:{exc}"})
297
- continue
298
- for line_no, line in hits:
299
- violations.append({
300
- "check_id": check["id"],
301
- "file": rel_path,
302
- "line_no": line_no,
303
- "line": line[:160],
304
- "message": check["message"],
305
- "severity": check["severity"],
306
- })
307
- return violations, skipped
308
-
309
-
310
- def run_regex_verifier(
311
- pattern: str,
312
- files: str,
313
- contents: Mapping[str, str],
314
- timeout: float = CHECK_TIMEOUT_SECONDS,
315
- ) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
316
- """Execute one verifier regex over explicit file contents."""
317
- try:
318
- compiled = re.compile(pattern)
319
- except re.error as exc:
320
- return [], [{"check_id": "verifier", "reason": f"invalid_regex:{exc}"}]
321
- check = {
322
- "id": "verifier",
323
- "type": "regex",
324
- "pattern": pattern,
325
- "regex": compiled,
326
- "files": files,
327
- "message": "Rule verifier matched forbidden content",
328
- "severity": "warn",
329
- }
330
- violations: list[dict[str, object]] = []
331
- skipped: list[dict[str, object]] = []
332
- for relative, content in contents.items():
333
- file_violations, file_skipped = run_checks([check], relative, content, timeout=timeout)
334
- violations.extend(file_violations)
335
- skipped.extend(file_skipped)
336
- return violations, skipped