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/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
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))
|