@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.
- package/README.md +75 -110
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +268 -0
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +21 -9
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -264
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -67
- package/src/adapters/claude/commands/cf-stats.md +0 -86
- package/src/adapters/claude/commands/cf-sync.md +0 -25
- package/src/adapters/claude/commands/cf-task/align.md +0 -309
- package/src/adapters/claude/commands/cf-task/archive.md +0 -161
- package/src/adapters/claude/commands/cf-task/block.md +0 -36
- package/src/adapters/claude/commands/cf-task/graph.md +0 -80
- package/src/adapters/claude/commands/cf-task/note.md +0 -236
- package/src/adapters/claude/commands/cf-task/plan.md +0 -363
- package/src/adapters/claude/commands/cf-task/prd.md +0 -162
- package/src/adapters/claude/commands/cf-task/start.md +0 -304
- package/src/adapters/claude/commands/cf-task/status.md +0 -59
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/claude/commands/cf-validate.md +0 -75
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -50
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -264
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -67
- package/src/adapters/costrict/commands/cf-stats.md +0 -86
- package/src/adapters/costrict/commands/cf-sync.md +0 -25
- package/src/adapters/costrict/commands/cf-task/align.md +0 -309
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
- package/src/adapters/costrict/commands/cf-task/block.md +0 -36
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
- package/src/adapters/costrict/commands/cf-task/note.md +0 -236
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
- package/src/adapters/costrict/commands/cf-task/start.md +0 -304
- package/src/adapters/costrict/commands/cf-task/status.md +0 -59
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/costrict/commands/cf-validate.md +0 -75
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -59
- package/src/adapters/opencode/commands/cf-stats.md +0 -90
- package/src/adapters/opencode/commands/cf-sync.md +0 -29
- package/src/adapters/opencode/commands/cf-task/align.md +0 -314
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
- package/src/adapters/opencode/commands/cf-task/block.md +0 -40
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
- package/src/adapters/opencode/commands/cf-task/note.md +0 -240
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
- package/src/adapters/opencode/commands/cf-task/start.md +0 -309
- package/src/adapters/opencode/commands/cf-task/status.md +0 -63
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/opencode/commands/cf-validate.md +0 -79
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/cli.js +0 -1023
- package/src/core/code-flow/config.yml +0 -158
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_checks.py +0 -336
- package/src/core/code-flow/scripts/cf_core.py +0 -787
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_feedback.py +0 -56
- package/src/core/code-flow/scripts/cf_log.py +0 -154
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
- package/src/core/code-flow/scripts/cf_scan.py +0 -295
- package/src/core/code-flow/scripts/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
- package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
- package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
- package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
- package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
- package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
- package/src/core/code-flow/scripts/cf_stats.py +0 -506
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
- package/src/core/code-flow/scripts/cf_sync.py +0 -140
- package/src/core/code-flow/scripts/cf_task_index.py +0 -148
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_validation.py +0 -81
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -53
- 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
|