custos-code 0.0.1__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.
custos_code/rules.py ADDED
@@ -0,0 +1,464 @@
1
+ """Tiers 1–2: deterministic witness and outcome checks. No model.
2
+
3
+ One function per claim type, all sharing the same evidence helpers. Every rule returns a
4
+ VerdictRecord or None (None = "no rule applies, escalate"). Invariants enforced here:
5
+ - `contradicted` only on positive evidence (a failed run, a missing file, a diff that disagrees).
6
+ - edit/create claims need repo state to agree (invariant 5); transcript alone yields `unwitnessed`.
7
+ - piped or truncated evidence yields `unrecorded`, never `confirmed`.
8
+ - sidechain events are never top-level evidence.
9
+
10
+ Repo state is read through `RepoState`, which is lazy and tolerant: when the session's cwd is not
11
+ on this machine (post-hoc audit of someone else's transcript) every state query answers "unknown"
12
+ and the rules fall back to the weaker verdict rather than guessing.
13
+
14
+ Owner: Oliver (rules). Runner parsers live in parsers.py (Anush).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import subprocess
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass, field
23
+ from typing import Literal
24
+
25
+ from . import parsers
26
+ from .models import Claim, ClaimType, EventKind, LedgerEvent, Verdict, VerdictRecord
27
+
28
+ # ---------- known tools ----------
29
+ _RUNNERS = ("pytest", "py.test", "jest", "vitest", "mocha", "cargo test", "go test", "npm test", "yarn test",
30
+ "pnpm test", "bun test", "python -m pytest", "python3 -m pytest", "make test", "gradle test",
31
+ "mvn test", "xcodebuild test", "swift test", "dotnet test", "rspec", "phpunit")
32
+ _LINTERS = ("ruff", "mypy", "eslint", "tsc", "pyright", "flake8", "black --check", "prettier --check",
33
+ "cargo clippy", "golangci-lint", "go vet", "npm run lint", "npm run typecheck", "make lint")
34
+ _BUILDERS = ("npm run build", "yarn build", "pnpm build", "cargo build", "go build", "make", "tsc",
35
+ "xcodebuild", "gradle build", "mvn package", "docker build", "uv build", "python -m build")
36
+ _DEPLOYERS = ("deploy", "fly deploy", "vercel", "netlify deploy", "gh release", "kubectl apply", "helm upgrade",
37
+ "git push heroku", "serverless deploy", "sam deploy", "terraform apply")
38
+ _EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit", "str_replace_based_edit_tool"}
39
+ _READ_TOOLS = {"Read", "Glob", "Grep"}
40
+ _PREFIX_RE = re.compile(r"^(?:cd\s+\S+\s*&&\s*|(?:[A-Z_][A-Z0-9_]*=\S+\s+)+|uv\s+run\s+|npx\s+|pnpm\s+exec\s+|poetry\s+run\s+|bunx\s+|pipenv\s+run\s+|sudo\s+)*")
41
+ _FAIL_TEXT_RE = re.compile(r"\b(?:error|failed|failure|traceback|exception|E\d{3}\b|cannot find|not found)\b", re.I)
42
+ # A digit that can only be a test tally, so the number must sit against a pass-word. A bare
43
+ # "N tests" is not enough: "added 12 tests in tests/test_rate_limit.py" is a claim about writing
44
+ # tests, and it is perfectly consistent with an earlier run reporting 9 passed. Accusing there
45
+ # would be the unfounded accusation this project exists to prevent. A missed mismatch costs one
46
+ # catch; a false one costs the product's credibility, and those are not symmetric.
47
+ _TEST_COUNT_RE = re.compile(r"\b(\d+)\s*(?:tests?\s+)?(?:passed|passing|pass|green)\b", re.I)
48
+
49
+ _LINT_OK_RE = re.compile(r"all checks passed|success: no issues|no issues found|0 errors|found 0 errors|✓|clean", re.I)
50
+ _BASH_EDIT_RE = re.compile(r"\bsed\s+-i|\btee\b|>{1,2}\s*[\w./-]+|\bmv\b|\bcp\b|\bpatch\b|\bgit\s+apply\b")
51
+ _RM_RE = re.compile(r"\b(?:rm\s+(?:-\w+\s+)*|git\s+rm\s+|unlink\s+)")
52
+
53
+
54
+ def norm_cmd(command: str) -> str:
55
+ return _PREFIX_RE.sub("", command.strip()).strip()
56
+
57
+
58
+ def _first_tool(command: str, tools: tuple[str, ...]) -> str | None:
59
+ c = norm_cmd(command)
60
+ for t in sorted(tools, key=len, reverse=True):
61
+ if c == t or c.startswith(t + " ") or c.startswith(t + "\n") or (" " in t and t in c):
62
+ return t
63
+ return None
64
+
65
+
66
+ def path_matches(ledger_path: str, obj: str) -> bool:
67
+ if not obj or obj.startswith(("/", ".")) is None:
68
+ return False
69
+ o = obj.strip().rstrip("/")
70
+ p = ledger_path.rstrip("/")
71
+ return p == o or p.endswith("/" + o) or (("/" not in o) and os.path.basename(p) == o)
72
+
73
+
74
+ # ---------- repo state ----------
75
+ @dataclass
76
+ class RepoState:
77
+ root: str | None
78
+ _git: bool | None = field(default=None, init=False)
79
+ _changed: frozenset[str] | None = field(default=None, init=False)
80
+ _changed_known: bool = field(default=False, init=False)
81
+
82
+ def _run(self, *args: str) -> str | None:
83
+ if not self.root or not os.path.isdir(self.root):
84
+ return None
85
+ try:
86
+ r = subprocess.run(["git", "-C", self.root, *args], capture_output=True, text=True, timeout=15)
87
+ except (OSError, subprocess.TimeoutExpired):
88
+ return None
89
+ return r.stdout if r.returncode == 0 else None
90
+
91
+ @property
92
+ def is_git(self) -> bool:
93
+ if self._git is None:
94
+ self._git = self._run("rev-parse", "--is-inside-work-tree") is not None
95
+ return self._git
96
+
97
+ def exists(self, rel_or_abs: str) -> bool | None:
98
+ if not self.root or not os.path.isdir(self.root):
99
+ return None
100
+ p = rel_or_abs if os.path.isabs(rel_or_abs) else os.path.join(self.root, rel_or_abs)
101
+ return os.path.exists(p)
102
+
103
+ def changed_files(self) -> frozenset[str] | None:
104
+ """Paths changed vs HEAD (staged, unstaged, untracked), repo-relative. None if unknown."""
105
+ if self._changed_known:
106
+ return self._changed
107
+ self._changed_known = True
108
+ if not self.is_git:
109
+ return None
110
+ out = self._run("status", "--porcelain", "--untracked-files=all")
111
+ if out is None:
112
+ return None
113
+ self._changed = frozenset(line[3:].split(" -> ")[-1].strip() for line in out.splitlines() if len(line) > 3)
114
+ return self._changed
115
+
116
+ def changed(self, obj: str) -> bool | None:
117
+ ch = self.changed_files()
118
+ if ch is None:
119
+ return None
120
+ return any(path_matches(p, obj) or path_matches(obj, p) for p in ch)
121
+
122
+ def has_commit(self, sha: str) -> bool | None:
123
+ if not self.is_git:
124
+ return None
125
+ return self._run("cat-file", "-e", f"{sha}^{{commit}}") is not None
126
+
127
+
128
+
129
+ def accusable(path: str, state: RepoState) -> bool:
130
+ """Whether a file-state finding is solid enough to accuse on (invariant 2, two-evidence rule).
131
+
132
+ Measured 2026-09-19: across 93 local sessions the engine produced 4 `contradicted` verdicts and
133
+ at least 3 were false, every one of them a path we could not actually resolve. So a missing or
134
+ unchanged file only supports an accusation when all of these hold:
135
+
136
+ - we have a repo root that exists on this machine (otherwise every path looks missing);
137
+ - the claim names a directory component, not a bare `foo.json` that could live anywhere;
138
+ - the path is absolute, or relative to a repo root it actually sits inside.
139
+
140
+ Anything else is `unwitnessed`: we could not check it, which is not the same as it being false.
141
+ """
142
+ if not state.root or not os.path.isdir(state.root):
143
+ return False
144
+ if "/" not in path.strip("/"):
145
+ return False
146
+ if os.path.isabs(path):
147
+ return True
148
+ return not path.startswith("..")
149
+
150
+
151
+ # ---------- evidence helpers ----------
152
+ def _visible(ledger: list[LedgerEvent]) -> list[LedgerEvent]:
153
+ return [e for e in ledger if not e.flags.sidechain]
154
+
155
+
156
+ def _pairs(ledger: list[LedgerEvent]) -> list[tuple[LedgerEvent, LedgerEvent | None]]:
157
+ """(CALL, following RESULT) pairs on the main chain, in order."""
158
+ vis = _visible(ledger)
159
+ out: list[tuple[LedgerEvent, LedgerEvent | None]] = []
160
+ for i, e in enumerate(vis):
161
+ if e.kind != EventKind.CALL:
162
+ continue
163
+ nxt = vis[i + 1] if i + 1 < len(vis) else None
164
+ out.append((e, nxt if nxt is not None and nxt.kind == EventKind.RESULT else None))
165
+ return out
166
+
167
+
168
+ def _cmd(e: LedgerEvent) -> str:
169
+ v = (e.input or {}).get("command")
170
+ return v if isinstance(v, str) else ""
171
+
172
+
173
+ Method = Literal["rule", "rerun", "judge", "state"]
174
+
175
+
176
+ def _rec(claim: Claim, verdict: Verdict, tier: int, method: Method, ev: Sequence[LedgerEvent | None], why: str,
177
+ conf: float = 0.9, qualifier: str | None = None) -> VerdictRecord:
178
+ return VerdictRecord(
179
+ claim_id=claim.id, verdict=verdict, tier=tier, method=method,
180
+ confidence=conf, evidence=[e.seq for e in ev if e is not None], rationale=why, qualifier=qualifier,
181
+ )
182
+
183
+
184
+ def _outcome_of(call: LedgerEvent, res: LedgerEvent | None, claim: Claim, label: str) -> VerdictRecord:
185
+ """Shared outcome logic for runner, linter, build, and plain-command claims (Tier 2)."""
186
+ if res is None:
187
+ return _rec(claim, Verdict.UNRECORDED, 2, "rule", [call], f"{label} was invoked at #{call.seq} but no result was recorded.")
188
+ if res.flags.piped or res.flags.truncated:
189
+ return _rec(claim, Verdict.UNRECORDED, 2, "rule", [call, res],
190
+ f"{label} output at #{res.seq} was {'piped' if res.flags.piped else 'truncated'}; the outcome is not in the record.")
191
+ if res.flags.interrupted:
192
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{label} at #{call.seq} was interrupted before completing.")
193
+ parsed = parsers.parse(res.output or "", res.exit_code)
194
+ if parsed is not None:
195
+ if parsed.collected == 0 and parsed.passed == 0:
196
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{parsed.runner} collected 0 tests at #{res.seq}; nothing ran.")
197
+ if parsed.failed or parsed.errors:
198
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res],
199
+ f"{parsed.runner} at #{res.seq}: {parsed.passed} passed, {parsed.failed} failed, {parsed.errors} errors.")
200
+ if res.exit_code not in (None, 0) or res.flags.error:
201
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{parsed.runner} at #{res.seq} exited non-zero.")
202
+ # A test count the runner disagrees with is not a hedge, it is a false statement, and it is
203
+ # one of the few the log settles outright. Tier 2 owns it: this is arithmetic over parsed
204
+ # runner output, not a judgement, so AGENTS.md invariant 3 permits `contradicted` here.
205
+ #
206
+ # Read the count off the claim text rather than `claim.objects`, and only in a shape that
207
+ # can only mean a test tally. "3 test files" and a bare version number are digits too, and
208
+ # accusing on those would be exactly the unfounded accusation this project exists to stop.
209
+ # Every plausible reading of the run has to disagree before it fires.
210
+ plausible = {parsed.passed, parsed.passed + parsed.skipped}
211
+ if parsed.collected is not None:
212
+ plausible.add(parsed.collected)
213
+ for m in _TEST_COUNT_RE.finditer(claim.text):
214
+ n = int(next(g for g in m.groups() if g))
215
+ if n not in plausible:
216
+ shown = " or ".join(str(x) for x in sorted(plausible))
217
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res],
218
+ f"{parsed.runner} at #{res.seq} reports {shown}, not {n}.",
219
+ 0.95, f"{n} claimed, {parsed.passed} passed")
220
+ return _rec(claim, Verdict.CONFIRMED, 2, "rule", [call, res], f"{parsed.runner} at #{res.seq}: {parsed.passed} passed, 0 failed.")
221
+ # no runner-format output: fall back to exit status and failure text
222
+ if res.exit_code not in (None, 0) or res.flags.error:
223
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{label} at #{call.seq} failed (exit {res.exit_code if res.exit_code is not None else 'non-zero'}).")
224
+ out = res.output or ""
225
+ if _LINT_OK_RE.search(out) or (res.exit_code == 0):
226
+ return _rec(claim, Verdict.CONFIRMED, 2, "rule", [call, res], f"{label} at #{call.seq} reported success.", 0.8)
227
+ if _FAIL_TEXT_RE.search(out):
228
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{label} output at #{res.seq} contains failure text.", 0.75)
229
+ return _rec(claim, Verdict.UNRECORDED, 2, "rule", [call, res],
230
+ f"{label} ran at #{call.seq} but the record has no exit code and no recognisable summary.", 0.6)
231
+
232
+
233
+ def _latest_call(ledger: list[LedgerEvent], pred: Callable[[str], bool]) -> tuple[LedgerEvent, LedgerEvent | None] | None:
234
+ hits = [(c, r) for c, r in _pairs(ledger) if c.tool == "Bash" and pred(_cmd(c))]
235
+ return hits[-1] if hits else None
236
+
237
+
238
+ # ---------- rules per claim type ----------
239
+ def rule_run_tests(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
240
+ hit = _latest_call(ledger, lambda c: _first_tool(c, _RUNNERS) is not None)
241
+ if hit is None:
242
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "No test runner was invoked in this session.")
243
+ return _outcome_of(hit[0], hit[1], claim, "test runner")
244
+
245
+
246
+ def rule_build(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
247
+ tools = _LINTERS + _BUILDERS
248
+ hit = _latest_call(ledger, lambda c: _first_tool(c, tools) is not None)
249
+ if hit is None:
250
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "No linter, type checker, or build command was invoked in this session.")
251
+ return _outcome_of(hit[0], hit[1], claim, _first_tool(_cmd(hit[0]), tools) or "build")
252
+
253
+
254
+ def _edit_events(ledger: list[LedgerEvent], obj: str) -> list[LedgerEvent]:
255
+ out = []
256
+ for c, r in _pairs(ledger):
257
+ if c.tool in _EDIT_TOOLS and any(path_matches(p, obj) for p in c.paths):
258
+ out += [c] + ([r] if r else [])
259
+ elif c.tool == "Bash" and _BASH_EDIT_RE.search(_cmd(c)) and any(path_matches(p, obj) for p in c.paths):
260
+ out += [c] + ([r] if r else [])
261
+ return out
262
+
263
+
264
+ def rule_edit(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
265
+ paths = [o for o in claim.objects if "/" in o or "." in o]
266
+ if not paths:
267
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "The claim names no file; nothing to match.", 0.5)
268
+ evs: list[LedgerEvent] = []
269
+ missing: list[str] = []
270
+ for p in paths:
271
+ e = _edit_events(ledger, p)
272
+ if e:
273
+ evs += e
274
+ else:
275
+ missing.append(p)
276
+ if missing and not evs:
277
+ ch = [state.changed(p) for p in missing]
278
+ if all(c is False for c in ch) and all(accusable(p, state) for p in missing):
279
+ return _rec(claim, Verdict.CONTRADICTED, 1, "state", [], f"No edit to {', '.join(missing)} in the log, and git shows no change to it.")
280
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], f"No edit to {', '.join(missing)} in the log" + ("; the path could not be resolved here." if missing and not all(accusable(p, state) for p in missing) else "."))
281
+ errs = [e for e in evs if e.kind == EventKind.RESULT and e.flags.error]
282
+ if errs:
283
+ return _rec(claim, Verdict.CONTRADICTED, 1, "rule", evs, f"The edit at #{errs[0].seq} failed.")
284
+ agree = [state.changed(p) for p in paths if p not in missing]
285
+ if all(a is True for a in agree):
286
+ return _rec(claim, Verdict.CONFIRMED, 1, "state", evs, f"Edit event(s) on {', '.join(p for p in paths if p not in missing)}; git shows the file changed.")
287
+ if any(a is False for a in agree):
288
+ return _rec(claim, Verdict.QUALIFIED, 1, "state", evs, "Edit event exists, but the file matches HEAD now.", 0.8, "written, then reverted or committed")
289
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", evs, "Edit event exists, but repo state is unavailable to confirm it (invariant 5).", 0.6)
290
+
291
+
292
+ def rule_create(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
293
+ paths = [o for o in claim.objects if "/" in o or "." in o]
294
+ if not paths:
295
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "The claim names no file; nothing to match.", 0.5)
296
+ evs = [e for p in paths for e in _edit_events(ledger, p)]
297
+ exists = [state.exists(p) for p in paths]
298
+ if all(x is True for x in exists) and evs:
299
+ return _rec(claim, Verdict.CONFIRMED, 1, "state", evs, f"Write event(s) and the file(s) exist: {', '.join(paths)}.")
300
+ gone = [p for p, x in zip(paths, exists, strict=True) if x is False]
301
+ if gone and all(accusable(p, state) for p in gone):
302
+ return _rec(claim, Verdict.CONTRADICTED, 1, "state", evs, f"{', '.join(gone)} does not exist.")
303
+ if gone:
304
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", evs,
305
+ f"Cannot resolve {', '.join(gone)} against this repo, so absence proves nothing.")
306
+ if all(x is True for x in exists):
307
+ return _rec(claim, Verdict.QUALIFIED, 1, "state", [], "File exists but no write event in the log.", 0.7, "existed before, or created outside the log")
308
+ if evs:
309
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", evs, "Write event exists; repo state unavailable to confirm (invariant 5).", 0.6)
310
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], f"No write to {', '.join(paths)} in the log.")
311
+
312
+
313
+ def rule_delete(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
314
+ paths = [o for o in claim.objects if "/" in o or "." in o]
315
+ if not paths:
316
+ return None
317
+ evs = [c for c, _ in _pairs(ledger) if c.tool == "Bash" and _RM_RE.search(_cmd(c)) and any(path_matches(p, o) for p in c.paths for o in paths)]
318
+ exists = [state.exists(p) for p in paths]
319
+ still = [p for p, x in zip(paths, exists, strict=True) if x is True]
320
+ if still and all(accusable(p, state) for p in still):
321
+ return _rec(claim, Verdict.CONTRADICTED, 1, "state", evs, f"{', '.join(still)} still exists.")
322
+ if still:
323
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", evs, "Cannot resolve the path against this repo.")
324
+ if all(x is False for x in exists):
325
+ return _rec(claim, Verdict.CONFIRMED, 1, "state", evs, "File is absent" + (" and a removal command was recorded." if evs else "."), 0.9 if evs else 0.7)
326
+ return _rec(claim, Verdict.UNWITNESSED if not evs else Verdict.CONFIRMED, 1, "rule", evs, "Removal command recorded; state unavailable." if evs else "No removal in the log; state unavailable.", 0.6)
327
+
328
+
329
+ def rule_review_all(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
330
+ reads = [c for c, _ in _pairs(ledger) if c.tool in _READ_TOOLS or (c.tool == "Bash" and re.search(r"\b(cat|head|tail|less|sed -n)\b", _cmd(c)))]
331
+ read_paths = {p for c in reads for p in c.paths}
332
+ paths = [o for o in claim.objects if "/" in o or "." in o]
333
+ if paths:
334
+ seen = [any(path_matches(rp, p) for rp in read_paths) for p in paths]
335
+ if all(seen):
336
+ return _rec(claim, Verdict.CONFIRMED, 1, "rule", reads[-5:], f"All {len(paths)} named files were read.")
337
+ return _rec(claim, Verdict.QUALIFIED, 1, "rule", reads[-5:], f"{sum(seen)} of {len(paths)} named files were read.", 0.85, f"{sum(seen)} of {len(paths)} opened")
338
+ m = re.search(r"\b(\d+)\b", claim.text)
339
+ if m:
340
+ n = int(m.group(1))
341
+ if len(read_paths) < n:
342
+ return _rec(claim, Verdict.QUALIFIED, 1, "rule", reads[-5:], f"{len(read_paths)} distinct files read, {n} claimed.", 0.8, f"{len(read_paths)} of {n} opened")
343
+ return _rec(claim, Verdict.CONFIRMED, 1, "rule", reads[-5:], f"{len(read_paths)} distinct files read (≥ {n} claimed).", 0.75)
344
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", reads[-3:], f"{len(read_paths)} files were read; the claim's scope cannot be enumerated.", 0.5)
345
+
346
+
347
+ def rule_commit(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
348
+ shas = [o for o in claim.objects if re.fullmatch(r"[0-9a-f]{7,40}", o)]
349
+ for sha in shas:
350
+ has = state.has_commit(sha)
351
+ if has is True:
352
+ return _rec(claim, Verdict.CONFIRMED, 1, "state", [], f"Commit {sha} exists in the repo.")
353
+ if has is False:
354
+ return _rec(claim, Verdict.CONTRADICTED, 1, "state", [], f"Commit {sha} does not exist in the repo.")
355
+ if shas and all(state.has_commit(x) is None for x in shas):
356
+ # The claim names a commit we cannot look up here. Confirming it from some other push in
357
+ # the session would attribute unrelated evidence to it (found by MR1, 2026-09-19).
358
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [],
359
+ f"The claim names {', '.join(shas)}, which cannot be verified against a repo from here.", 0.6)
360
+ pushing = bool(re.search(r"\bpush(?:ed)?\b", claim.text, re.I))
361
+ pat = r"\bgit\s+push\b" if pushing else r"\bgit\s+(?:commit|merge)\b|\bgh\s+pr\s+(?:create|merge)\b"
362
+ hit = _latest_call(ledger, lambda c: re.search(pat, c) is not None)
363
+ if hit is None:
364
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], f"No {'git push' if pushing else 'git commit/merge'} in the log.")
365
+ call, res = hit
366
+ if res is not None and (res.flags.error or (res.exit_code not in (None, 0))):
367
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{'git push' if pushing else 'git commit'} at #{call.seq} failed.")
368
+ if res is not None and re.search(r"rejected|fatal:|error:", res.output or "", re.I):
369
+ return _rec(claim, Verdict.CONTRADICTED, 2, "rule", [call, res], f"{'git push' if pushing else 'git commit'} at #{call.seq} reported an error.")
370
+ if not shas and not claim.objects:
371
+ return _rec(claim, Verdict.CONFIRMED, 2, "rule", [call, res],
372
+ f"A {'push' if pushing else 'commit'} succeeded at #{call.seq}; the claim names no commit or ref, "
373
+ "so this is session-level evidence rather than evidence for this claim specifically.", 0.6)
374
+ return _rec(claim, Verdict.CONFIRMED, 2, "rule", [call, res], f"{'git push' if pushing else 'git commit'} at #{call.seq} succeeded.", 0.85)
375
+
376
+
377
+ def rule_run_cmd(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
378
+ cmds = [o for o in claim.objects if " " in o or "/" in o]
379
+ if not cmds:
380
+ return None
381
+ want = " ".join(norm_cmd(cmds[0]).split())
382
+ hit = _latest_call(ledger, lambda c: want in " ".join(norm_cmd(c).split()))
383
+ if hit is None:
384
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], f"`{want}` was not run in this session.")
385
+ return _outcome_of(hit[0], hit[1], claim, f"`{want}`")
386
+
387
+
388
+ def rule_observed_output(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
389
+ needles = [o for o in claim.objects if len(o) >= 2] + re.findall(r"\b\d{3,}\b", claim.text)
390
+ if not needles:
391
+ return None
392
+ for e in reversed(_visible(ledger)):
393
+ if e.kind == EventKind.RESULT and e.output and any(n in e.output for n in needles):
394
+ return _rec(claim, Verdict.CONFIRMED, 2, "rule", [e], f"Output at #{e.seq} contains the observed value.", 0.8)
395
+ return _rec(claim, Verdict.UNWITNESSED, 2, "rule", [], "No recorded output contains the observed value.")
396
+
397
+
398
+ def rule_verify(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
399
+ if "manual" in claim.objects:
400
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "A manual check leaves no trace in the record; not an accusation.", 0.9)
401
+ cmds = [o for o in claim.objects if " " in o]
402
+ if cmds:
403
+ return rule_run_cmd(claim, ledger, state)
404
+ paths = [o for o in claim.objects if "/" in o or "." in o]
405
+ if paths:
406
+ return rule_review_all(claim, ledger, state)
407
+ return _rec(claim, Verdict.UNWITNESSED, 4, "rule", [], "The claim does not name a check that could have produced evidence.", 0.6)
408
+
409
+
410
+ def rule_deploy(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
411
+ hit = _latest_call(ledger, lambda c: any(d in c for d in _DEPLOYERS))
412
+ if hit is None:
413
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [], "No deploy command in the log.")
414
+ return _outcome_of(hit[0], hit[1], claim, "deploy")
415
+
416
+
417
+ def rule_did_not_touch(claim: Claim, ledger: list[LedgerEvent], state: RepoState) -> VerdictRecord | None:
418
+ objs = [o for o in claim.objects if "/" in o or "." in o]
419
+ if not objs and re.search(r"\btests?\b", claim.text, re.I):
420
+ objs = ["tests/", "test_"]
421
+ if not objs:
422
+ return None
423
+ touched = [e for o in objs for e in _edit_events(ledger, o)] + [
424
+ c for c, _ in _pairs(ledger) if c.tool in _EDIT_TOOLS and any(("/tests/" in p or "/test_" in p) for p in c.paths) and objs == ["tests/", "test_"]
425
+ ]
426
+ if touched:
427
+ return _rec(claim, Verdict.CONTRADICTED, 1, "rule", touched[:4], f"Edit event(s) on {', '.join(objs)} at #{touched[0].seq}.")
428
+ ch = [state.changed(o) for o in objs]
429
+ if any(c is True for c in ch):
430
+ return _rec(claim, Verdict.CONTRADICTED, 1, "state", [], f"git shows changes under {', '.join(objs)}.")
431
+ if all(c is False for c in ch):
432
+ return _rec(claim, Verdict.CONFIRMED, 1, "state", [], f"No edit events and git shows no change under {', '.join(objs)}.")
433
+ # No edit events, and no repo to check against. That is NOT a confirmation: a negative claim
434
+ # ("I did not touch X") is backed by the repository, not by our own silence, and with no
435
+ # `state` check there is nothing a reader could verify -- the record would carry an empty
436
+ # evidence list. verdicts._enforce rejects exactly this shape, and it is right to.
437
+ #
438
+ # Found on a real session (5f8a60d1) where the claim was a mis-extracted pytest flag,
439
+ # `-o python_files=<name>`, read as a path. Confirming a negative about a path we cannot
440
+ # resolve is how a checker starts agreeing with things it has not established.
441
+ #
442
+ # `unwitnessed` is the conservative answer: it never blocks and is never an accusation, so the
443
+ # cost of being wrong here is a mark the agent can clear by checking the path itself.
444
+ return _rec(claim, Verdict.UNWITNESSED, 1, "rule", [],
445
+ "No edit events on the named paths, but the repo state could not be read, so the "
446
+ "absence of a change is not established.", 0.7)
447
+
448
+
449
+ Rule = Callable[[Claim, list[LedgerEvent], RepoState], VerdictRecord | None]
450
+ RULES: dict[ClaimType, Rule] = {
451
+ ClaimType.RUN_TESTS: rule_run_tests, ClaimType.BUILD: rule_build, ClaimType.EDIT: rule_edit,
452
+ ClaimType.CREATE: rule_create, ClaimType.DELETE: rule_delete, ClaimType.READ: rule_review_all,
453
+ ClaimType.REVIEW_ALL: rule_review_all, ClaimType.COMMIT: rule_commit, ClaimType.RUN_CMD: rule_run_cmd,
454
+ ClaimType.OBSERVED_OUTPUT: rule_observed_output, ClaimType.VERIFY: rule_verify, ClaimType.DEPLOY: rule_deploy,
455
+ ClaimType.DID_NOT_TOUCH: rule_did_not_touch,
456
+ }
457
+
458
+
459
+ def check(claim: Claim, ledger: list[LedgerEvent], repo_root: str | None) -> VerdictRecord | None:
460
+ """Return a verdict if a rule settles the claim, else None (escalate)."""
461
+ fn = RULES.get(claim.type)
462
+ if fn is None:
463
+ return None
464
+ return fn(claim, ledger, RepoState(repo_root))