fleetproof 0.1.0__py3-none-any.whl
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.
- fleetproof/__init__.py +46 -0
- fleetproof/__main__.py +6 -0
- fleetproof/checker.py +280 -0
- fleetproof/checks.py +218 -0
- fleetproof/cli.py +293 -0
- fleetproof/hookgate.py +166 -0
- fleetproof/report.py +384 -0
- fleetproof/runlog.py +500 -0
- fleetproof-0.1.0.dist-info/METADATA +231 -0
- fleetproof-0.1.0.dist-info/RECORD +14 -0
- fleetproof-0.1.0.dist-info/WHEEL +5 -0
- fleetproof-0.1.0.dist-info/entry_points.txt +2 -0
- fleetproof-0.1.0.dist-info/licenses/LICENSE +21 -0
- fleetproof-0.1.0.dist-info/top_level.txt +1 -0
fleetproof/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""FleetProof — independent, out-of-band verification for agent fleets.
|
|
2
|
+
|
|
3
|
+
The agent may author the checks; it never executes and grades its own work.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .runlog import (
|
|
11
|
+
RunRecord,
|
|
12
|
+
SubInvocation,
|
|
13
|
+
child_run_id,
|
|
14
|
+
current_run_id,
|
|
15
|
+
list_run_records,
|
|
16
|
+
load_run,
|
|
17
|
+
project_root,
|
|
18
|
+
record,
|
|
19
|
+
recorded,
|
|
20
|
+
runs_dir,
|
|
21
|
+
set_runs_dir,
|
|
22
|
+
)
|
|
23
|
+
from .checks import Check, CheckSpecError, load_checks
|
|
24
|
+
from .checker import CheckReport, CheckResult, run_check, run_checks
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"recorded",
|
|
29
|
+
"record",
|
|
30
|
+
"current_run_id",
|
|
31
|
+
"child_run_id",
|
|
32
|
+
"project_root",
|
|
33
|
+
"runs_dir",
|
|
34
|
+
"set_runs_dir",
|
|
35
|
+
"list_run_records",
|
|
36
|
+
"load_run",
|
|
37
|
+
"RunRecord",
|
|
38
|
+
"SubInvocation",
|
|
39
|
+
"Check",
|
|
40
|
+
"CheckSpecError",
|
|
41
|
+
"load_checks",
|
|
42
|
+
"CheckReport",
|
|
43
|
+
"CheckResult",
|
|
44
|
+
"run_check",
|
|
45
|
+
"run_checks",
|
|
46
|
+
]
|
fleetproof/__main__.py
ADDED
fleetproof/checker.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The independent checker.
|
|
2
|
+
|
|
3
|
+
This is the part of FleetProof that no agent runs against itself. The agent that
|
|
4
|
+
did the work authored ``checks.json``; *this* module executes those checks — each
|
|
5
|
+
declared command in its own OS subprocess — grades the results deterministically,
|
|
6
|
+
and appends the verdict to the run log. It is invoked from the Stop hook, i.e.
|
|
7
|
+
from a process the working agent does not control.
|
|
8
|
+
|
|
9
|
+
The separation is load-bearing:
|
|
10
|
+
- the working agent writes the run records and the check spec;
|
|
11
|
+
- the checker (a different process) executes and grades them.
|
|
12
|
+
|
|
13
|
+
No LLM is in this path. Grading is pure comparison.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .checks import Check, default_checks_path, load_checks, short_spec_hash, spec_hash
|
|
26
|
+
from .runlog import list_run_records, record, runs_dir
|
|
27
|
+
|
|
28
|
+
# Per-check wall-clock ceiling. A check that hangs is a failed check, not a hung fleet.
|
|
29
|
+
DEFAULT_TIMEOUT_S = 600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CheckResult:
|
|
34
|
+
id: str
|
|
35
|
+
expectation: str
|
|
36
|
+
passed: bool
|
|
37
|
+
blocking: bool
|
|
38
|
+
returncode: int | None
|
|
39
|
+
detail: str
|
|
40
|
+
duration_ms: float
|
|
41
|
+
stdout_tail: str = ""
|
|
42
|
+
stderr_tail: str = ""
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CheckReport:
|
|
50
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
51
|
+
run_id: str | None = None
|
|
52
|
+
spec_sha256: str | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def total(self) -> int:
|
|
56
|
+
return len(self.results)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def passed(self) -> int:
|
|
60
|
+
return sum(1 for r in self.results if r.passed)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def failed(self) -> int:
|
|
64
|
+
return sum(1 for r in self.results if not r.passed)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def blocking_failures(self) -> list[CheckResult]:
|
|
68
|
+
return [r for r in self.results if not r.passed and r.blocking]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def verdict(self) -> str:
|
|
72
|
+
"""'pass' unless a blocking check failed. Non-blocking failures do not gate."""
|
|
73
|
+
return "fail" if self.blocking_failures else "pass"
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"verdict": self.verdict,
|
|
78
|
+
# SHA-256 of the checks.json that governed this verdict. Additive:
|
|
79
|
+
# records written before this field existed simply omit it, and every
|
|
80
|
+
# reader treats a missing value as None (no hash on record).
|
|
81
|
+
"spec_sha256": self.spec_sha256,
|
|
82
|
+
"summary": {
|
|
83
|
+
"total": self.total,
|
|
84
|
+
"passed": self.passed,
|
|
85
|
+
"failed": self.failed,
|
|
86
|
+
"blocking_failed": len(self.blocking_failures),
|
|
87
|
+
},
|
|
88
|
+
"checks": [r.to_dict() for r in self.results],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _tail(text: str, limit: int = 2000) -> str:
|
|
93
|
+
if len(text) <= limit:
|
|
94
|
+
return text
|
|
95
|
+
return "…(truncated)…\n" + text[-limit:]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_command(command: str, cwd: Path, timeout: int) -> tuple[int | None, str, str]:
|
|
99
|
+
"""Execute a shell command in a separate process. Returns (returncode, stdout, stderr).
|
|
100
|
+
|
|
101
|
+
A timeout or spawn failure yields a None returncode, which every grader treats
|
|
102
|
+
as a failure — the tool never lets an unrunnable check silently pass.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
proc = subprocess.run(
|
|
106
|
+
command,
|
|
107
|
+
shell=True,
|
|
108
|
+
cwd=str(cwd),
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
)
|
|
113
|
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
114
|
+
except subprocess.TimeoutExpired as e:
|
|
115
|
+
out = e.stdout or ""
|
|
116
|
+
err = e.stderr or ""
|
|
117
|
+
if isinstance(out, bytes):
|
|
118
|
+
out = out.decode(errors="replace")
|
|
119
|
+
if isinstance(err, bytes):
|
|
120
|
+
err = err.decode(errors="replace")
|
|
121
|
+
return None, out, (err + f"\n[fleetproof] command timed out after {timeout}s")
|
|
122
|
+
except OSError as e:
|
|
123
|
+
return None, "", f"[fleetproof] could not launch command: {e}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _grade(check: Check, returncode: int | None, stdout: str, stderr: str, cwd: Path) -> tuple[bool, str]:
|
|
127
|
+
"""Return (passed, human-readable detail) for one check. Pure comparison."""
|
|
128
|
+
kind = check.expect["kind"]
|
|
129
|
+
|
|
130
|
+
if kind in ("exit0", "exit"):
|
|
131
|
+
want = 0 if kind == "exit0" else check.expect["code"]
|
|
132
|
+
if returncode is None:
|
|
133
|
+
return False, f"command did not complete (expected exit {want})"
|
|
134
|
+
ok = returncode == want
|
|
135
|
+
return ok, f"exit {returncode} (expected {want})"
|
|
136
|
+
|
|
137
|
+
if kind == "regex":
|
|
138
|
+
if returncode is None:
|
|
139
|
+
return False, "command did not complete; cannot match output"
|
|
140
|
+
pattern = check.expect["pattern"]
|
|
141
|
+
combined = f"{stdout}\n{stderr}"
|
|
142
|
+
ok = re.search(pattern, combined) is not None
|
|
143
|
+
return ok, ("matched" if ok else "no match") + f" for /{pattern}/"
|
|
144
|
+
|
|
145
|
+
if kind == "file_exists":
|
|
146
|
+
target = (cwd / check.expect["path"]).resolve()
|
|
147
|
+
ok = target.exists()
|
|
148
|
+
return ok, ("present" if ok else "missing") + f": {check.expect['path']}"
|
|
149
|
+
|
|
150
|
+
return False, f"unknown expectation kind: {kind}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_check(check: Check, cwd: Path, timeout: int = DEFAULT_TIMEOUT_S) -> CheckResult:
|
|
154
|
+
"""Execute and grade a single check in its own subprocess."""
|
|
155
|
+
import time as _time
|
|
156
|
+
started = _time.perf_counter()
|
|
157
|
+
returncode: int | None = None
|
|
158
|
+
stdout = stderr = ""
|
|
159
|
+
if check.run is not None:
|
|
160
|
+
returncode, stdout, stderr = _run_command(check.run, cwd, timeout)
|
|
161
|
+
passed, detail = _grade(check, returncode, stdout, stderr, cwd)
|
|
162
|
+
duration_ms = (_time.perf_counter() - started) * 1000.0
|
|
163
|
+
return CheckResult(
|
|
164
|
+
id=check.id,
|
|
165
|
+
expectation=check.describe_expectation(),
|
|
166
|
+
passed=passed,
|
|
167
|
+
blocking=check.block,
|
|
168
|
+
returncode=returncode,
|
|
169
|
+
detail=detail,
|
|
170
|
+
duration_ms=round(duration_ms, 3),
|
|
171
|
+
stdout_tail=_tail(stdout),
|
|
172
|
+
stderr_tail=_tail(stderr),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_checks(
|
|
177
|
+
checks: list[Check] | None = None,
|
|
178
|
+
*,
|
|
179
|
+
cwd: Path | None = None,
|
|
180
|
+
timeout: int = DEFAULT_TIMEOUT_S,
|
|
181
|
+
record_to_log: bool = True,
|
|
182
|
+
spec_path: Path | None = None,
|
|
183
|
+
) -> CheckReport:
|
|
184
|
+
"""Run every check and (by default) append the verdict to the run log.
|
|
185
|
+
|
|
186
|
+
``checks`` defaults to the loaded ``.fleetproof/checks.json``. ``cwd`` defaults
|
|
187
|
+
to the current working directory — the directory the fleet actually worked in.
|
|
188
|
+
``spec_path`` is the check-spec file whose bytes get hashed onto the verdict;
|
|
189
|
+
when omitted it is resolved from ``cwd`` the same way the checker itself finds
|
|
190
|
+
the spec, so the recorded hash always describes the spec that governed the run.
|
|
191
|
+
"""
|
|
192
|
+
if checks is None:
|
|
193
|
+
checks = load_checks(spec_path)
|
|
194
|
+
work_dir = Path(cwd) if cwd is not None else Path.cwd()
|
|
195
|
+
resolved_spec = Path(spec_path) if spec_path is not None else default_checks_path(work_dir)
|
|
196
|
+
sha = spec_hash(resolved_spec)
|
|
197
|
+
|
|
198
|
+
report = CheckReport(spec_sha256=sha)
|
|
199
|
+
if not record_to_log:
|
|
200
|
+
for check in checks:
|
|
201
|
+
report.results.append(run_check(check, work_dir, timeout))
|
|
202
|
+
return report
|
|
203
|
+
|
|
204
|
+
with record("fleetproof", "check", {"check_count": len(checks)}) as handle:
|
|
205
|
+
report.run_id = handle.run_id
|
|
206
|
+
for check in checks:
|
|
207
|
+
report.results.append(run_check(check, work_dir, timeout))
|
|
208
|
+
payload = report.to_dict()
|
|
209
|
+
payload["recorded_from_pid"] = _self_pid()
|
|
210
|
+
handle.set_output(payload)
|
|
211
|
+
return report
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def session_spec_baseline(session_id: str | None) -> str | None:
|
|
215
|
+
"""The spec hash of the EARLIEST recorded checker verdict in ``session_id``.
|
|
216
|
+
|
|
217
|
+
This is the baseline every later verdict in the same session is compared
|
|
218
|
+
against: if a verdict's own spec hash differs from this, the checks.json was
|
|
219
|
+
edited mid-session (spec drift). Legacy verdicts with no hash on record are
|
|
220
|
+
skipped, so the baseline is the earliest verdict that actually carries one.
|
|
221
|
+
Returns None when there is no session context or no hashed verdict yet — in
|
|
222
|
+
which case there is nothing to drift *from*, so callers treat it as no drift.
|
|
223
|
+
"""
|
|
224
|
+
if not session_id:
|
|
225
|
+
return None
|
|
226
|
+
hashed: list[tuple[str, str]] = []
|
|
227
|
+
for run in list_run_records():
|
|
228
|
+
if (run.session_id or None) != session_id:
|
|
229
|
+
continue
|
|
230
|
+
for sub in run.sub_invocations:
|
|
231
|
+
if sub.tool != "fleetproof" or sub.subcmd != "check":
|
|
232
|
+
continue
|
|
233
|
+
payload = sub.load_output()
|
|
234
|
+
if not isinstance(payload, dict):
|
|
235
|
+
continue
|
|
236
|
+
sha = payload.get("spec_sha256")
|
|
237
|
+
if sha:
|
|
238
|
+
order_key = sub.started_at or run.started_at or ""
|
|
239
|
+
hashed.append((order_key, sha))
|
|
240
|
+
if not hashed:
|
|
241
|
+
return None
|
|
242
|
+
hashed.sort(key=lambda item: item[0])
|
|
243
|
+
return hashed[0][1]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def spec_drifted(current_hash: str | None, session_id: str | None) -> tuple[bool, str | None]:
|
|
247
|
+
"""Return ``(drifted, baseline_hash)`` for a verdict's hash within a session.
|
|
248
|
+
|
|
249
|
+
``drifted`` is True only when a session baseline exists and the current hash
|
|
250
|
+
differs from it — a v0.1 pass-with-drift still passes, this just flags it.
|
|
251
|
+
"""
|
|
252
|
+
baseline = session_spec_baseline(session_id)
|
|
253
|
+
drifted = bool(baseline and current_hash and baseline != current_hash)
|
|
254
|
+
return drifted, baseline
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _self_pid() -> int:
|
|
258
|
+
import os
|
|
259
|
+
return os.getpid()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def format_report_text(report: CheckReport) -> str:
|
|
263
|
+
"""A compact plain-text rendering for the CLI and hook feedback."""
|
|
264
|
+
lines = []
|
|
265
|
+
for r in report.results:
|
|
266
|
+
mark = "PASS" if r.passed else ("FAIL" if r.blocking else "warn")
|
|
267
|
+
lines.append(f" [{mark}] {r.id}: {r.detail}")
|
|
268
|
+
s = report.to_dict()["summary"]
|
|
269
|
+
lines.append(
|
|
270
|
+
f"{report.verdict.upper()} - {s['passed']}/{s['total']} passed, "
|
|
271
|
+
f"{s['blocking_failed']} blocking failure(s)."
|
|
272
|
+
)
|
|
273
|
+
lines.append(f"spec: {short_spec_hash(report.spec_sha256)}")
|
|
274
|
+
return "\n".join(lines)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__": # pragma: no cover - manual smoke entry
|
|
278
|
+
rep = run_checks()
|
|
279
|
+
print(format_report_text(rep))
|
|
280
|
+
sys.exit(1 if rep.verdict == "fail" else 0)
|
fleetproof/checks.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Check-spec loader.
|
|
2
|
+
|
|
3
|
+
A check spec is a JSON file — ``.fleetproof/checks.json`` by default — that
|
|
4
|
+
declares, up front, what "done" is supposed to mean for this repo. An agent is
|
|
5
|
+
allowed to *author* this file. It is never allowed to grade itself against it:
|
|
6
|
+
that job belongs to :mod:`fleetproof.checker`, which runs in a separate process.
|
|
7
|
+
|
|
8
|
+
Spec shape::
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"checks": [
|
|
12
|
+
{
|
|
13
|
+
"id": "tests-pass",
|
|
14
|
+
"run": "python -m pytest -q",
|
|
15
|
+
"expect": "exit0",
|
|
16
|
+
"block": true,
|
|
17
|
+
"description": "Unit tests must pass."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
``expect`` is one of:
|
|
23
|
+
"exit0" command must exit 0 (default if omitted)
|
|
24
|
+
{"exit": N} command must exit with code N
|
|
25
|
+
{"regex": "pattern"} combined stdout+stderr must match the regex
|
|
26
|
+
{"file_exists": "path"} path (relative to the run cwd) must exist
|
|
27
|
+
|
|
28
|
+
JSON, not YAML, on purpose: the runtime is stdlib-only, so there is no third-
|
|
29
|
+
party parser to trust in a tool whose entire job is being trustworthy.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from .runlog import PROJECT_MARKER, project_root
|
|
41
|
+
|
|
42
|
+
DEFAULT_CHECKS_FILENAME = "checks.json"
|
|
43
|
+
|
|
44
|
+
_VALID_EXPECT_KEYS = {"exit", "regex", "file_exists"}
|
|
45
|
+
|
|
46
|
+
# The line a drifted verdict carries everywhere it surfaces (gate output, report,
|
|
47
|
+
# CLI). One canonical string so the three surfaces never disagree about wording.
|
|
48
|
+
SPEC_DRIFT_NOTE = (
|
|
49
|
+
"NOTE: .fleetproof/checks.json was modified during this session "
|
|
50
|
+
"(spec drift). Review the diff before trusting this verdict."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CheckSpecError(Exception):
|
|
55
|
+
"""Raised when a check spec is missing or malformed."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Check:
|
|
60
|
+
"""One declared check. Immutable — the spec is a contract, not a scratchpad."""
|
|
61
|
+
id: str
|
|
62
|
+
run: str | None
|
|
63
|
+
expect: dict[str, Any] # normalized: {"kind": ..., ...}
|
|
64
|
+
block: bool
|
|
65
|
+
description: str = ""
|
|
66
|
+
|
|
67
|
+
def describe_expectation(self) -> str:
|
|
68
|
+
kind = self.expect["kind"]
|
|
69
|
+
if kind == "exit0":
|
|
70
|
+
return "exit code 0"
|
|
71
|
+
if kind == "exit":
|
|
72
|
+
return f"exit code {self.expect['code']}"
|
|
73
|
+
if kind == "regex":
|
|
74
|
+
return f"output matches /{self.expect['pattern']}/"
|
|
75
|
+
if kind == "file_exists":
|
|
76
|
+
return f"file exists: {self.expect['path']}"
|
|
77
|
+
return kind
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def default_checks_path(start: Path | None = None) -> Path:
|
|
81
|
+
"""Return the default check-spec location for the project rooted at ``start``."""
|
|
82
|
+
return project_root(start) / PROJECT_MARKER / DEFAULT_CHECKS_FILENAME
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def spec_hash(path: Path | None = None) -> str | None:
|
|
86
|
+
"""SHA-256 (hex) of the check-spec bytes, or ``None`` if it can't be read.
|
|
87
|
+
|
|
88
|
+
Hashed over the raw file bytes, not the parsed spec: the point is to notice
|
|
89
|
+
that *the file the agent authored changed* mid-session, which a byte hash
|
|
90
|
+
catches even for edits (whitespace, key reordering) that parse identically.
|
|
91
|
+
Returns None rather than raising so a missing/unreadable spec degrades to
|
|
92
|
+
"no hash on record" instead of breaking a gate run.
|
|
93
|
+
"""
|
|
94
|
+
spec_path = Path(path) if path is not None else default_checks_path()
|
|
95
|
+
try:
|
|
96
|
+
return hashlib.sha256(spec_path.read_bytes()).hexdigest()
|
|
97
|
+
except OSError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def short_spec_hash(full: str | None, length: int = 12) -> str:
|
|
102
|
+
"""A git-style short form of a spec hash for display; 'unknown' when absent."""
|
|
103
|
+
if not full:
|
|
104
|
+
return "unknown"
|
|
105
|
+
return full[:length]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_checks(path: Path | None = None) -> list[Check]:
|
|
109
|
+
"""Load and validate the check spec. Raises CheckSpecError on any problem."""
|
|
110
|
+
spec_path = Path(path) if path is not None else default_checks_path()
|
|
111
|
+
if not spec_path.exists():
|
|
112
|
+
raise CheckSpecError(
|
|
113
|
+
f"No check spec found at {spec_path}. Run `fleetproof init` to create one."
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
raw = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
117
|
+
except json.JSONDecodeError as e:
|
|
118
|
+
raise CheckSpecError(f"Check spec {spec_path} is not valid JSON: {e}") from e
|
|
119
|
+
|
|
120
|
+
if not isinstance(raw, dict) or "checks" not in raw:
|
|
121
|
+
raise CheckSpecError("Check spec must be an object with a top-level 'checks' array.")
|
|
122
|
+
entries = raw["checks"]
|
|
123
|
+
if not isinstance(entries, list):
|
|
124
|
+
raise CheckSpecError("'checks' must be an array.")
|
|
125
|
+
|
|
126
|
+
checks: list[Check] = []
|
|
127
|
+
seen_ids: set[str] = set()
|
|
128
|
+
for i, entry in enumerate(entries):
|
|
129
|
+
check = _parse_check(entry, i)
|
|
130
|
+
if check.id in seen_ids:
|
|
131
|
+
raise CheckSpecError(f"Duplicate check id: {check.id!r}")
|
|
132
|
+
seen_ids.add(check.id)
|
|
133
|
+
checks.append(check)
|
|
134
|
+
return checks
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_check(entry: Any, index: int) -> Check:
|
|
138
|
+
where = f"checks[{index}]"
|
|
139
|
+
if not isinstance(entry, dict):
|
|
140
|
+
raise CheckSpecError(f"{where} must be an object.")
|
|
141
|
+
|
|
142
|
+
cid = entry.get("id")
|
|
143
|
+
if not cid or not isinstance(cid, str):
|
|
144
|
+
raise CheckSpecError(f"{where} is missing a string 'id'.")
|
|
145
|
+
|
|
146
|
+
run = entry.get("run")
|
|
147
|
+
if run is not None and not isinstance(run, str):
|
|
148
|
+
raise CheckSpecError(f"{where} ({cid}): 'run' must be a string or omitted.")
|
|
149
|
+
|
|
150
|
+
block = entry.get("block", True)
|
|
151
|
+
if not isinstance(block, bool):
|
|
152
|
+
raise CheckSpecError(f"{where} ({cid}): 'block' must be true or false.")
|
|
153
|
+
|
|
154
|
+
description = entry.get("description", "")
|
|
155
|
+
if not isinstance(description, str):
|
|
156
|
+
raise CheckSpecError(f"{where} ({cid}): 'description' must be a string.")
|
|
157
|
+
|
|
158
|
+
expect = _normalize_expect(entry.get("expect", "exit0"), cid, where)
|
|
159
|
+
|
|
160
|
+
# A file_exists check may have no command; every other kind needs one.
|
|
161
|
+
if run is None and expect["kind"] != "file_exists":
|
|
162
|
+
raise CheckSpecError(
|
|
163
|
+
f"{where} ({cid}): a '{expect['kind']}' check requires a 'run' command."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return Check(id=cid, run=run, expect=expect, block=block, description=description)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_expect(expect: Any, cid: str, where: str) -> dict[str, Any]:
|
|
170
|
+
if expect == "exit0":
|
|
171
|
+
return {"kind": "exit0"}
|
|
172
|
+
if not isinstance(expect, dict) or len(expect) != 1:
|
|
173
|
+
raise CheckSpecError(
|
|
174
|
+
f"{where} ({cid}): 'expect' must be \"exit0\" or a single-key object "
|
|
175
|
+
f"({sorted(_VALID_EXPECT_KEYS)})."
|
|
176
|
+
)
|
|
177
|
+
key, val = next(iter(expect.items()))
|
|
178
|
+
if key not in _VALID_EXPECT_KEYS:
|
|
179
|
+
raise CheckSpecError(f"{where} ({cid}): unknown expect kind {key!r}.")
|
|
180
|
+
if key == "exit":
|
|
181
|
+
if not isinstance(val, int) or isinstance(val, bool):
|
|
182
|
+
raise CheckSpecError(f"{where} ({cid}): expect.exit must be an integer.")
|
|
183
|
+
return {"kind": "exit", "code": val}
|
|
184
|
+
if key == "regex":
|
|
185
|
+
if not isinstance(val, str):
|
|
186
|
+
raise CheckSpecError(f"{where} ({cid}): expect.regex must be a string.")
|
|
187
|
+
return {"kind": "regex", "pattern": val}
|
|
188
|
+
# file_exists
|
|
189
|
+
if not isinstance(val, str):
|
|
190
|
+
raise CheckSpecError(f"{where} ({cid}): expect.file_exists must be a path string.")
|
|
191
|
+
return {"kind": "file_exists", "path": val}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
STARTER_SPEC: dict[str, Any] = {
|
|
195
|
+
"checks": [
|
|
196
|
+
{
|
|
197
|
+
"id": "tests-pass",
|
|
198
|
+
"run": "python -m pytest -q",
|
|
199
|
+
"expect": "exit0",
|
|
200
|
+
"block": True,
|
|
201
|
+
"description": "Unit tests must pass before a task can be called done.",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"id": "build-succeeds",
|
|
205
|
+
"run": "python -m build",
|
|
206
|
+
"expect": "exit0",
|
|
207
|
+
"block": True,
|
|
208
|
+
"description": "The package must build. Edit or delete if you do not ship a package.",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"id": "changelog-updated",
|
|
212
|
+
"run": None,
|
|
213
|
+
"expect": {"file_exists": "CHANGELOG.md"},
|
|
214
|
+
"block": False,
|
|
215
|
+
"description": "Advisory reminder — non-blocking example of a file_exists check.",
|
|
216
|
+
},
|
|
217
|
+
]
|
|
218
|
+
}
|