donespec 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.
- donespec/__init__.py +3 -0
- donespec/__main__.py +4 -0
- donespec/checkers/__init__.py +3 -0
- donespec/checkers/base.py +67 -0
- donespec/checkers/command.py +60 -0
- donespec/checkers/file_exists.py +28 -0
- donespec/checkers/git.py +61 -0
- donespec/checkers/http.py +58 -0
- donespec/checkers/regex.py +99 -0
- donespec/checkers/registry.py +26 -0
- donespec/cli.py +86 -0
- donespec/engine.py +44 -0
- donespec/exceptions.py +6 -0
- donespec/loader.py +26 -0
- donespec/models.py +47 -0
- donespec/output.py +55 -0
- donespec/pathing.py +17 -0
- donespec/schema.py +28 -0
- donespec/schemas/done.schema.json +196 -0
- donespec-0.1.0.dist-info/METADATA +397 -0
- donespec-0.1.0.dist-info/RECORD +24 -0
- donespec-0.1.0.dist-info/WHEEL +4 -0
- donespec-0.1.0.dist-info/entry_points.txt +2 -0
- donespec-0.1.0.dist-info/licenses/LICENSE +21 -0
donespec/__init__.py
ADDED
donespec/__main__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from donespec.models import CheckGroup, CheckResult, ValidationContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Checker(ABC):
|
|
11
|
+
"""Base class for deterministic checkers."""
|
|
12
|
+
|
|
13
|
+
type_name: str
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self, config: dict[str, Any], group: CheckGroup, context: ValidationContext
|
|
17
|
+
) -> None:
|
|
18
|
+
self.config = config
|
|
19
|
+
self.group = group
|
|
20
|
+
self.context = context
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def run(self) -> CheckResult:
|
|
24
|
+
"""Execute the checker and return a structured result."""
|
|
25
|
+
|
|
26
|
+
def result(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
passed: bool,
|
|
30
|
+
duration_ms: float,
|
|
31
|
+
error: str | None = None,
|
|
32
|
+
details: str | None = None,
|
|
33
|
+
metadata: dict[str, Any] | None = None,
|
|
34
|
+
) -> CheckResult:
|
|
35
|
+
return CheckResult(
|
|
36
|
+
id=self.config.get("id"),
|
|
37
|
+
name=self.config.get("name") or self.default_name(),
|
|
38
|
+
type=self.type_name,
|
|
39
|
+
group=self.group,
|
|
40
|
+
passed=passed,
|
|
41
|
+
duration_ms=duration_ms,
|
|
42
|
+
error=error,
|
|
43
|
+
details=details,
|
|
44
|
+
metadata=metadata or {},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def default_name(self) -> str:
|
|
48
|
+
return self.type_name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def timed(fn):
|
|
52
|
+
"""Decorator helper for checker implementations."""
|
|
53
|
+
|
|
54
|
+
def wrapper(self: Checker):
|
|
55
|
+
started = perf_counter()
|
|
56
|
+
try:
|
|
57
|
+
return fn(self, started)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
60
|
+
return self.result(
|
|
61
|
+
passed=False,
|
|
62
|
+
duration_ms=duration_ms,
|
|
63
|
+
error=str(exc),
|
|
64
|
+
metadata={"exception_type": exc.__class__.__name__},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return wrapper
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
|
|
6
|
+
from donespec.checkers.base import Checker, timed
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandChecker(Checker):
|
|
10
|
+
type_name = "command"
|
|
11
|
+
|
|
12
|
+
def default_name(self) -> str:
|
|
13
|
+
return self.config.get("run", "command")
|
|
14
|
+
|
|
15
|
+
@timed
|
|
16
|
+
def run(self, started: float):
|
|
17
|
+
command = self.config["run"]
|
|
18
|
+
expected_exit_code = int(self.config.get("expected_exit_code", 0))
|
|
19
|
+
timeout_seconds = float(self.config.get("timeout_seconds", 120))
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
completed = subprocess.run(
|
|
23
|
+
command,
|
|
24
|
+
cwd=self.context.root_dir,
|
|
25
|
+
shell=True,
|
|
26
|
+
text=True,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
timeout=timeout_seconds,
|
|
29
|
+
check=False,
|
|
30
|
+
)
|
|
31
|
+
except subprocess.TimeoutExpired as exc:
|
|
32
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
33
|
+
return self.result(
|
|
34
|
+
passed=False,
|
|
35
|
+
duration_ms=duration_ms,
|
|
36
|
+
error=f"Command timed out after {timeout_seconds:g}s",
|
|
37
|
+
metadata={
|
|
38
|
+
"command": command,
|
|
39
|
+
"timeout_seconds": timeout_seconds,
|
|
40
|
+
"stdout": exc.stdout,
|
|
41
|
+
"stderr": exc.stderr,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
46
|
+
passed = completed.returncode == expected_exit_code
|
|
47
|
+
details = f"exit_code={completed.returncode}, expected_exit_code={expected_exit_code}"
|
|
48
|
+
return self.result(
|
|
49
|
+
passed=passed,
|
|
50
|
+
duration_ms=duration_ms,
|
|
51
|
+
error=None if passed else details,
|
|
52
|
+
details=details,
|
|
53
|
+
metadata={
|
|
54
|
+
"command": command,
|
|
55
|
+
"exit_code": completed.returncode,
|
|
56
|
+
"expected_exit_code": expected_exit_code,
|
|
57
|
+
"stdout_tail": completed.stdout[-4000:],
|
|
58
|
+
"stderr_tail": completed.stderr[-4000:],
|
|
59
|
+
},
|
|
60
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
|
|
5
|
+
from donespec.checkers.base import Checker, timed
|
|
6
|
+
from donespec.pathing import resolve_under_root
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileExistsChecker(Checker):
|
|
10
|
+
type_name = "file_exists"
|
|
11
|
+
|
|
12
|
+
def default_name(self) -> str:
|
|
13
|
+
return f"{self.config['path']} exists"
|
|
14
|
+
|
|
15
|
+
@timed
|
|
16
|
+
def run(self, started: float):
|
|
17
|
+
raw_path = self.config["path"]
|
|
18
|
+
target = resolve_under_root(self.context.root_dir, raw_path)
|
|
19
|
+
exists = target.exists()
|
|
20
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
21
|
+
|
|
22
|
+
return self.result(
|
|
23
|
+
passed=exists,
|
|
24
|
+
duration_ms=duration_ms,
|
|
25
|
+
error=None if exists else f"File does not exist: {raw_path}",
|
|
26
|
+
details=f"resolved_path={target}",
|
|
27
|
+
metadata={"path": raw_path, "resolved_path": str(target), "is_file": target.is_file()},
|
|
28
|
+
)
|
donespec/checkers/git.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
|
|
6
|
+
from donespec.checkers.base import Checker, timed
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileNotModifiedChecker(Checker):
|
|
10
|
+
type_name = "file_not_modified"
|
|
11
|
+
|
|
12
|
+
def default_name(self) -> str:
|
|
13
|
+
return f"{self.config['path']} not modified"
|
|
14
|
+
|
|
15
|
+
@timed
|
|
16
|
+
def run(self, started: float):
|
|
17
|
+
raw_path = self.config["path"]
|
|
18
|
+
|
|
19
|
+
repo_check = subprocess.run(
|
|
20
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
21
|
+
cwd=self.context.root_dir,
|
|
22
|
+
text=True,
|
|
23
|
+
capture_output=True,
|
|
24
|
+
check=False,
|
|
25
|
+
)
|
|
26
|
+
if repo_check.returncode != 0:
|
|
27
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
28
|
+
return self.result(
|
|
29
|
+
passed=False,
|
|
30
|
+
duration_ms=duration_ms,
|
|
31
|
+
error="file_not_modified requires a Git work tree",
|
|
32
|
+
metadata={"path": raw_path, "stderr": repo_check.stderr.strip()},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
status = subprocess.run(
|
|
36
|
+
["git", "status", "--porcelain", "--", raw_path],
|
|
37
|
+
cwd=self.context.root_dir,
|
|
38
|
+
text=True,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
check=False,
|
|
41
|
+
)
|
|
42
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
43
|
+
|
|
44
|
+
if status.returncode != 0:
|
|
45
|
+
return self.result(
|
|
46
|
+
passed=False,
|
|
47
|
+
duration_ms=duration_ms,
|
|
48
|
+
error=status.stderr.strip() or "git status failed",
|
|
49
|
+
metadata={"path": raw_path, "exit_code": status.returncode},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
porcelain = status.stdout.strip()
|
|
53
|
+
passed = porcelain == ""
|
|
54
|
+
|
|
55
|
+
return self.result(
|
|
56
|
+
passed=passed,
|
|
57
|
+
duration_ms=duration_ms,
|
|
58
|
+
error=None if passed else f"Forbidden path modified: {raw_path}",
|
|
59
|
+
details="clean" if passed else porcelain,
|
|
60
|
+
metadata={"path": raw_path, "git_status": porcelain},
|
|
61
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
from urllib.error import HTTPError, URLError
|
|
5
|
+
from urllib.request import Request, urlopen
|
|
6
|
+
|
|
7
|
+
from donespec.checkers.base import Checker, timed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpCheckChecker(Checker):
|
|
11
|
+
type_name = "http_check"
|
|
12
|
+
|
|
13
|
+
def default_name(self) -> str:
|
|
14
|
+
return f"{self.config.get('method', 'GET')} {self.config['url']} returns expected status"
|
|
15
|
+
|
|
16
|
+
@timed
|
|
17
|
+
def run(self, started: float):
|
|
18
|
+
url = self.config["url"]
|
|
19
|
+
method = self.config.get("method", "GET")
|
|
20
|
+
expected_status = int(self.config.get("expected_status", 200))
|
|
21
|
+
timeout_seconds = float(self.config.get("timeout_seconds", 10))
|
|
22
|
+
headers = self.config.get("headers", {})
|
|
23
|
+
|
|
24
|
+
request = Request(url, method=method, headers=headers)
|
|
25
|
+
status: int | None = None
|
|
26
|
+
reason: str | None = None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with urlopen(request, timeout=timeout_seconds) as response:
|
|
30
|
+
status = int(response.status)
|
|
31
|
+
reason = response.reason
|
|
32
|
+
except HTTPError as exc:
|
|
33
|
+
status = int(exc.code)
|
|
34
|
+
reason = exc.reason
|
|
35
|
+
except URLError as exc:
|
|
36
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
37
|
+
return self.result(
|
|
38
|
+
passed=False,
|
|
39
|
+
duration_ms=duration_ms,
|
|
40
|
+
error=f"HTTP request failed: {exc.reason}",
|
|
41
|
+
metadata={"url": url, "method": method, "expected_status": expected_status},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
45
|
+
passed = status == expected_status
|
|
46
|
+
return self.result(
|
|
47
|
+
passed=passed,
|
|
48
|
+
duration_ms=duration_ms,
|
|
49
|
+
error=None if passed else f"Expected HTTP {expected_status}, got {status}",
|
|
50
|
+
details=f"status={status}, reason={reason}",
|
|
51
|
+
metadata={
|
|
52
|
+
"url": url,
|
|
53
|
+
"method": method,
|
|
54
|
+
"status": status,
|
|
55
|
+
"reason": reason,
|
|
56
|
+
"expected_status": expected_status,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
|
|
6
|
+
from donespec.checkers.base import Checker, timed
|
|
7
|
+
from donespec.pathing import resolve_under_root
|
|
8
|
+
|
|
9
|
+
_FLAG_MAP = {
|
|
10
|
+
"IGNORECASE": re.IGNORECASE,
|
|
11
|
+
"MULTILINE": re.MULTILINE,
|
|
12
|
+
"DOTALL": re.DOTALL,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compile_flags(raw_flags: list[str] | None) -> re.RegexFlag:
|
|
17
|
+
flags = re.NOFLAG
|
|
18
|
+
for raw_flag in raw_flags or []:
|
|
19
|
+
flags |= _FLAG_MAP[raw_flag]
|
|
20
|
+
return flags
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RegexInFileChecker(Checker):
|
|
24
|
+
type_name = "regex_in_file"
|
|
25
|
+
|
|
26
|
+
def default_name(self) -> str:
|
|
27
|
+
return f"{self.config['pattern']} exists in {self.config['path']}"
|
|
28
|
+
|
|
29
|
+
@timed
|
|
30
|
+
def run(self, started: float):
|
|
31
|
+
raw_path = self.config["path"]
|
|
32
|
+
pattern = self.config["pattern"]
|
|
33
|
+
target = resolve_under_root(self.context.root_dir, raw_path)
|
|
34
|
+
|
|
35
|
+
if not target.exists():
|
|
36
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
37
|
+
return self.result(
|
|
38
|
+
passed=False,
|
|
39
|
+
duration_ms=duration_ms,
|
|
40
|
+
error=f"File does not exist: {raw_path}",
|
|
41
|
+
metadata={"path": raw_path, "pattern": pattern},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
match = re.search(pattern, content, flags=compile_flags(self.config.get("flags")))
|
|
46
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
47
|
+
|
|
48
|
+
return self.result(
|
|
49
|
+
passed=match is not None,
|
|
50
|
+
duration_ms=duration_ms,
|
|
51
|
+
error=None if match else f"Pattern not found: {pattern}",
|
|
52
|
+
details=f"file={raw_path}",
|
|
53
|
+
metadata={
|
|
54
|
+
"path": raw_path,
|
|
55
|
+
"pattern": pattern,
|
|
56
|
+
"match_start": match.start() if match else None,
|
|
57
|
+
"match_end": match.end() if match else None,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RegexAbsentChecker(Checker):
|
|
63
|
+
type_name = "regex_absent"
|
|
64
|
+
|
|
65
|
+
def default_name(self) -> str:
|
|
66
|
+
return f"{self.config['pattern']} absent from {self.config['path']}"
|
|
67
|
+
|
|
68
|
+
@timed
|
|
69
|
+
def run(self, started: float):
|
|
70
|
+
raw_path = self.config["path"]
|
|
71
|
+
pattern = self.config["pattern"]
|
|
72
|
+
target = resolve_under_root(self.context.root_dir, raw_path)
|
|
73
|
+
|
|
74
|
+
if not target.exists():
|
|
75
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
76
|
+
return self.result(
|
|
77
|
+
passed=False,
|
|
78
|
+
duration_ms=duration_ms,
|
|
79
|
+
error=f"File does not exist: {raw_path}",
|
|
80
|
+
metadata={"path": raw_path, "pattern": pattern},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
84
|
+
match = re.search(pattern, content, flags=compile_flags(self.config.get("flags")))
|
|
85
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
86
|
+
passed = match is None
|
|
87
|
+
|
|
88
|
+
return self.result(
|
|
89
|
+
passed=passed,
|
|
90
|
+
duration_ms=duration_ms,
|
|
91
|
+
error=None if passed else f"Forbidden pattern found: {pattern}",
|
|
92
|
+
details=f"file={raw_path}",
|
|
93
|
+
metadata={
|
|
94
|
+
"path": raw_path,
|
|
95
|
+
"pattern": pattern,
|
|
96
|
+
"match_start": match.start() if match else None,
|
|
97
|
+
"match_end": match.end() if match else None,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from donespec.checkers.base import Checker
|
|
4
|
+
from donespec.checkers.command import CommandChecker
|
|
5
|
+
from donespec.checkers.file_exists import FileExistsChecker
|
|
6
|
+
from donespec.checkers.git import FileNotModifiedChecker
|
|
7
|
+
from donespec.checkers.http import HttpCheckChecker
|
|
8
|
+
from donespec.checkers.regex import RegexAbsentChecker, RegexInFileChecker
|
|
9
|
+
|
|
10
|
+
CHECKERS: dict[str, type[Checker]] = {
|
|
11
|
+
CommandChecker.type_name: CommandChecker,
|
|
12
|
+
FileExistsChecker.type_name: FileExistsChecker,
|
|
13
|
+
RegexInFileChecker.type_name: RegexInFileChecker,
|
|
14
|
+
RegexAbsentChecker.type_name: RegexAbsentChecker,
|
|
15
|
+
FileNotModifiedChecker.type_name: FileNotModifiedChecker,
|
|
16
|
+
HttpCheckChecker.type_name: HttpCheckChecker,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_checker(type_name: str) -> type[Checker]:
|
|
21
|
+
try:
|
|
22
|
+
return CHECKERS[type_name]
|
|
23
|
+
except KeyError as exc:
|
|
24
|
+
available = ", ".join(sorted(CHECKERS))
|
|
25
|
+
msg = f"Unknown checker type: {type_name}. Available: {available}"
|
|
26
|
+
raise ValueError(msg) from exc
|
donespec/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from donespec import __version__
|
|
11
|
+
from donespec.engine import validate_payload
|
|
12
|
+
from donespec.exceptions import SpecValidationError
|
|
13
|
+
from donespec.loader import load_spec
|
|
14
|
+
from donespec.output import error_to_json, print_human_report, report_to_json
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="donespec",
|
|
18
|
+
help="Deterministic validation for AI agent task completion.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
console = Console(stderr=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _version_callback(value: bool) -> None:
|
|
25
|
+
if value:
|
|
26
|
+
console.print(f"donespec {__version__}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def main(
|
|
32
|
+
version: Annotated[
|
|
33
|
+
bool,
|
|
34
|
+
typer.Option("--version", callback=_version_callback, help="Show version and exit."),
|
|
35
|
+
] = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
_ = version
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def validate(
|
|
42
|
+
spec: Annotated[Path, typer.Argument(help="Path to done.json")],
|
|
43
|
+
json_output: Annotated[
|
|
44
|
+
bool,
|
|
45
|
+
typer.Option("--json", help="Emit machine-readable JSON output."),
|
|
46
|
+
] = False,
|
|
47
|
+
root: Annotated[
|
|
48
|
+
Path | None,
|
|
49
|
+
typer.Option("--root", help="Project root. Defaults to the spec file directory."),
|
|
50
|
+
] = None,
|
|
51
|
+
fail_fast: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
typer.Option("--fail-fast", help="Stop after the first failed check."),
|
|
54
|
+
] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Validate a DoneSpec task file."""
|
|
57
|
+
spec_path = spec.resolve()
|
|
58
|
+
root_dir = root.resolve() if root else spec_path.parent.resolve()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
payload = load_spec(spec_path)
|
|
62
|
+
report = validate_payload(
|
|
63
|
+
payload,
|
|
64
|
+
spec_path=spec_path,
|
|
65
|
+
root_dir=root_dir,
|
|
66
|
+
fail_fast=fail_fast,
|
|
67
|
+
)
|
|
68
|
+
except SpecValidationError as exc:
|
|
69
|
+
if json_output:
|
|
70
|
+
sys.stdout.write(error_to_json(str(exc)) + "\n")
|
|
71
|
+
else:
|
|
72
|
+
console.print(f"[bold red]Spec error:[/bold red] {exc}")
|
|
73
|
+
raise typer.Exit(code=2) from exc
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if json_output:
|
|
76
|
+
sys.stdout.write(error_to_json(str(exc)) + "\n")
|
|
77
|
+
else:
|
|
78
|
+
console.print(f"[bold red]Runtime error:[/bold red] {exc}")
|
|
79
|
+
raise typer.Exit(code=2) from exc
|
|
80
|
+
|
|
81
|
+
if json_output:
|
|
82
|
+
sys.stdout.write(report_to_json(report) + "\n")
|
|
83
|
+
else:
|
|
84
|
+
print_human_report(report, console=console)
|
|
85
|
+
|
|
86
|
+
raise typer.Exit(code=report.exit_code)
|
donespec/engine.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from donespec.checkers import get_checker
|
|
8
|
+
from donespec.models import CheckGroup, CheckResult, ValidationContext, ValidationReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_check(config: dict[str, Any], group: CheckGroup, context: ValidationContext) -> CheckResult:
|
|
12
|
+
checker_class = get_checker(config["type"])
|
|
13
|
+
checker = checker_class(config=config, group=group, context=context)
|
|
14
|
+
return checker.run()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_payload(
|
|
18
|
+
payload: dict[str, Any], *, spec_path: Path, root_dir: Path, fail_fast: bool = False
|
|
19
|
+
) -> ValidationReport:
|
|
20
|
+
started = perf_counter()
|
|
21
|
+
context = ValidationContext(root_dir=root_dir, spec_path=spec_path)
|
|
22
|
+
results: list[CheckResult] = []
|
|
23
|
+
|
|
24
|
+
for group in ("must_pass", "must_not"):
|
|
25
|
+
for config in payload.get(group, []):
|
|
26
|
+
result = run_check(config=config, group=group, context=context) # type: ignore[arg-type]
|
|
27
|
+
results.append(result)
|
|
28
|
+
if fail_fast and not result.passed:
|
|
29
|
+
break
|
|
30
|
+
if fail_fast and results and not results[-1].passed:
|
|
31
|
+
break
|
|
32
|
+
|
|
33
|
+
duration_ms = (perf_counter() - started) * 1000
|
|
34
|
+
failed_checks = sum(1 for result in results if not result.passed)
|
|
35
|
+
|
|
36
|
+
return ValidationReport(
|
|
37
|
+
version=payload["version"],
|
|
38
|
+
task_id=payload["task_id"],
|
|
39
|
+
passed=failed_checks == 0,
|
|
40
|
+
total_checks=len(results),
|
|
41
|
+
failed_checks=failed_checks,
|
|
42
|
+
duration_ms=duration_ms,
|
|
43
|
+
results=results,
|
|
44
|
+
)
|
donespec/exceptions.py
ADDED
donespec/loader.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from donespec.exceptions import SpecValidationError
|
|
8
|
+
from donespec.schema import validate_spec_payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
12
|
+
if not path.exists():
|
|
13
|
+
raise SpecValidationError(f"Spec file not found: {path}")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
17
|
+
except json.JSONDecodeError as exc:
|
|
18
|
+
raise SpecValidationError(f"Invalid JSON in {path}: {exc}") from exc
|
|
19
|
+
|
|
20
|
+
if not isinstance(payload, dict):
|
|
21
|
+
raise SpecValidationError("Invalid done.json: root value must be an object")
|
|
22
|
+
|
|
23
|
+
validate_spec_payload(payload)
|
|
24
|
+
payload.setdefault("must_pass", [])
|
|
25
|
+
payload.setdefault("must_not", [])
|
|
26
|
+
return payload
|
donespec/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
CheckGroup = Literal["must_pass", "must_not"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationContext(BaseModel):
|
|
12
|
+
"""Runtime context shared by every checker."""
|
|
13
|
+
|
|
14
|
+
root_dir: Path
|
|
15
|
+
spec_path: Path
|
|
16
|
+
|
|
17
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckResult(BaseModel):
|
|
21
|
+
"""Structured result returned by every checker."""
|
|
22
|
+
|
|
23
|
+
id: str | None = None
|
|
24
|
+
name: str
|
|
25
|
+
type: str
|
|
26
|
+
group: CheckGroup
|
|
27
|
+
passed: bool
|
|
28
|
+
duration_ms: float = Field(ge=0)
|
|
29
|
+
error: str | None = None
|
|
30
|
+
details: str | None = None
|
|
31
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationReport(BaseModel):
|
|
35
|
+
"""Complete validation report emitted by the engine."""
|
|
36
|
+
|
|
37
|
+
version: str
|
|
38
|
+
task_id: str
|
|
39
|
+
passed: bool
|
|
40
|
+
total_checks: int
|
|
41
|
+
failed_checks: int
|
|
42
|
+
duration_ms: float = Field(ge=0)
|
|
43
|
+
results: list[CheckResult]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def exit_code(self) -> int:
|
|
47
|
+
return 0 if self.passed else 1
|
donespec/output.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from donespec.models import ValidationReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def report_to_json(report: ValidationReport) -> str:
|
|
13
|
+
return json.dumps(report.model_dump(mode="json"), indent=2, sort_keys=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _symbol(passed: bool) -> str:
|
|
17
|
+
return "✓" if passed else "✗"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _style(passed: bool) -> str:
|
|
21
|
+
return "green" if passed else "red"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_human_report(report: ValidationReport, console: Console | None = None) -> None:
|
|
25
|
+
console = console or Console()
|
|
26
|
+
console.print(f"DoneSpec validation: [bold]{report.task_id}[/bold]")
|
|
27
|
+
console.print()
|
|
28
|
+
|
|
29
|
+
for result in report.results:
|
|
30
|
+
line = Text()
|
|
31
|
+
line.append(_symbol(result.passed), style=f"bold {_style(result.passed)}")
|
|
32
|
+
line.append(" ")
|
|
33
|
+
line.append(result.name)
|
|
34
|
+
line.append(f" ({result.duration_ms:.1f}ms)", style="dim")
|
|
35
|
+
console.print(line)
|
|
36
|
+
if not result.passed and result.error:
|
|
37
|
+
console.print(f" [red]{result.error}[/red]")
|
|
38
|
+
|
|
39
|
+
console.print()
|
|
40
|
+
if report.passed:
|
|
41
|
+
console.print(
|
|
42
|
+
f"[bold green]Validation passed.[/bold green] {report.total_checks} checks passed."
|
|
43
|
+
)
|
|
44
|
+
console.print("Exit code: 0", style="dim")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
plural = "check" if report.failed_checks == 1 else "checks"
|
|
48
|
+
console.print("[bold red]Validation failed.[/bold red]")
|
|
49
|
+
console.print(f"{report.failed_checks} {plural} failed.")
|
|
50
|
+
console.print("Exit code: 1", style="dim")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def error_to_json(error: str) -> str:
|
|
54
|
+
payload: dict[str, Any] = {"passed": False, "error": error}
|
|
55
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|