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 ADDED
@@ -0,0 +1,3 @@
1
+ """DoneSpec: deterministic validation for AI agent task completion."""
2
+
3
+ __version__ = "0.1.0"
donespec/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from donespec.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,3 @@
1
+ from donespec.checkers.registry import CHECKERS, get_checker
2
+
3
+ __all__ = ["CHECKERS", "get_checker"]
@@ -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
+ )
@@ -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
@@ -0,0 +1,6 @@
1
+ class DoneSpecError(Exception):
2
+ """Base DoneSpec exception."""
3
+
4
+
5
+ class SpecValidationError(DoneSpecError):
6
+ """Raised when done.json is invalid."""
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)