taskproof 0.3.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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/verify.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""Acceptance verification — the part that does not trust the worker.
|
|
2
|
+
|
|
3
|
+
Rules
|
|
4
|
+
-----
|
|
5
|
+
1. The agent's self-report is NEVER evidence. Only this module decides success.
|
|
6
|
+
2. The acceptance command comes from the registry, and is run by taskproof in
|
|
7
|
+
the project workdir after the adapter exits.
|
|
8
|
+
3. Artifact self-check: if the run claims to have changed files, the worktree is
|
|
9
|
+
inspected to confirm it. (One observed failure mode: an agent reported
|
|
10
|
+
success while the tree was untouched.)
|
|
11
|
+
4. Forbidden paths are checked AFTER the run — a violation fails the task even
|
|
12
|
+
if the agent reported success. The check does not rely on the git change list
|
|
13
|
+
alone: git cannot see inside `.git/` and omits every ignored path, so the
|
|
14
|
+
declared paths are fingerprinted before and after the run as well. The one
|
|
15
|
+
special case is a rule that targets the repository's `.git` directory:
|
|
16
|
+
`.git` state (HEAD, refs, and stash) is compared instead of its bookkeeping
|
|
17
|
+
files, so `git status` refreshing `.git/index` is not mistaken for a
|
|
18
|
+
forbidden change.
|
|
19
|
+
5. Python byte-code is never a breach, whatever the rule says: a directory
|
|
20
|
+
segment named `__pycache__`, or a path ending in `.pyc` / `.pyo`, is skipped
|
|
21
|
+
before any rule is applied. It is a byproduct of *running* the tool (an
|
|
22
|
+
`import`), not authored work, and is regenerated on demand — see
|
|
23
|
+
`is_python_bytecode`. The exemption is deliberately narrow and path-based.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Dict, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ForbiddenRule:
|
|
34
|
+
"""A parsed forbidden-path rule.
|
|
35
|
+
|
|
36
|
+
The registry stores rules as strings so old registries keep working. The
|
|
37
|
+
optional ``file:`` / ``git:`` / ``presence:`` prefixes select the signal;
|
|
38
|
+
an unprefixed rule is the historical ``file`` form.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
kind: str
|
|
42
|
+
path: str
|
|
43
|
+
raw: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class VerifyOutcome:
|
|
48
|
+
ran: bool
|
|
49
|
+
passed: bool
|
|
50
|
+
exit_code: Optional[int] = None
|
|
51
|
+
command: Optional[str] = None
|
|
52
|
+
output_tail: str = ""
|
|
53
|
+
violations: List[str] = field(default_factory=list)
|
|
54
|
+
files_changed: Optional[int] = None
|
|
55
|
+
note: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tail(text: str, lines: int) -> str:
|
|
59
|
+
"""Last `lines` lines of `text` (no trailing newline). Long output must
|
|
60
|
+
never be dumped wholesale into the ledger."""
|
|
61
|
+
if not text:
|
|
62
|
+
return ""
|
|
63
|
+
if not lines or lines <= 0:
|
|
64
|
+
return text
|
|
65
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_text(value) -> str:
|
|
69
|
+
if value is None:
|
|
70
|
+
return ""
|
|
71
|
+
if isinstance(value, bytes):
|
|
72
|
+
return value.decode("utf-8", "replace")
|
|
73
|
+
return str(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_acceptance(
|
|
77
|
+
workdir: str,
|
|
78
|
+
command: Optional[str],
|
|
79
|
+
*,
|
|
80
|
+
timeout: int = 1800,
|
|
81
|
+
env: Optional[dict] = None,
|
|
82
|
+
tail_lines: int = 40,
|
|
83
|
+
) -> VerifyOutcome:
|
|
84
|
+
"""Run the registry's acceptance command.
|
|
85
|
+
|
|
86
|
+
* `command` empty/None -> `ran=False, passed=True, note="no acceptance command"`.
|
|
87
|
+
Skipping is not a pass; the caller must surface that distinction.
|
|
88
|
+
* Output is truncated to the last `tail_lines` for the ledger.
|
|
89
|
+
* A timeout is a FAILURE, not an error: exit code is recorded as None and
|
|
90
|
+
`note` says so.
|
|
91
|
+
"""
|
|
92
|
+
if command is None or not str(command).strip():
|
|
93
|
+
# Skipping is NOT passing: ran=False lets the caller record SKIPPED
|
|
94
|
+
# rather than DONE even though `passed` is trivially true.
|
|
95
|
+
return VerifyOutcome(ran=False, passed=True, note="no acceptance command")
|
|
96
|
+
|
|
97
|
+
command = str(command)
|
|
98
|
+
# Registry acceptance commands are shell one-liners ("exit 0",
|
|
99
|
+
# "for i in $(seq 1 200); do ...; done", "npm run build && npm test") that
|
|
100
|
+
# need real shell features. We therefore choose the EXPLICIT shell string
|
|
101
|
+
# form, `/bin/sh -c <command>`, over `shlex.split`. This keeps argv a list
|
|
102
|
+
# (no `shell=True`), makes the use of a shell visible and auditable, and is
|
|
103
|
+
# required because shell builtins like `exit` are not executables.
|
|
104
|
+
argv = ["/bin/sh", "-c", command]
|
|
105
|
+
|
|
106
|
+
run_env = None
|
|
107
|
+
if env:
|
|
108
|
+
# Merge so the command still inherits PATH etc.
|
|
109
|
+
run_env = {**os.environ, **env}
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
proc = subprocess.run(
|
|
113
|
+
argv,
|
|
114
|
+
cwd=workdir,
|
|
115
|
+
env=run_env,
|
|
116
|
+
stdout=subprocess.PIPE,
|
|
117
|
+
stderr=subprocess.STDOUT,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
except subprocess.TimeoutExpired as exc:
|
|
122
|
+
return VerifyOutcome(
|
|
123
|
+
ran=True,
|
|
124
|
+
passed=False,
|
|
125
|
+
exit_code=None,
|
|
126
|
+
command=command,
|
|
127
|
+
output_tail=_tail(_as_text(exc.output), tail_lines),
|
|
128
|
+
note=f"acceptance timeout: command exceeded {timeout}s (treated as failure)",
|
|
129
|
+
)
|
|
130
|
+
except FileNotFoundError as exc:
|
|
131
|
+
return VerifyOutcome(
|
|
132
|
+
ran=True,
|
|
133
|
+
passed=False,
|
|
134
|
+
exit_code=None,
|
|
135
|
+
command=command,
|
|
136
|
+
note=f"acceptance command could not run: {exc}",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
output = proc.stdout or ""
|
|
140
|
+
passed = proc.returncode == 0
|
|
141
|
+
note = "" if passed else f"acceptance command exited {proc.returncode}"
|
|
142
|
+
return VerifyOutcome(
|
|
143
|
+
ran=True,
|
|
144
|
+
passed=passed,
|
|
145
|
+
exit_code=proc.returncode,
|
|
146
|
+
command=command,
|
|
147
|
+
output_tail=_tail(output, tail_lines),
|
|
148
|
+
note=note,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _git_porcelain(workdir: str, timeout=None) -> Optional[str]:
|
|
153
|
+
"""Read-only `git status --porcelain`; None when workdir is not a repo.
|
|
154
|
+
|
|
155
|
+
``timeout`` bounds the subprocess (seconds); ``None`` keeps the original
|
|
156
|
+
unbounded semantics, so every existing caller is unaffected. A timeout
|
|
157
|
+
raises ``subprocess.TimeoutExpired`` (a ``SubprocessError``) and surfaces as
|
|
158
|
+
``None`` exactly like the other probe failures.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
proc = subprocess.run(
|
|
162
|
+
["git", "status", "--porcelain"],
|
|
163
|
+
cwd=workdir,
|
|
164
|
+
stdout=subprocess.PIPE,
|
|
165
|
+
stderr=subprocess.DEVNULL,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=timeout,
|
|
168
|
+
)
|
|
169
|
+
except (OSError, subprocess.SubprocessError):
|
|
170
|
+
return None
|
|
171
|
+
if proc.returncode != 0:
|
|
172
|
+
return None
|
|
173
|
+
return proc.stdout
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def detect_changes(workdir: str, timeout=None) -> Optional[int]:
|
|
177
|
+
"""Count changed files in the worktree (git only; None when not a repo).
|
|
178
|
+
|
|
179
|
+
Read-only: `git status --porcelain` and nothing else. taskproof never runs
|
|
180
|
+
a git write command.
|
|
181
|
+
|
|
182
|
+
``timeout`` is an optional short probe bound (seconds) used by the live API;
|
|
183
|
+
``None`` (the default, and every pre-existing caller) preserves the original
|
|
184
|
+
no-timeout behaviour.
|
|
185
|
+
"""
|
|
186
|
+
porcelain = _git_porcelain(workdir, timeout=timeout)
|
|
187
|
+
if porcelain is None:
|
|
188
|
+
return None
|
|
189
|
+
return len([line for line in porcelain.splitlines() if line.strip()])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def changed_files(workdir: str) -> List[str]:
|
|
193
|
+
"""Relative paths reported by `git status --porcelain` (read-only)."""
|
|
194
|
+
porcelain = _git_porcelain(workdir)
|
|
195
|
+
return _changed_files_from_porcelain(porcelain)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _changed_files_from_porcelain(porcelain: Optional[str]) -> List[str]:
|
|
199
|
+
if not porcelain:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
files: List[str] = []
|
|
203
|
+
for line in porcelain.splitlines():
|
|
204
|
+
if not line.strip():
|
|
205
|
+
continue
|
|
206
|
+
files.append(_porcelain_path(line))
|
|
207
|
+
return files
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def changed_files_snapshot(workdir: str) -> Optional[Dict[str, str]]:
|
|
211
|
+
"""Map current porcelain paths to their two-column status.
|
|
212
|
+
|
|
213
|
+
Keeping the status beside the path lets the dispatcher distinguish a path
|
|
214
|
+
that was merely present before the run from one whose state changed. The
|
|
215
|
+
public ``changed_files`` / ``detect_changes`` functions intentionally keep
|
|
216
|
+
their historical current-state semantics.
|
|
217
|
+
"""
|
|
218
|
+
porcelain = _git_porcelain(workdir)
|
|
219
|
+
if porcelain is None:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
entries: Dict[str, str] = {}
|
|
223
|
+
for line in porcelain.splitlines():
|
|
224
|
+
if not line.strip():
|
|
225
|
+
continue
|
|
226
|
+
entries[_porcelain_path(line)] = line[:2]
|
|
227
|
+
return entries
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def diff_changed_files(
|
|
231
|
+
before: Optional[Dict[str, str]], after: Optional[Dict[str, str]]
|
|
232
|
+
) -> List[str]:
|
|
233
|
+
"""Return paths added, removed, or whose porcelain status changed.
|
|
234
|
+
|
|
235
|
+
Two full ``git status`` snapshots are required: a pre-existing dirty path
|
|
236
|
+
that the adapter never touched is absent from both and therefore cannot be
|
|
237
|
+
mistaken for this run's work.
|
|
238
|
+
"""
|
|
239
|
+
before = before or {}
|
|
240
|
+
after = after or {}
|
|
241
|
+
return sorted(
|
|
242
|
+
path
|
|
243
|
+
for path in set(before) | set(after)
|
|
244
|
+
if before.get(path) != after.get(path)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _porcelain_path(line: str) -> str:
|
|
249
|
+
# Format: two status columns, a space, then the path.
|
|
250
|
+
path = line[3:] if len(line) > 3 else line.strip()
|
|
251
|
+
# Renames/copies read "R old -> new"; we care about the new path.
|
|
252
|
+
if " -> " in path:
|
|
253
|
+
path = path.split(" -> ", 1)[1]
|
|
254
|
+
return path.strip().strip('"')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _normalise(workdir: str, path: str) -> str:
|
|
258
|
+
"""Normalise a path to a POSIX-style relative path from `workdir`.
|
|
259
|
+
|
|
260
|
+
Handles relative (`dist/x`), `./`-prefixed (`./dist/x`) and absolute
|
|
261
|
+
(`/repo/dist/x`) spellings so they all compare on the same footing.
|
|
262
|
+
"""
|
|
263
|
+
text = str(path).replace("\\", "/")
|
|
264
|
+
base = os.path.abspath(workdir)
|
|
265
|
+
if os.path.isabs(text):
|
|
266
|
+
try:
|
|
267
|
+
text = os.path.relpath(os.path.normpath(text), base)
|
|
268
|
+
except ValueError:
|
|
269
|
+
# Different drive (Windows); fall through using the raw text.
|
|
270
|
+
pass
|
|
271
|
+
normalised = os.path.normpath(text).replace(os.sep, "/")
|
|
272
|
+
# Strip any leading "./" that survived normpath (e.g. on odd inputs).
|
|
273
|
+
while normalised.startswith("./"):
|
|
274
|
+
normalised = normalised[2:]
|
|
275
|
+
if normalised == ".":
|
|
276
|
+
normalised = ""
|
|
277
|
+
return normalised
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parse_forbidden_rule(raw) -> Optional[ForbiddenRule]:
|
|
281
|
+
"""Parse the optional ``file:`` / ``git:`` / ``presence:`` prefix.
|
|
282
|
+
|
|
283
|
+
Unprefixed strings are the legacy spelling and mean ``file``. A literal
|
|
284
|
+
path beginning with one of these prefixes can be protected as a file by
|
|
285
|
+
spelling it with an explicit extra ``file:`` prefix (for example,
|
|
286
|
+
``file:presence:notes.txt``).
|
|
287
|
+
"""
|
|
288
|
+
if raw is None:
|
|
289
|
+
return None
|
|
290
|
+
text = str(raw)
|
|
291
|
+
for kind in ("file", "git", "presence"):
|
|
292
|
+
prefix = kind + ":"
|
|
293
|
+
if text.startswith(prefix):
|
|
294
|
+
return ForbiddenRule(
|
|
295
|
+
kind=kind,
|
|
296
|
+
path=text[len(prefix):].replace("\\", "/"),
|
|
297
|
+
raw=text,
|
|
298
|
+
)
|
|
299
|
+
return ForbiddenRule(kind="file", path=text.replace("\\", "/"), raw=text)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _git_output(workdir: str, args: List[str]) -> Optional[Tuple[int, str]]:
|
|
303
|
+
"""Run one read-only git probe; None when git cannot be executed."""
|
|
304
|
+
try:
|
|
305
|
+
proc = subprocess.run(
|
|
306
|
+
["git", *args],
|
|
307
|
+
cwd=workdir,
|
|
308
|
+
stdout=subprocess.PIPE,
|
|
309
|
+
stderr=subprocess.DEVNULL,
|
|
310
|
+
text=True,
|
|
311
|
+
)
|
|
312
|
+
except (OSError, subprocess.SubprocessError):
|
|
313
|
+
return None
|
|
314
|
+
return proc.returncode, proc.stdout or ""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def is_git_forbidden_rule(workdir: str, raw_rule) -> bool:
|
|
318
|
+
"""Whether a forbidden rule targets the repository's `.git` entry.
|
|
319
|
+
|
|
320
|
+
Both ``.git`` and ``.git/`` resolve to the same protected target. A more
|
|
321
|
+
specific rule such as ``.git/config`` intentionally stays on the file
|
|
322
|
+
fingerprint path.
|
|
323
|
+
"""
|
|
324
|
+
if raw_rule is None:
|
|
325
|
+
return False
|
|
326
|
+
rule_path = _normalise(workdir, str(raw_rule)).rstrip("/")
|
|
327
|
+
return rule_path == ".git"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _branches_checked_out_elsewhere(workdir: str) -> set:
|
|
331
|
+
"""Full branch refs checked out by *other* worktrees of the same repo.
|
|
332
|
+
|
|
333
|
+
``refs/heads/*`` is shared by every worktree of a repository, so a commit,
|
|
334
|
+
checkout or reset in a sibling checkout moves the very same branch ref the
|
|
335
|
+
probed worktree sees. That is the point of worktree isolation: it is not
|
|
336
|
+
this card's doing. ``git worktree list --porcelain`` names each checkout
|
|
337
|
+
(``worktree <path>``) and the branch it has checked out (``branch
|
|
338
|
+
refs/heads/<b>``; a detached checkout has no ``branch`` line). We collect
|
|
339
|
+
the branches of every entry whose path is not the probed worktree's own
|
|
340
|
+
root, so the caller can drop exactly those refs from the snapshot.
|
|
341
|
+
|
|
342
|
+
Returns an empty set when git cannot be run, the path is not a worktree, or
|
|
343
|
+
the listing is unavailable (an older git, for instance). An empty set means
|
|
344
|
+
"exclude nothing", which is byte-for-byte today's behaviour.
|
|
345
|
+
"""
|
|
346
|
+
listing = _git_output(workdir, ["worktree", "list", "--porcelain"])
|
|
347
|
+
if listing is None or listing[0] != 0:
|
|
348
|
+
return set()
|
|
349
|
+
|
|
350
|
+
# The probed checkout, resolved to its real top: ``workdir`` may be a
|
|
351
|
+
# subdirectory of the worktree (a lane can point at one) and the listing
|
|
352
|
+
# always reports the top. Compare real paths so symlinked spellings match.
|
|
353
|
+
top = _git_output(workdir, ["rev-parse", "--show-toplevel"])
|
|
354
|
+
if top is None or top[0] != 0:
|
|
355
|
+
return set()
|
|
356
|
+
current = os.path.realpath(top[1].strip())
|
|
357
|
+
|
|
358
|
+
excluded: set = set()
|
|
359
|
+
|
|
360
|
+
def _flush(path: Optional[str], branch: Optional[str]) -> None:
|
|
361
|
+
if not branch or not path:
|
|
362
|
+
return
|
|
363
|
+
if os.path.realpath(path) != current:
|
|
364
|
+
excluded.add(branch)
|
|
365
|
+
|
|
366
|
+
path: Optional[str] = None
|
|
367
|
+
branch: Optional[str] = None
|
|
368
|
+
for line in listing[1].splitlines():
|
|
369
|
+
if line.startswith("worktree "):
|
|
370
|
+
_flush(path, branch)
|
|
371
|
+
path = line[len("worktree "):].strip()
|
|
372
|
+
branch = None
|
|
373
|
+
elif line.startswith("branch "):
|
|
374
|
+
branch = line[len("branch "):].strip()
|
|
375
|
+
_flush(path, branch)
|
|
376
|
+
return excluded
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def git_state_snapshot(workdir: str) -> Dict[str, str]:
|
|
380
|
+
"""Read the repository state guarded by a forbidden ``.git`` rule.
|
|
381
|
+
|
|
382
|
+
The probe is deliberately about *history state*, not git's bookkeeping
|
|
383
|
+
files: HEAD, the symbolic branch, all refs, and stashed commits. Commits,
|
|
384
|
+
amends, resets, checkouts, branch/tag changes, and stash changes move one of
|
|
385
|
+
these values; a ``git status`` index refresh does not.
|
|
386
|
+
|
|
387
|
+
One deliberate narrowing: ``refs/heads/*`` is shared across a repository's
|
|
388
|
+
worktrees, so branches another worktree has checked out are left out of the
|
|
389
|
+
snapshot (see :func:`_branches_checked_out_elsewhere`). A commit in a
|
|
390
|
+
sibling checkout must not read as this card's doing. The probed worktree's
|
|
391
|
+
own branch, every ``refs/remotes/*`` and ``refs/tags/*`` ref, and the stash
|
|
392
|
+
stay in, because those are the moves this card can really make.
|
|
393
|
+
|
|
394
|
+
Returns ``{}`` when ``workdir`` is not a repository, git is not on PATH, or
|
|
395
|
+
git cannot be executed. In that case there is no state to claim changed, so
|
|
396
|
+
the caller must not treat absence as a violation.
|
|
397
|
+
"""
|
|
398
|
+
repo = _git_output(workdir, ["rev-parse", "--git-dir"])
|
|
399
|
+
if repo is None or repo[0] != 0:
|
|
400
|
+
return {}
|
|
401
|
+
|
|
402
|
+
# Branches a sibling worktree has checked out; exclude them from the refs
|
|
403
|
+
# below. Empty when there is no other worktree, which keeps the snapshot
|
|
404
|
+
# byte-for-byte identical to the pre-worktree behaviour.
|
|
405
|
+
elsewhere = _branches_checked_out_elsewhere(workdir)
|
|
406
|
+
|
|
407
|
+
state: Dict[str, str] = {"HEAD": "", "symbolic-ref": "", "stash": ""}
|
|
408
|
+
|
|
409
|
+
head = _git_output(workdir, ["rev-parse", "--verify", "HEAD"])
|
|
410
|
+
if head is not None and head[0] == 0:
|
|
411
|
+
state["HEAD"] = head[1].strip()
|
|
412
|
+
|
|
413
|
+
symbolic = _git_output(workdir, ["symbolic-ref", "-q", "HEAD"])
|
|
414
|
+
if symbolic is not None and symbolic[0] == 0:
|
|
415
|
+
state["symbolic-ref"] = symbolic[1].strip()
|
|
416
|
+
|
|
417
|
+
refs = _git_output(
|
|
418
|
+
workdir,
|
|
419
|
+
[
|
|
420
|
+
"for-each-ref",
|
|
421
|
+
"--format=%(refname) %(objectname)",
|
|
422
|
+
"refs/heads",
|
|
423
|
+
"refs/remotes",
|
|
424
|
+
"refs/tags",
|
|
425
|
+
],
|
|
426
|
+
)
|
|
427
|
+
if refs is not None and refs[0] == 0:
|
|
428
|
+
for line in refs[1].splitlines():
|
|
429
|
+
fields = line.strip().split(None, 1)
|
|
430
|
+
if len(fields) == 2:
|
|
431
|
+
if fields[0] in elsewhere:
|
|
432
|
+
continue
|
|
433
|
+
state[fields[0]] = fields[1]
|
|
434
|
+
|
|
435
|
+
stashes = _git_output(workdir, ["stash", "list", "--format=%H"])
|
|
436
|
+
if stashes is not None and stashes[0] == 0:
|
|
437
|
+
state["stash"] = "\n".join(
|
|
438
|
+
line.strip() for line in stashes[1].splitlines() if line.strip()
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return state
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
#: The directory Python writes byte-code into, and the file suffixes it uses.
|
|
445
|
+
_BYTECODE_DIR = "__pycache__"
|
|
446
|
+
_BYTECODE_SUFFIXES = (".pyc", ".pyo")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def is_python_bytecode(path: str) -> bool:
|
|
450
|
+
"""Whether `path` is Python byte-code the interpreter wrote, not authored work.
|
|
451
|
+
|
|
452
|
+
Byte-code is a byproduct of *running* Python (an ``import``), carries no
|
|
453
|
+
human intent, and is regenerated on demand. A rule that forbids a Python
|
|
454
|
+
source directory would otherwise fire the first time anyone imports the
|
|
455
|
+
package from inside the guarded tree, which makes the rule unusable. A path
|
|
456
|
+
is exempt when either:
|
|
457
|
+
|
|
458
|
+
* any path segment is literally ``__pycache__``, or
|
|
459
|
+
* its final segment ends in ``.pyc`` or ``.pyo``.
|
|
460
|
+
|
|
461
|
+
The match is on the *path itself*, independent of the rule's ``kind``
|
|
462
|
+
(``file`` / ``git`` / ``presence``), so all three signals skip it alike. It
|
|
463
|
+
is deliberately narrow: only these two spellings are exempt. Every other
|
|
464
|
+
ignored path (``node_modules/``, ``dist/``) is still watched, and a name
|
|
465
|
+
that merely contains the substring — ``a__pycache__b.py`` — is not exempt.
|
|
466
|
+
"""
|
|
467
|
+
text = str(path).replace("\\", "/")
|
|
468
|
+
segments = [seg for seg in text.split("/") if seg]
|
|
469
|
+
if not segments:
|
|
470
|
+
return False
|
|
471
|
+
if any(seg == _BYTECODE_DIR for seg in segments):
|
|
472
|
+
return True
|
|
473
|
+
return segments[-1].endswith(_BYTECODE_SUFFIXES)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def check_forbidden(workdir: str, forbidden_paths: List[str],
|
|
477
|
+
changed_files: List[str]) -> List[str]:
|
|
478
|
+
"""Return the subset of `changed_files` that violates `forbidden_paths`.
|
|
479
|
+
|
|
480
|
+
Matching is on normalised relative paths and must catch a change nested
|
|
481
|
+
under a forbidden directory (e.g. `dist/app.js` for `dist/`). A rule that
|
|
482
|
+
ends with `/` is a directory rule (matches the directory and everything
|
|
483
|
+
below it); a rule without a trailing slash must match that exact path.
|
|
484
|
+
"""
|
|
485
|
+
rules = []
|
|
486
|
+
for raw in forbidden_paths or []:
|
|
487
|
+
parsed = parse_forbidden_rule(raw)
|
|
488
|
+
if parsed is None or parsed.kind == "git":
|
|
489
|
+
continue
|
|
490
|
+
rule_text = parsed.path
|
|
491
|
+
is_dir = rule_text.endswith("/")
|
|
492
|
+
rule_path = _normalise(workdir, rule_text)
|
|
493
|
+
if not rule_path:
|
|
494
|
+
continue
|
|
495
|
+
rules.append((rule_path, is_dir))
|
|
496
|
+
|
|
497
|
+
violations: List[str] = []
|
|
498
|
+
for original in changed_files or []:
|
|
499
|
+
candidate = _normalise(workdir, original)
|
|
500
|
+
# Byte-code is the interpreter's byproduct, not authored work; it is a
|
|
501
|
+
# boundary breach for no rule kind (see `is_python_bytecode`).
|
|
502
|
+
if is_python_bytecode(candidate):
|
|
503
|
+
continue
|
|
504
|
+
for rule_path, is_dir in rules:
|
|
505
|
+
if is_dir:
|
|
506
|
+
if candidate == rule_path or candidate.startswith(rule_path + "/"):
|
|
507
|
+
violations.append(original)
|
|
508
|
+
break
|
|
509
|
+
else:
|
|
510
|
+
if candidate == rule_path:
|
|
511
|
+
violations.append(original)
|
|
512
|
+
break
|
|
513
|
+
return violations
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
# Forbidden paths, part two: watching what git cannot see.
|
|
518
|
+
#
|
|
519
|
+
# `check_forbidden` matches against the `git status` change list, which is a
|
|
520
|
+
# cheap and good change detector but a blind one for two whole classes of path:
|
|
521
|
+
#
|
|
522
|
+
# * nothing inside `.git/` is ever reported -- git does not list its own
|
|
523
|
+
# directory;
|
|
524
|
+
# * every ignored path is omitted -- and build output and dependency
|
|
525
|
+
# directories are exactly the ignored ones.
|
|
526
|
+
#
|
|
527
|
+
# This was measured, not assumed. With `forbidden_paths = ["dist/"]` and `dist/`
|
|
528
|
+
# in `.gitignore`, an adapter wrote `dist/app.js` and the run was recorded as
|
|
529
|
+
# `done`. The same happened for `.git/`. Those rules, and `node_modules/`, are
|
|
530
|
+
# what the shipped example declares -- so the guard was inert for the three
|
|
531
|
+
# paths people most expect it to cover.
|
|
532
|
+
#
|
|
533
|
+
# So the declared paths are also fingerprinted before and after the run. That
|
|
534
|
+
# costs one walk of the declared set and nothing else.
|
|
535
|
+
#
|
|
536
|
+
# `.git/` is the exception: git's index/logs/object files are bookkeeping that a
|
|
537
|
+
# read-only `git status` (or a worker's commit) can refresh, while the useful
|
|
538
|
+
# signal is history state. That rule is compared with `git_state_snapshot`
|
|
539
|
+
# instead; every other rule continues through the fingerprint path below.
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
#: Above this many entries a rule's walk stops and only its root is compared.
|
|
543
|
+
#: Reported in the audit event when it happens, so a partial guarantee is
|
|
544
|
+
#: visible rather than implied.
|
|
545
|
+
SNAPSHOT_LIMIT = 20000
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _fingerprint(path: str) -> str:
|
|
549
|
+
try:
|
|
550
|
+
stat = os.stat(path)
|
|
551
|
+
except OSError:
|
|
552
|
+
return "gone"
|
|
553
|
+
return f"{stat.st_size}:{stat.st_mtime_ns}"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def snapshot_forbidden(
|
|
557
|
+
workdir: str, forbidden_paths: List[str]
|
|
558
|
+
) -> Tuple[Dict[str, str], bool]:
|
|
559
|
+
"""Fingerprint everything at or under each forbidden rule.
|
|
560
|
+
|
|
561
|
+
Returns `(entries, truncated)`. A rule whose path does not exist yet is
|
|
562
|
+
skipped rather than recorded: if the run creates it, that shows up as an
|
|
563
|
+
addition in the diff, which is the point.
|
|
564
|
+
"""
|
|
565
|
+
entries: Dict[str, str] = {}
|
|
566
|
+
truncated = False
|
|
567
|
+
|
|
568
|
+
for raw in forbidden_paths or []:
|
|
569
|
+
if raw is None:
|
|
570
|
+
continue
|
|
571
|
+
rule_path = _normalise(workdir, str(raw))
|
|
572
|
+
if not rule_path:
|
|
573
|
+
continue
|
|
574
|
+
target = os.path.join(workdir, rule_path)
|
|
575
|
+
|
|
576
|
+
if os.path.isfile(target):
|
|
577
|
+
entries[rule_path] = _fingerprint(target)
|
|
578
|
+
continue
|
|
579
|
+
if not os.path.isdir(target):
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
for root, dirs, names in os.walk(target):
|
|
583
|
+
dirs.sort()
|
|
584
|
+
for name in sorted(names):
|
|
585
|
+
if len(entries) >= SNAPSHOT_LIMIT:
|
|
586
|
+
truncated = True
|
|
587
|
+
return entries, truncated
|
|
588
|
+
full = os.path.join(root, name)
|
|
589
|
+
entries[_normalise(workdir, full)] = _fingerprint(full)
|
|
590
|
+
|
|
591
|
+
return entries, truncated
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def diff_snapshots(before: Dict[str, str], after: Dict[str, str]) -> List[str]:
|
|
595
|
+
"""Paths under a protected rule that appeared, changed or disappeared."""
|
|
596
|
+
changed = set()
|
|
597
|
+
for key, fingerprint in after.items():
|
|
598
|
+
if before.get(key) != fingerprint:
|
|
599
|
+
changed.add(key)
|
|
600
|
+
for key in before:
|
|
601
|
+
if key not in after:
|
|
602
|
+
changed.add(key)
|
|
603
|
+
return sorted(changed)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def diff_presence_snapshots(
|
|
607
|
+
before: Dict[str, str], after: Dict[str, str]
|
|
608
|
+
) -> List[str]:
|
|
609
|
+
"""Paths that appeared or disappeared, ignoring fingerprint changes.
|
|
610
|
+
|
|
611
|
+
This is the ``presence`` signal: a devserver may rewrite the content of an
|
|
612
|
+
existing build artifact without adding or removing a file, and that must
|
|
613
|
+
not be reported as a forbidden change.
|
|
614
|
+
"""
|
|
615
|
+
return sorted(set(before) ^ set(after))
|