donecheck 0.1.8__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.
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: donecheck
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: Your coding agent says it is done. Make it prove it.
|
|
5
|
+
Author: Atharva Maik
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AtharvaMaik/donecheck
|
|
8
|
+
Project-URL: Repository, https://github.com/AtharvaMaik/donecheck
|
|
9
|
+
Project-URL: Issues, https://github.com/AtharvaMaik/donecheck/issues
|
|
10
|
+
Project-URL: GitHub Marketplace, https://github.com/marketplace/actions/donecheck
|
|
11
|
+
Keywords: ai,agents,codex,claude-code,code-review,verification
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# DoneCheck: proof-of-done for AI coding agents
|
|
24
|
+
|
|
25
|
+
[](https://github.com/AtharvaMaik/donecheck/actions/workflows/ci.yml)
|
|
26
|
+
[](https://github.com/AtharvaMaik/donecheck/releases)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
Your AI coding agent says it is done. Make it prove it.
|
|
30
|
+
|
|
31
|
+
Works with Codex, Claude Code, Cursor, OpenCode, local CLIs, and GitHub Actions.
|
|
32
|
+
|
|
33
|
+
`donecheck` is a zero-dependency proof-of-done gate for AI-assisted code changes. It scans the changed files, runs the verification command you choose, and writes a small `DONECHECK.md` receipt before anyone claims the work is finished.
|
|
34
|
+
|
|
35
|
+
Star it if you want a tiny AI coding agent verification gate you can drop into any repo.
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python donecheck.py --cmd "pytest -q"
|
|
41
|
+
cat DONECHECK.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If there are no files and no command, it fails. No evidence, no "done".
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
Add DoneCheck to a repo using AI coding agents:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install donecheck
|
|
52
|
+
donecheck --init --cmd "pytest -q"
|
|
53
|
+
donecheck --agent-prompt --cmd "pytest -q"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then tell Codex, Claude Code, Cursor, or any agent:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
Before claiming done, run donecheck and fix anything it reports.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What It Catches
|
|
63
|
+
|
|
64
|
+
- unfinished markers and placeholder phrases in changed files
|
|
65
|
+
- narrow verification that does not mention changed code paths
|
|
66
|
+
- proof files that only say tests passed without output, exit code, and timestamp
|
|
67
|
+
- stale DoneCheck receipts when files, commands, or base inputs change
|
|
68
|
+
- skipped verification as `SKIPPED`, not a quiet pass
|
|
69
|
+
- swallowed Python exceptions and empty JavaScript catch blocks
|
|
70
|
+
- accidental literal secrets
|
|
71
|
+
- unsafe `eval` / `exec`
|
|
72
|
+
- failed verification commands and missing evidence when nothing was checked
|
|
73
|
+
|
|
74
|
+
## 20 Second Demo
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git init demo && cd demo
|
|
78
|
+
curl -O https://raw.githubusercontent.com/AtharvaMaik/donecheck/main/donecheck.py
|
|
79
|
+
printf 'def charge_card():\n # TODO wire Stripe later\n return True\n' > app.py
|
|
80
|
+
git add app.py
|
|
81
|
+
python donecheck.py --all
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Output:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
DoneCheck: FAIL
|
|
88
|
+
- unfinished_marker app.py:2 # TODO wire Stripe later
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The full receipt is in `DONECHECK.md`. Fix the file, then run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python donecheck.py --all --cmd "python -m py_compile app.py"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Now the receipt says `PASS` and records the command output.
|
|
98
|
+
|
|
99
|
+
If verification cannot run, say so explicitly:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python donecheck.py --skip-reason "missing DATABASE_URL"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
That writes a `SKIPPED` receipt and exits non-zero.
|
|
106
|
+
|
|
107
|
+
## Use It Anywhere
|
|
108
|
+
|
|
109
|
+
| Place | Command |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| Local repo | `python donecheck.py --cmd "pytest -q"` |
|
|
112
|
+
| Installed CLI | `pipx install donecheck` |
|
|
113
|
+
| Claude Code / Codex / Cursor | Tell the agent to run DoneCheck before claiming done |
|
|
114
|
+
| GitHub Actions | `uses: AtharvaMaik/donecheck@v0.1.8` |
|
|
115
|
+
|
|
116
|
+
To create the GitHub Action workflow in a repo:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
donecheck --init --cmd "pytest -q"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## GitHub Action
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
name: donecheck
|
|
126
|
+
on: [pull_request]
|
|
127
|
+
|
|
128
|
+
jobs:
|
|
129
|
+
donecheck:
|
|
130
|
+
runs-on: ubuntu-latest
|
|
131
|
+
steps:
|
|
132
|
+
- uses: actions/checkout@v4
|
|
133
|
+
with:
|
|
134
|
+
fetch-depth: 0
|
|
135
|
+
- uses: AtharvaMaik/donecheck@v0.1.8
|
|
136
|
+
with:
|
|
137
|
+
command: pytest -q
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
On pull requests, the action scans the PR diff against the base branch, emits GitHub error annotations for findings, and adds the full receipt to the Actions step summary. Outside pull requests, pass `args: --all` to scan the whole repo.
|
|
141
|
+
|
|
142
|
+
## Agent Prompt
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
Before claiming done, run:
|
|
146
|
+
python donecheck.py --cmd "<project test command>"
|
|
147
|
+
|
|
148
|
+
If it fails, fix the work and rerun it. Include the DONECHECK.md status in your final answer.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
There is also a drop-in skill at `skills/donecheck/SKILL.md`.
|
|
152
|
+
|
|
153
|
+
## Publish To PyPI
|
|
154
|
+
|
|
155
|
+
The repo now includes `.github/workflows/publish-pypi.yml`, which builds on every GitHub release and publishes with PyPI trusted publishing.
|
|
156
|
+
|
|
157
|
+
Before the first release, add this GitHub repo as a trusted publisher for the `donecheck` project on PyPI. After that, publishing is just: create a GitHub release.
|
|
158
|
+
|
|
159
|
+
## Why It Exists
|
|
160
|
+
|
|
161
|
+
AI agents are good at sounding finished. DoneCheck makes them leave evidence:
|
|
162
|
+
|
|
163
|
+
- changed files
|
|
164
|
+
- findings
|
|
165
|
+
- commands run
|
|
166
|
+
- exit codes
|
|
167
|
+
- recent command output
|
|
168
|
+
|
|
169
|
+
It is not a full linter, security scanner, or test framework. It is the cheap first gate that catches obvious AI-code misses before a human review, CI system, or hosted review bot spends time on them.
|
|
170
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
donecheck.py,sha256=ikQ2aDt3g4fnDzIO-QpVIsNG-ZF-2f4kVwTrwwIQ6GU,19131
|
|
2
|
+
donecheck-0.1.8.dist-info/licenses/LICENSE,sha256=TvH1qPRT2DZu6H7KH5OrJyzMaQuggdIPchxUg71ZGjo,1069
|
|
3
|
+
donecheck-0.1.8.dist-info/METADATA,sha256=KrjAoL2os8mn_gUaFF8pUWox9QVVGMsBday1TdV2nnk,5629
|
|
4
|
+
donecheck-0.1.8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
donecheck-0.1.8.dist-info/entry_points.txt,sha256=3cl6Vm4w7690QWKk-5PD-d53w49xPKi5C9oEaXqdTy0,45
|
|
6
|
+
donecheck-0.1.8.dist-info/top_level.txt,sha256=OSZ8IbJMvMxWBbmTEmllcq6pkTxI-XnHLgR3mU2DIBE,10
|
|
7
|
+
donecheck-0.1.8.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atharva Maik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
donecheck
|
donecheck.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""DoneCheck: make coding agents prove "done" with local evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import fnmatch
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_EXCLUDES = (
|
|
18
|
+
".git/*",
|
|
19
|
+
".venv/*",
|
|
20
|
+
"venv/*",
|
|
21
|
+
"node_modules/*",
|
|
22
|
+
"dist/*",
|
|
23
|
+
"build/*",
|
|
24
|
+
"__pycache__/*",
|
|
25
|
+
"*.lock",
|
|
26
|
+
"DONECHECK.md",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
VERSION = "0.1.8"
|
|
30
|
+
ACTION_REF = "v0.1.8"
|
|
31
|
+
|
|
32
|
+
UNFINISHED_WORDS = ("TO" + "DO", "FIX" + "ME", "X" * 3, "HA" + "CK")
|
|
33
|
+
UNFINISHED_PHRASES = ("not " + "implemented", "coming " + "soon", "st" + "ub")
|
|
34
|
+
UNFINISHED_RE = r"\b(" + "|".join(UNFINISHED_WORDS) + r")\b|" + "|".join(re.escape(p) for p in UNFINISHED_PHRASES)
|
|
35
|
+
|
|
36
|
+
RULES = (
|
|
37
|
+
("unfinished_marker", re.compile(UNFINISHED_RE, re.I)),
|
|
38
|
+
("python_silent_failure", re.compile(r"except\b[^\n:]*:\s*(?:\n\s*)?(pass|return None)\b", re.I)),
|
|
39
|
+
("js_silent_failure", re.compile(r"catch\s*\([^)]*\)\s*{\s*(?:/\*.*?\*/\s*)?}", re.I | re.S)),
|
|
40
|
+
("unsafe_eval", re.compile(r"\b(eval|exec)\s*\(", re.I)),
|
|
41
|
+
("secret_literal", re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"][^'\"\n]{8,}['\"]")),
|
|
42
|
+
)
|
|
43
|
+
CODE_SUFFIXES = {
|
|
44
|
+
".c",
|
|
45
|
+
".cfg",
|
|
46
|
+
".cpp",
|
|
47
|
+
".cs",
|
|
48
|
+
".css",
|
|
49
|
+
".go",
|
|
50
|
+
".html",
|
|
51
|
+
".ini",
|
|
52
|
+
".java",
|
|
53
|
+
".js",
|
|
54
|
+
".json",
|
|
55
|
+
".jsx",
|
|
56
|
+
".kt",
|
|
57
|
+
".php",
|
|
58
|
+
".ps1",
|
|
59
|
+
".py",
|
|
60
|
+
".rb",
|
|
61
|
+
".rs",
|
|
62
|
+
".sh",
|
|
63
|
+
".sql",
|
|
64
|
+
".swift",
|
|
65
|
+
".toml",
|
|
66
|
+
".ts",
|
|
67
|
+
".tsx",
|
|
68
|
+
".yaml",
|
|
69
|
+
".yml",
|
|
70
|
+
}
|
|
71
|
+
BROAD_COMMAND_RE = re.compile(
|
|
72
|
+
r"\b(pytest|unittest|tox|nox|cargo\s+test|go\s+test|dotnet\s+test|mvn\s+test|gradle\b.*\btest|npm\s+(?:run\s+)?test|pnpm\s+test|yarn\s+test)\b",
|
|
73
|
+
re.I,
|
|
74
|
+
)
|
|
75
|
+
PROOF_FILE_RE = re.compile(r"(donecheck|proof|verification|test[-_]?results)", re.I)
|
|
76
|
+
PROOF_EXTENSIONS = {".md", ".markdown", ".txt"}
|
|
77
|
+
THIN_PROOF_RE = re.compile(r"\b(all\s+)?tests?\s+pass(?:ed|es)?\b", re.I)
|
|
78
|
+
STALE_INPUT_PATTERNS = (
|
|
79
|
+
"*.lock",
|
|
80
|
+
"package-lock.json",
|
|
81
|
+
"pnpm-lock.yaml",
|
|
82
|
+
"yarn.lock",
|
|
83
|
+
"poetry.lock",
|
|
84
|
+
"Pipfile.lock",
|
|
85
|
+
"requirements*.txt",
|
|
86
|
+
"pyproject.toml",
|
|
87
|
+
"package.json",
|
|
88
|
+
"Cargo.toml",
|
|
89
|
+
"go.mod",
|
|
90
|
+
"go.sum",
|
|
91
|
+
"Gemfile.lock",
|
|
92
|
+
"migrations/*",
|
|
93
|
+
"migrations/**/*",
|
|
94
|
+
"db/migrations/*",
|
|
95
|
+
"db/migrations/**/*",
|
|
96
|
+
".env.example",
|
|
97
|
+
".env.sample",
|
|
98
|
+
"schema.sql",
|
|
99
|
+
"*.schema.json",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass
|
|
104
|
+
class Finding:
|
|
105
|
+
rule: str
|
|
106
|
+
path: str
|
|
107
|
+
line: int
|
|
108
|
+
text: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclasses.dataclass
|
|
112
|
+
class CommandResult:
|
|
113
|
+
command: str
|
|
114
|
+
code: int
|
|
115
|
+
output: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run(command: str) -> CommandResult:
|
|
119
|
+
proc = subprocess.run(command, shell=True, text=True, capture_output=True)
|
|
120
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
121
|
+
return CommandResult(command, proc.returncode, output)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def git_output(args: list[str]) -> str:
|
|
125
|
+
proc = subprocess.run(["git", *args], text=True, capture_output=True)
|
|
126
|
+
if proc.returncode != 0:
|
|
127
|
+
raise SystemExit(proc.stderr.strip() or "not a git repository")
|
|
128
|
+
return proc.stdout.strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def current_commit() -> str:
|
|
132
|
+
proc = subprocess.run(["git", "rev-parse", "--short", "HEAD"], text=True, capture_output=True)
|
|
133
|
+
return proc.stdout.strip() if proc.returncode == 0 else "no-commit-yet"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def merge_base(base_ref: str) -> str:
|
|
137
|
+
return git_output(["merge-base", base_ref, "HEAD"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def changed_files(base_ref: str | None = None, base_commit: str | None = None) -> list[Path]:
|
|
141
|
+
if base_ref:
|
|
142
|
+
base_commit = base_commit or merge_base(base_ref)
|
|
143
|
+
return [Path(line) for line in git_output(["diff", "--name-only", f"{base_commit}..HEAD"]).splitlines() if line]
|
|
144
|
+
|
|
145
|
+
names = set()
|
|
146
|
+
for args in (["diff", "--name-only"], ["diff", "--cached", "--name-only"], ["ls-files", "--others", "--exclude-standard"]):
|
|
147
|
+
names.update(line for line in git_output(args).splitlines() if line)
|
|
148
|
+
return [Path(name) for name in sorted(names)]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def excluded(path: Path, patterns: tuple[str, ...]) -> bool:
|
|
152
|
+
value = path.as_posix()
|
|
153
|
+
return any(fnmatch.fnmatch(value, pattern) for pattern in patterns)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def strip_markdown_fences(text: str) -> str:
|
|
157
|
+
lines = []
|
|
158
|
+
fenced = False
|
|
159
|
+
for line in text.splitlines():
|
|
160
|
+
if line.lstrip().startswith("```"):
|
|
161
|
+
fenced = not fenced
|
|
162
|
+
lines.append("")
|
|
163
|
+
elif fenced:
|
|
164
|
+
lines.append("")
|
|
165
|
+
else:
|
|
166
|
+
lines.append(line)
|
|
167
|
+
return "\n".join(lines)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scan_file(path: Path) -> list[Finding]:
|
|
171
|
+
try:
|
|
172
|
+
text = path.read_text(encoding="utf-8")
|
|
173
|
+
except (UnicodeDecodeError, OSError):
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
if path.suffix.lower() in {".md", ".markdown"}:
|
|
177
|
+
text = strip_markdown_fences(text)
|
|
178
|
+
|
|
179
|
+
findings: list[Finding] = []
|
|
180
|
+
lines = text.splitlines()
|
|
181
|
+
for rule, pattern in RULES:
|
|
182
|
+
for match in pattern.finditer(text):
|
|
183
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
184
|
+
snippet = lines[line - 1].strip() if line <= len(lines) else match.group(0).strip()
|
|
185
|
+
findings.append(Finding(rule, path.as_posix(), line, snippet[:160]))
|
|
186
|
+
return findings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def scan(paths: list[Path], excludes: tuple[str, ...]) -> list[Finding]:
|
|
190
|
+
findings: list[Finding] = []
|
|
191
|
+
for path in paths:
|
|
192
|
+
if path.is_file() and not excluded(path, excludes):
|
|
193
|
+
findings.extend(scan_file(path))
|
|
194
|
+
return findings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def proof_like(path: Path) -> bool:
|
|
198
|
+
return path.suffix.lower() in PROOF_EXTENSIONS and bool(PROOF_FILE_RE.search(path.name))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_receipt_field(text: str, field: str) -> str | None:
|
|
202
|
+
pattern = re.compile(rf"^-\s*{re.escape(field)}:\s*`?([^`\n]+)`?", re.I | re.M)
|
|
203
|
+
match = pattern.search(text)
|
|
204
|
+
return match.group(1).strip() if match else None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def proof_file_findings(paths: list[Path], current_hash: str, base_commit: str | None = None) -> list[Finding]:
|
|
208
|
+
findings: list[Finding] = []
|
|
209
|
+
for path in paths:
|
|
210
|
+
if not proof_like(path) or not path.is_file():
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
text = path.read_text(encoding="utf-8")
|
|
214
|
+
except (UnicodeDecodeError, OSError):
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
has_exit = re.search(r"\bexit code\b", text, re.I)
|
|
218
|
+
has_output = re.search(r"\boutput\b|```", text, re.I)
|
|
219
|
+
has_time = re.search(r"\bgenerated\b|\btimestamp\b|\d{4}-\d{2}-\d{2}", text, re.I)
|
|
220
|
+
if THIN_PROOF_RE.search(text) and not (has_exit and has_output and has_time):
|
|
221
|
+
findings.append(
|
|
222
|
+
Finding(
|
|
223
|
+
"thin_proof_file",
|
|
224
|
+
path.as_posix(),
|
|
225
|
+
1,
|
|
226
|
+
"proof says tests passed without command output, exit code, and timestamp",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
if "# DoneCheck Receipt:" not in text:
|
|
232
|
+
continue
|
|
233
|
+
saved_hash = parse_receipt_field(text, "evidence hash")
|
|
234
|
+
if not saved_hash:
|
|
235
|
+
findings.append(Finding("stale_proof", path.as_posix(), 1, "receipt has no evidence hash; rerun verification"))
|
|
236
|
+
elif saved_hash != current_hash:
|
|
237
|
+
findings.append(Finding("stale_proof", path.as_posix(), 1, "evidence changed; rerun verification"))
|
|
238
|
+
saved_base = parse_receipt_field(text, "base commit")
|
|
239
|
+
if base_commit and saved_base and saved_base != base_commit:
|
|
240
|
+
findings.append(Finding("stale_proof", path.as_posix(), 1, "base commit changed; rerun verification"))
|
|
241
|
+
return findings
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def stale_input_files(files: list[Path]) -> list[Path]:
|
|
245
|
+
selected = {path for path in files}
|
|
246
|
+
try:
|
|
247
|
+
tracked = [Path(line) for line in git_output(["ls-files"]).splitlines() if line]
|
|
248
|
+
except SystemExit:
|
|
249
|
+
tracked = []
|
|
250
|
+
for path in tracked:
|
|
251
|
+
value = path.as_posix()
|
|
252
|
+
if any(fnmatch.fnmatch(value, pattern) for pattern in STALE_INPUT_PATTERNS):
|
|
253
|
+
selected.add(path)
|
|
254
|
+
return sorted(selected, key=lambda path: path.as_posix())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def evidence_hash(files: list[Path], commands: list[CommandResult], base_commit: str | None = None) -> str:
|
|
258
|
+
digest = hashlib.sha256()
|
|
259
|
+
if base_commit:
|
|
260
|
+
digest.update(f"base:{base_commit}\0".encode())
|
|
261
|
+
for command in commands:
|
|
262
|
+
digest.update(f"cmd:{command.command}\0".encode())
|
|
263
|
+
for path in sorted(files, key=lambda item: item.as_posix()):
|
|
264
|
+
digest.update(f"path:{path.as_posix()}\0".encode())
|
|
265
|
+
try:
|
|
266
|
+
digest.update(path.read_bytes())
|
|
267
|
+
except OSError:
|
|
268
|
+
digest.update(b"<missing>")
|
|
269
|
+
digest.update(b"\0")
|
|
270
|
+
return digest.hexdigest()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def broad_command(command: str) -> bool:
|
|
274
|
+
return bool(BROAD_COMMAND_RE.search(" ".join(command.split())))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def needs_command_evidence(path: Path) -> bool:
|
|
278
|
+
return path.suffix.lower() in CODE_SUFFIXES and not proof_like(path)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def command_mentions_path(command: CommandResult, path: Path) -> bool:
|
|
282
|
+
haystack = f"{command.command}\n{command.output}".replace("\\", "/").lower()
|
|
283
|
+
value = path.as_posix().lower()
|
|
284
|
+
return value in haystack or path.name.lower() in haystack
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def path_evidence_findings(commands: list[CommandResult], files: list[Path]) -> list[Finding]:
|
|
288
|
+
if not commands or any(broad_command(command.command) for command in commands):
|
|
289
|
+
return []
|
|
290
|
+
findings = []
|
|
291
|
+
for path in files:
|
|
292
|
+
if needs_command_evidence(path) and not any(command_mentions_path(command, path) for command in commands):
|
|
293
|
+
findings.append(
|
|
294
|
+
Finding(
|
|
295
|
+
"missing_path_evidence",
|
|
296
|
+
path.as_posix(),
|
|
297
|
+
0,
|
|
298
|
+
f"no verification command mentions changed path {path.as_posix()}",
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return findings
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def receipt(
|
|
305
|
+
findings: list[Finding],
|
|
306
|
+
commands: list[CommandResult],
|
|
307
|
+
files: list[Path],
|
|
308
|
+
elapsed: float,
|
|
309
|
+
*,
|
|
310
|
+
evidence_hash: str = "",
|
|
311
|
+
base_ref: str | None = None,
|
|
312
|
+
base_commit: str | None = None,
|
|
313
|
+
skip_reasons: list[str] | None = None,
|
|
314
|
+
status: str | None = None,
|
|
315
|
+
) -> str:
|
|
316
|
+
skip_reasons = skip_reasons or []
|
|
317
|
+
sha = current_commit() if Path(".git").exists() else "no-git"
|
|
318
|
+
status = status or assess(findings, commands, files, skip_reasons)
|
|
319
|
+
now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
|
|
320
|
+
base_lines = [f"- base: `{base_ref}`", f"- base commit: `{base_commit}`"] if base_ref and base_commit else []
|
|
321
|
+
lines = [
|
|
322
|
+
f"# DoneCheck Receipt: {status}",
|
|
323
|
+
"",
|
|
324
|
+
f"- commit: `{sha}`",
|
|
325
|
+
f"- generated: `{now}`",
|
|
326
|
+
*base_lines,
|
|
327
|
+
f"- evidence hash: `{evidence_hash or 'unavailable'}`",
|
|
328
|
+
"- stale if: base commit, checked files, verification commands, dependency locks, migrations, or env contracts change",
|
|
329
|
+
f"- files checked: `{len(files)}`",
|
|
330
|
+
f"- findings: `{len(findings)}`",
|
|
331
|
+
f"- commands: `{len(commands)}`",
|
|
332
|
+
f"- skipped verification: `{len(skip_reasons)}`",
|
|
333
|
+
f"- elapsed: `{elapsed:.2f}s`",
|
|
334
|
+
"",
|
|
335
|
+
"## Findings",
|
|
336
|
+
"",
|
|
337
|
+
]
|
|
338
|
+
if findings:
|
|
339
|
+
lines += [f"- `{f.rule}` in `{f.path}:{f.line}`: {f.text}" for f in findings]
|
|
340
|
+
else:
|
|
341
|
+
lines.append("- none")
|
|
342
|
+
|
|
343
|
+
lines += ["", "## Commands", ""]
|
|
344
|
+
if commands:
|
|
345
|
+
for command in commands:
|
|
346
|
+
lines += [
|
|
347
|
+
f"### `{command.command}`",
|
|
348
|
+
"",
|
|
349
|
+
f"- exit code: `{command.code}`",
|
|
350
|
+
"",
|
|
351
|
+
"```text",
|
|
352
|
+
command.output[-4000:] or "(no output)",
|
|
353
|
+
"```",
|
|
354
|
+
"",
|
|
355
|
+
]
|
|
356
|
+
else:
|
|
357
|
+
lines.append("- none supplied")
|
|
358
|
+
|
|
359
|
+
lines += ["", "## Skipped Verification", ""]
|
|
360
|
+
if skip_reasons:
|
|
361
|
+
lines += [f"- {reason}" for reason in skip_reasons]
|
|
362
|
+
else:
|
|
363
|
+
lines.append("- none")
|
|
364
|
+
|
|
365
|
+
lines += ["", "## Files", ""]
|
|
366
|
+
lines += [f"- `{path.as_posix()}`" for path in files] or ["- none"]
|
|
367
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def proof_findings(findings: list[Finding], commands: list[CommandResult], files: list[Path]) -> list[Finding]:
|
|
371
|
+
if not findings and not commands and not files:
|
|
372
|
+
return [Finding("missing_evidence", "-", 0, "no files or commands checked")]
|
|
373
|
+
return findings + path_evidence_findings(commands, files)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def assess(
|
|
377
|
+
findings: list[Finding],
|
|
378
|
+
commands: list[CommandResult],
|
|
379
|
+
files: list[Path] | None = None,
|
|
380
|
+
skip_reasons: list[str] | None = None,
|
|
381
|
+
) -> str:
|
|
382
|
+
files = files or []
|
|
383
|
+
skip_reasons = skip_reasons or []
|
|
384
|
+
if proof_findings(findings, commands, files):
|
|
385
|
+
return "FAIL"
|
|
386
|
+
if any(command.code != 0 for command in commands):
|
|
387
|
+
return "FAIL"
|
|
388
|
+
if skip_reasons or (files and not commands):
|
|
389
|
+
return "SKIPPED"
|
|
390
|
+
return "PASS"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def summary(status: str, findings: list[Finding], commands: list[CommandResult], skip_reasons: list[str] | None = None) -> str:
|
|
394
|
+
skip_reasons = skip_reasons or []
|
|
395
|
+
lines = [f"DoneCheck: {status}"]
|
|
396
|
+
lines += [f"- {f.rule} {f.path}:{f.line} {f.text}" for f in findings]
|
|
397
|
+
lines += [f"- verification skipped: {reason}" for reason in skip_reasons]
|
|
398
|
+
for command in commands:
|
|
399
|
+
if command.code != 0:
|
|
400
|
+
lines.append(f"- command failed: {command.command}")
|
|
401
|
+
if command.output:
|
|
402
|
+
lines.append(" output:")
|
|
403
|
+
lines += [f" {line}" for line in command.output[-1200:].splitlines()]
|
|
404
|
+
return "\n".join(lines) + "\n"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def annotation_escape(value: str) -> str:
|
|
408
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def github_annotations(findings: list[Finding]) -> list[str]:
|
|
412
|
+
return [
|
|
413
|
+
f"::error file={annotation_escape(f.path)},line={f.line},title={annotation_escape(f.rule)}::{annotation_escape(f.text)}"
|
|
414
|
+
for f in findings
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def write_github_step_summary(body: str) -> None:
|
|
419
|
+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
420
|
+
if not summary_path:
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
with Path(summary_path).open("a", encoding="utf-8") as file:
|
|
424
|
+
file.write(body)
|
|
425
|
+
except OSError as error:
|
|
426
|
+
print(f"DoneCheck: could not write GitHub step summary: {error}", file=sys.stderr)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def action_workflow(command: str) -> str:
|
|
430
|
+
lines = [
|
|
431
|
+
"name: donecheck",
|
|
432
|
+
"on: [pull_request]",
|
|
433
|
+
"",
|
|
434
|
+
"jobs:",
|
|
435
|
+
" donecheck:",
|
|
436
|
+
" runs-on: ubuntu-latest",
|
|
437
|
+
" steps:",
|
|
438
|
+
" - uses: actions/checkout@v4",
|
|
439
|
+
" with:",
|
|
440
|
+
" fetch-depth: 0",
|
|
441
|
+
f" - uses: AtharvaMaik/donecheck@{ACTION_REF}",
|
|
442
|
+
]
|
|
443
|
+
command = " ".join(command.splitlines()).strip()
|
|
444
|
+
if command:
|
|
445
|
+
lines += [
|
|
446
|
+
" with:",
|
|
447
|
+
" command: >-",
|
|
448
|
+
f" {command}",
|
|
449
|
+
]
|
|
450
|
+
return "\n".join(lines) + "\n"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def init_action(command: str, workflow: Path = Path(".github/workflows/donecheck.yml")) -> int:
|
|
454
|
+
if workflow.exists():
|
|
455
|
+
print(f"DoneCheck: {workflow} already exists", file=sys.stderr)
|
|
456
|
+
return 1
|
|
457
|
+
workflow.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
workflow.write_text(action_workflow(command), encoding="utf-8")
|
|
459
|
+
print(f"DoneCheck: wrote {workflow}")
|
|
460
|
+
return 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
464
|
+
parser = argparse.ArgumentParser(description="Make coding agents prove done with local evidence.")
|
|
465
|
+
parser.add_argument("--version", action="version", version=f"donecheck {VERSION}")
|
|
466
|
+
parser.add_argument("--cmd", action="append", default=[], help="verification command to run, repeatable")
|
|
467
|
+
parser.add_argument("--write", default="DONECHECK.md", help="receipt path, or '-' for stdout")
|
|
468
|
+
parser.add_argument("--all", action="store_true", help="scan every tracked file instead of changed files")
|
|
469
|
+
parser.add_argument("--base", help="scan files changed since this git ref, for example origin/main")
|
|
470
|
+
parser.add_argument("--exclude", action="append", default=[], help="extra glob to skip")
|
|
471
|
+
parser.add_argument("--init", action="store_true", help="create .github/workflows/donecheck.yml")
|
|
472
|
+
parser.add_argument("--agent-prompt", action="store_true", help="print a copy-paste instruction for coding agents")
|
|
473
|
+
parser.add_argument("--skip-reason", action="append", default=[], help="record why verification could not run; exits non-zero")
|
|
474
|
+
parser.add_argument("--no-fail-on-findings", action="store_true", help="write receipt but exit 0 for findings")
|
|
475
|
+
return parser.parse_args(argv)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def main(argv: list[str] | None = None) -> int:
|
|
479
|
+
started = dt.datetime.now().timestamp()
|
|
480
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
481
|
+
if args.init:
|
|
482
|
+
return init_action(args.cmd[0] if args.cmd else "")
|
|
483
|
+
if args.agent_prompt:
|
|
484
|
+
command = args.cmd[0] if args.cmd else "<project test command>"
|
|
485
|
+
print(
|
|
486
|
+
f'Before claiming done, run:\ndonecheck --cmd "{command}"\n\n'
|
|
487
|
+
"If it fails, fix the work and rerun it. Include the DONECHECK.md status in your final answer."
|
|
488
|
+
)
|
|
489
|
+
return 0
|
|
490
|
+
|
|
491
|
+
base_commit = merge_base(args.base) if args.base else None
|
|
492
|
+
paths = [Path(p) for p in git_output(["ls-files"]).splitlines()] if args.all else changed_files(args.base, base_commit)
|
|
493
|
+
excludes = (*DEFAULT_EXCLUDES, *tuple(args.exclude))
|
|
494
|
+
findings = scan(paths, excludes)
|
|
495
|
+
commands = [run(command) for command in args.cmd]
|
|
496
|
+
skip_reasons = list(args.skip_reason)
|
|
497
|
+
if paths and not commands and not skip_reasons:
|
|
498
|
+
skip_reasons.append("no verification command supplied")
|
|
499
|
+
current_hash = evidence_hash(stale_input_files(paths), commands, base_commit)
|
|
500
|
+
receipt_path = None if args.write == "-" else Path(args.write)
|
|
501
|
+
proof_paths = [path for path in paths if not receipt_path or path.resolve() != receipt_path.resolve()]
|
|
502
|
+
findings += proof_file_findings(proof_paths, current_hash, base_commit)
|
|
503
|
+
findings = proof_findings(findings, commands, paths)
|
|
504
|
+
elapsed = dt.datetime.now().timestamp() - started
|
|
505
|
+
status = assess([] if args.no_fail_on_findings else findings, commands, paths, skip_reasons)
|
|
506
|
+
body = receipt(
|
|
507
|
+
findings,
|
|
508
|
+
commands,
|
|
509
|
+
paths,
|
|
510
|
+
elapsed,
|
|
511
|
+
evidence_hash=current_hash,
|
|
512
|
+
base_ref=args.base,
|
|
513
|
+
base_commit=base_commit,
|
|
514
|
+
skip_reasons=skip_reasons,
|
|
515
|
+
status=status,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if args.write == "-":
|
|
519
|
+
print(body, end="")
|
|
520
|
+
else:
|
|
521
|
+
Path(args.write).write_text(body, encoding="utf-8")
|
|
522
|
+
write_github_step_summary(body)
|
|
523
|
+
|
|
524
|
+
if args.write != "-":
|
|
525
|
+
print(summary(status, findings, commands, skip_reasons), end="")
|
|
526
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
527
|
+
print("\n".join(github_annotations(findings)))
|
|
528
|
+
return 0 if status == "PASS" else 1
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
raise SystemExit(main())
|