@michaelxwb/code-flow 0.6.2 → 0.6.3
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/package.json +1 -1
- package/src/adapters/claude/commands/cf-task/archive.md +5 -5
- package/src/adapters/claude/commands/cf-task/block.md +15 -9
- package/src/adapters/claude/commands/cf-task/graph.md +8 -1
- package/src/adapters/claude/commands/cf-task/note.md +1 -4
- package/src/adapters/claude/commands/cf-task/start.md +18 -17
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/claude/commands/cf-validate.md +12 -13
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +5 -5
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -9
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +8 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +1 -4
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +18 -17
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +106 -0
- package/src/adapters/codex/skills/cf-validate/SKILL.md +12 -13
- package/src/adapters/costrict/commands/cf-task/archive.md +5 -5
- package/src/adapters/costrict/commands/cf-task/block.md +15 -9
- package/src/adapters/costrict/commands/cf-task/graph.md +8 -1
- package/src/adapters/costrict/commands/cf-task/note.md +1 -4
- package/src/adapters/costrict/commands/cf-task/start.md +18 -17
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/costrict/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/commands/cf-task/archive.md +5 -5
- package/src/adapters/opencode/commands/cf-task/block.md +15 -9
- package/src/adapters/opencode/commands/cf-task/graph.md +8 -1
- package/src/adapters/opencode/commands/cf-task/note.md +1 -4
- package/src/adapters/opencode/commands/cf-task/start.md +18 -17
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +8 -9
- package/src/adapters/opencode/commands/cf-validate.md +12 -13
- package/src/adapters/opencode/plugins/code-flow/index.js +50 -28
- package/src/cli.js +177 -63
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +100 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +85 -36
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +123 -38
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +83 -0
- package/src/core/code-flow/scripts/cf_core.py +6 -0
- package/src/core/code-flow/scripts/cf_exec_base.py +112 -0
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +8 -4
- package/src/core/code-flow/scripts/cf_scan.py +8 -6
- package/src/core/code-flow/scripts/cf_spec_context.py +178 -54
- package/src/core/code-flow/scripts/cf_spec_resolver.py +7 -2
- package/src/core/code-flow/scripts/cf_spec_router.py +2 -0
- package/src/core/code-flow/scripts/cf_spec_session.py +3 -2
- package/src/core/code-flow/scripts/cf_spec_verify.py +42 -31
- package/src/core/code-flow/scripts/cf_stats.py +41 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +98 -59
- package/src/core/code-flow/scripts/cf_task_index.py +147 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +62 -27
- package/src/core/code-flow/scripts/cf_task_state.py +3 -0
- package/src/core/code-flow/scripts/cf_task_workflow.py +107 -0
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +9 -5
- package/src/core/code-flow/scripts/cf_validation.py +81 -0
- package/src/core/code-flow/scripts/cf_workflow_service.py +366 -0
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +96 -0
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from dataclasses import dataclass, replace
|
|
7
7
|
import hashlib
|
|
8
|
+
import json
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
import time
|
|
10
11
|
from typing import Mapping, Optional
|
|
@@ -29,6 +30,8 @@ from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
|
29
30
|
from cf_spec_session import context_sha256
|
|
30
31
|
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
31
32
|
from cf_core import phase_timing
|
|
33
|
+
from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
|
|
34
|
+
from cf_exec_base import execution_session
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
@dataclass(frozen=True)
|
|
@@ -51,24 +54,45 @@ def _context_path(task_dir: str) -> str:
|
|
|
51
54
|
return str(Path(task_dir) / "spec-context.yml")
|
|
52
55
|
|
|
53
56
|
|
|
54
|
-
def _manual_manifest_issue(task_dir: str) -> str:
|
|
57
|
+
def _manual_manifest_issue(task_dir: str, owner: str = "") -> str:
|
|
55
58
|
path = Path(task_dir) / ".acceptance-manifest.json"
|
|
56
59
|
if not path.is_file():
|
|
57
60
|
return ""
|
|
58
61
|
try:
|
|
59
62
|
import json
|
|
60
63
|
|
|
61
|
-
data =
|
|
64
|
+
data = load_manifest(path)
|
|
62
65
|
pending = [
|
|
63
66
|
item.get("id", "unknown")
|
|
64
67
|
for item in data.get("scenarios", [])
|
|
65
|
-
if
|
|
68
|
+
if item.get("kind") == "manual" and not verified_evidence(item)
|
|
69
|
+
and (not owner or not isinstance(item.get("owner"), str) or not item.get("owner") or item.get("owner") == owner)
|
|
66
70
|
]
|
|
67
71
|
return f"manual 场景未完成: {', '.join(pending)}" if pending else ""
|
|
68
72
|
except (OSError, ValueError, TypeError):
|
|
69
73
|
return "acceptance manifest invalid"
|
|
70
74
|
|
|
71
75
|
|
|
76
|
+
def _acceptance_baseline_issue(task_dir: str, owner: str) -> str:
|
|
77
|
+
from cf_workflow_service import locate_task_file
|
|
78
|
+
path = Path(task_dir) / ".acceptance-manifest.json"
|
|
79
|
+
task = locate_task_file(task_dir, owner)
|
|
80
|
+
try:
|
|
81
|
+
requires = task is not None and "## Acceptance Coverage" in task.read_text(encoding="utf-8")
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return "acceptance_manifest_missing" if requires else ""
|
|
84
|
+
data = load_manifest(path)
|
|
85
|
+
if requires:
|
|
86
|
+
from cf_acceptance_manifest import validate_manifest
|
|
87
|
+
valid, reason = validate_manifest(str(task), str(path))
|
|
88
|
+
if not valid:
|
|
89
|
+
return reason
|
|
90
|
+
validate_execution_baseline(path, data)
|
|
91
|
+
return ""
|
|
92
|
+
except (OSError, ValueError) as exc:
|
|
93
|
+
return f"acceptance_manifest_invalid: {exc}"
|
|
94
|
+
|
|
95
|
+
|
|
72
96
|
def _required_candidate(candidate: SpecCandidate) -> bool:
|
|
73
97
|
metadata = candidate.metadata
|
|
74
98
|
return metadata.enforcement == "required" and any(
|
|
@@ -176,33 +200,38 @@ def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object
|
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
|
|
179
|
-
def
|
|
203
|
+
def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
|
|
204
|
+
deadline: Optional[float]) -> str:
|
|
205
|
+
baseline_issue = _acceptance_baseline_issue(task_dir, owner)
|
|
206
|
+
if baseline_issue:
|
|
207
|
+
return baseline_issue
|
|
208
|
+
manual_issue = _manual_manifest_issue(task_dir, owner)
|
|
209
|
+
if manual_issue:
|
|
210
|
+
return manual_issue
|
|
211
|
+
manifest_path = Path(task_dir) / ".acceptance-manifest.json"
|
|
212
|
+
if manifest_path.is_file():
|
|
213
|
+
from cf_acceptance_runner import run_manifest
|
|
214
|
+
scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e, owner=owner, deadline=deadline)
|
|
215
|
+
if scenario_result["decision"] == "block":
|
|
216
|
+
reason = scenario_result.get("error") or "acceptance scenario failed"
|
|
217
|
+
return f"acceptance scenario failed: {reason}"
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|
|
222
|
+
active = load_active_task(root)
|
|
223
|
+
owner = task_id or active.task_id
|
|
224
|
+
if owner != active.task_id or (Path(root) / active.task_dir).resolve() != Path(task_dir).resolve():
|
|
225
|
+
return DoneResult("block", (), (), "active_mismatch: task identity does not match marker")
|
|
180
226
|
started = time.monotonic()
|
|
181
|
-
phase_started = time.monotonic()
|
|
182
227
|
scope_result = evaluate_scope(root, task_dir)
|
|
183
|
-
phase_timing("done.evaluate_scope",
|
|
228
|
+
phase_timing("done.evaluate_scope", started)
|
|
184
229
|
if scope_result.decision == "pause":
|
|
185
230
|
return DoneResult("block", scope_result.files, (), scope_result.message)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if manifest_path.is_file():
|
|
191
|
-
try:
|
|
192
|
-
import json
|
|
193
|
-
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
194
|
-
has_commands = any(
|
|
195
|
-
isinstance(item, dict) and isinstance(item.get("command"), list)
|
|
196
|
-
for item in manifest.get("scenarios", [])
|
|
197
|
-
)
|
|
198
|
-
except (OSError, ValueError, TypeError):
|
|
199
|
-
return DoneResult("block", scope_result.files, (), "acceptance manifest invalid")
|
|
200
|
-
if has_commands:
|
|
201
|
-
from cf_acceptance_runner import run_manifest
|
|
202
|
-
|
|
203
|
-
scenario_result = run_manifest(str(manifest_path), root, write_evidence=True, include_e2e=include_e2e)
|
|
204
|
-
if scenario_result["decision"] == "block":
|
|
205
|
-
return DoneResult("block", scope_result.files, (), "acceptance scenario failed")
|
|
231
|
+
deadline = started + budget if budget is not None else None
|
|
232
|
+
issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline)
|
|
233
|
+
if issue:
|
|
234
|
+
return DoneResult("block", scope_result.files, (), issue)
|
|
206
235
|
phase_started = time.monotonic()
|
|
207
236
|
context = load_context(_context_path(task_dir))
|
|
208
237
|
diff_hash = _diff_hash(root, scope_result.files)
|
|
@@ -227,4 +256,10 @@ def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optiona
|
|
|
227
256
|
save_context(_context_path(task_dir), updated)
|
|
228
257
|
gate = validate_stage(updated, "code", diff_sha256=diff_hash)
|
|
229
258
|
phase_timing("done.total", started)
|
|
230
|
-
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence)
|
|
259
|
+
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
|
|
260
|
+
"; ".join(issue.message for issue in gate.errors))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|
|
264
|
+
with execution_session():
|
|
265
|
+
return _run_done_gate(root, task_dir, cheap, budget, include_e2e, task_id)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Executable task handoffs used by every platform command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Optional, Sequence
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from cf_acceptance_runner import run_manifest
|
|
13
|
+
from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
|
|
14
|
+
from cf_task_index import parse_task_file
|
|
15
|
+
from cf_task_runtime import run_done_gate
|
|
16
|
+
from cf_workflow_service import (FINISHED_STATUSES, _require_marker_task,
|
|
17
|
+
_sync_markdown, block_task, complete_task, locate_task_file, resume_task)
|
|
18
|
+
from cf_workflow_transaction import recover_transition
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def finish_task(root: str, directory: str, task_file: str, task_id: str) -> dict[str, object]:
|
|
22
|
+
_require_marker_task(root, task_id, directory, task_file)
|
|
23
|
+
gate = run_done_gate(root, directory, task_id=task_id)
|
|
24
|
+
if gate.decision != "pass":
|
|
25
|
+
return {"decision": "block", "reason": gate.message, "evidence": gate.evidence}
|
|
26
|
+
return {"decision": "pass", **complete_task(root, directory, task_file, task_id, True)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _demand_tasks(directory: Path) -> list[tuple[Path, str, str]]:
|
|
30
|
+
tasks = []
|
|
31
|
+
seen = set()
|
|
32
|
+
for task_file in sorted(directory.glob("*.md")):
|
|
33
|
+
if task_file.name.endswith((".design.md", ".prd.md")):
|
|
34
|
+
continue
|
|
35
|
+
if not re.search(r"(?m)^## TASK-\d+:", task_file.read_text(encoding="utf-8")):
|
|
36
|
+
continue
|
|
37
|
+
for node in parse_task_file(str(task_file)):
|
|
38
|
+
if node.task_id in seen:
|
|
39
|
+
raise ValueError(f"duplicate task id: {node.task_id}")
|
|
40
|
+
seen.add(node.task_id)
|
|
41
|
+
tasks.append((task_file, node.task_id, node.status))
|
|
42
|
+
if not tasks:
|
|
43
|
+
raise ValueError("no TASK sections found")
|
|
44
|
+
return tasks
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_e2e(root: str, directory: str) -> dict[str, object]:
|
|
48
|
+
recover_transition(root)
|
|
49
|
+
tasks = _demand_tasks(Path(directory))
|
|
50
|
+
unfinished = [task_id for _, task_id, status in tasks if status not in FINISHED_STATUSES]
|
|
51
|
+
if unfinished:
|
|
52
|
+
return {"decision": "block", "reason": "unfinished_tasks", "tasks": unfinished}
|
|
53
|
+
if (Path(root) / ".code-flow/.active-task.json").exists():
|
|
54
|
+
return {"decision": "block", "reason": "finish_active_task_first"}
|
|
55
|
+
manifest = Path(directory) / ".acceptance-manifest.json"
|
|
56
|
+
data = load_manifest(manifest)
|
|
57
|
+
validate_execution_baseline(manifest, data)
|
|
58
|
+
bound_task = (manifest.parent / str(data.get("task_file", ""))).resolve()
|
|
59
|
+
if {task.resolve() for task, _, _ in tasks} != {bound_task}:
|
|
60
|
+
return {"decision": "block", "reason": "acceptance_manifest_task_mismatch"}
|
|
61
|
+
required = [row["id"] for row in data["scenarios"] if row.get("kind", "functional") != "e2e"
|
|
62
|
+
and not verified_evidence(row)]
|
|
63
|
+
if required:
|
|
64
|
+
return {"decision": "block", "reason": "functional_or_manual_evidence_missing", "scenarios": required}
|
|
65
|
+
result = run_manifest(str(manifest), root, write_evidence=True, only_e2e=True)
|
|
66
|
+
if result["decision"] == "pass":
|
|
67
|
+
for task_file, task_id, _ in tasks:
|
|
68
|
+
_sync_markdown(str(task_file), task_id, "verified")
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
73
|
+
parser = argparse.ArgumentParser()
|
|
74
|
+
parser.add_argument("action", choices=("finish", "block", "resume", "verify-e2e"))
|
|
75
|
+
parser.add_argument("--root", default=os.getcwd())
|
|
76
|
+
parser.add_argument("--task-dir", required=True)
|
|
77
|
+
parser.add_argument("--task", default="")
|
|
78
|
+
parser.add_argument("--reason", default="")
|
|
79
|
+
parser.add_argument("--json", action="store_true")
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
try:
|
|
82
|
+
root = str(Path(args.root).resolve())
|
|
83
|
+
directory = str((Path(root) / args.task_dir).resolve())
|
|
84
|
+
if Path(root) not in Path(directory).parents:
|
|
85
|
+
raise ValueError("task directory outside project")
|
|
86
|
+
recover_transition(root)
|
|
87
|
+
if args.action == "verify-e2e":
|
|
88
|
+
result = verify_e2e(root, directory)
|
|
89
|
+
else:
|
|
90
|
+
task = locate_task_file(directory, args.task)
|
|
91
|
+
if task is None:
|
|
92
|
+
raise ValueError("task file missing or ambiguous")
|
|
93
|
+
if args.action == "finish":
|
|
94
|
+
result = finish_task(root, directory, str(task), args.task)
|
|
95
|
+
elif args.action == "block":
|
|
96
|
+
result = block_task(root, directory, str(task), args.task, args.reason)
|
|
97
|
+
else:
|
|
98
|
+
result = resume_task(root, directory, str(task), args.task)
|
|
99
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
100
|
+
return 3 if result.get("decision") == "block" else 0
|
|
101
|
+
except (OSError, ValueError) as exc:
|
|
102
|
+
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
|
@@ -19,6 +19,7 @@ from cf_core import (
|
|
|
19
19
|
timing_log,
|
|
20
20
|
)
|
|
21
21
|
from cf_session_state import load_session_state, save_session_state
|
|
22
|
+
from cf_spec_context import injection_version
|
|
22
23
|
from cf_spec_router import RouterError, route_prompt
|
|
23
24
|
|
|
24
25
|
|
|
@@ -133,9 +134,11 @@ def main() -> None:
|
|
|
133
134
|
reminder = _session_reminder(root, sid)
|
|
134
135
|
text = result.text
|
|
135
136
|
if result.mode == "task" and result.context_sha256:
|
|
136
|
-
# Dedup: inject
|
|
137
|
+
# Dedup: inject once per session/task/contract version, refreshing
|
|
137
138
|
# at the compress cadence so long sessions do not re-pay 12k chars
|
|
138
|
-
# on every prompt.
|
|
139
|
+
# on every prompt. The key covers task_id and the TASK contract
|
|
140
|
+
# digest (not just the Context hash) so switching TASKs on a
|
|
141
|
+
# shared Context always re-injects the new contract.
|
|
139
142
|
state = load_session_state(root)
|
|
140
143
|
count = int(state.get("prompt_count", 0))
|
|
141
144
|
dirty = False
|
|
@@ -146,12 +149,13 @@ def main() -> None:
|
|
|
146
149
|
count += 1
|
|
147
150
|
state["prompt_count"] = count
|
|
148
151
|
dirty = True
|
|
149
|
-
|
|
152
|
+
version = injection_version(root, sid, result.context_sha256)
|
|
153
|
+
injected = state.get("injected_version")
|
|
150
154
|
last_inject = int(state.get("last_inject_prompt", 0))
|
|
151
|
-
if injected ==
|
|
155
|
+
if injected == version and count - last_inject < _REINJECT_INTERVAL:
|
|
152
156
|
text = ""
|
|
153
157
|
else:
|
|
154
|
-
state["
|
|
158
|
+
state["injected_version"] = version
|
|
155
159
|
state["last_inject_prompt"] = count
|
|
156
160
|
dirty = True
|
|
157
161
|
if dirty:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Programmatic validation entry with complete Git scope and argv execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Optional, Sequence
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from cf_exec_base import execution_session
|
|
13
|
+
from cf_spec_context import _git_changes, current_owned_paths, load_active_task
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validation_scope(root: str, files: Sequence[str] = ()) -> tuple[str, ...]:
|
|
17
|
+
base = Path(root).resolve()
|
|
18
|
+
if files:
|
|
19
|
+
selected = []
|
|
20
|
+
for name in files:
|
|
21
|
+
path = (base / name).resolve()
|
|
22
|
+
if base not in path.parents:
|
|
23
|
+
raise ValueError(f"validation path outside project: {name}")
|
|
24
|
+
selected.append(path.relative_to(base).as_posix())
|
|
25
|
+
return tuple(sorted(set(selected)))
|
|
26
|
+
if (base / ".code-flow/.active-task.json").exists():
|
|
27
|
+
return current_owned_paths(str(base), load_active_task(str(base)))
|
|
28
|
+
return tuple(sorted(_git_changes(str(base))))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validators(root: str) -> list[dict[str, object]]:
|
|
32
|
+
from cf_stop_hook import load_validators
|
|
33
|
+
if (Path(root) / ".code-flow/validation.yml").is_file():
|
|
34
|
+
return load_validators(root)
|
|
35
|
+
package = Path(root) / "package.json"
|
|
36
|
+
if not package.is_file():
|
|
37
|
+
return []
|
|
38
|
+
data = json.loads(package.read_text(encoding="utf-8"))
|
|
39
|
+
scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
|
|
40
|
+
if not isinstance(scripts, dict):
|
|
41
|
+
raise ValueError("invalid package scripts")
|
|
42
|
+
return [{"name": name, "trigger": "*", "command": f"npm run {name}", "timeout": 60000}
|
|
43
|
+
for name in ("test", "lint") if isinstance(scripts.get(name), str)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_files(root: str, files: Sequence[str] = (), budget: Optional[float] = None) -> dict[str, object]:
|
|
47
|
+
from cf_stop_hook import run_validators
|
|
48
|
+
selected = validation_scope(root, files)
|
|
49
|
+
validators = _validators(root)
|
|
50
|
+
if not selected:
|
|
51
|
+
return {"decision": "pass", "files": [], "reason": "no_changes"}
|
|
52
|
+
if not validators:
|
|
53
|
+
return {"decision": "block", "files": selected, "reason": "no_validators_configured"}
|
|
54
|
+
maximum = budget if budget is not None else sum(float(v.get("timeout", 30000)) / 1000 for v in validators)
|
|
55
|
+
if not math.isfinite(maximum) or maximum <= 0:
|
|
56
|
+
raise ValueError("validation budget must be finite and positive")
|
|
57
|
+
with execution_session():
|
|
58
|
+
failures, truncated = run_validators(root, validators, list(selected), "cf-validate", maximum, strict=True)
|
|
59
|
+
return {"decision": "block" if failures or truncated else "pass", "files": selected,
|
|
60
|
+
"failures": failures, "incomplete": truncated}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser()
|
|
65
|
+
parser.add_argument("--root", default=os.getcwd())
|
|
66
|
+
parser.add_argument("--files", nargs="*", default=[])
|
|
67
|
+
parser.add_argument("--budget", type=float)
|
|
68
|
+
parser.add_argument("--scope-only", action="store_true")
|
|
69
|
+
parser.add_argument("--json", action="store_true")
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
try:
|
|
72
|
+
result = {"files": validation_scope(args.root, args.files)} if args.scope_only else validate_files(args.root, args.files, args.budget)
|
|
73
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
74
|
+
return 3 if result.get("decision") == "block" else 0
|
|
75
|
+
except (OSError, ValueError) as exc:
|
|
76
|
+
stdout.write(json.dumps({"decision": "block", "error": str(exc)}, ensure_ascii=False))
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
raise SystemExit(main())
|