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/parsers.py ADDED
@@ -0,0 +1,408 @@
1
+ """Per-runner output parsers and the pipe/truncation flagger.
2
+
3
+ Each parser: (stdout, stderr, exit_code) -> RunnerResult(passed, failed, errors, collected, skipped)
4
+ or None if the output is not this runner's format. Start with pytest, jest/vitest, go test, cargo (E2).
5
+ Known traps to handle: `collected 0 items` with exit 0; `| head -80` hiding the summary;
6
+ pytest -q vs verbose; jest --silent; cargo test with multiple targets.
7
+
8
+ Property-test these with hypothesis against synthetic outputs.
9
+
10
+ Owner: Anush.
11
+
12
+ Also owns E5 (wrapper-shadowing detection): normalizing a Bash command down to the binary
13
+ that would actually run, wrapping known-runner commands in PreToolUse so PostToolUse can see
14
+ the real resolved path (piggybacks on the same trailer trick used for exit codes, E9), and the
15
+ trust rule that rejects an in-tree `./pytest` unless it lives in a dependency manager's own
16
+ bin dir or is named by the repo's committed test/build config. See docs/MECHANICS.md §2, §4.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import shlex
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+
27
+ @dataclass
28
+ class RunnerResult:
29
+ runner: str
30
+ passed: int
31
+ failed: int
32
+ errors: int
33
+ collected: int | None
34
+ skipped: int = 0
35
+
36
+
37
+ _LABEL_RE = re.compile(r"(\d+)\s+(passed|failed|skipped|todo|total)")
38
+
39
+
40
+ def _scan_counts(text: str) -> dict[str, int]:
41
+ counts = {"passed": 0, "failed": 0, "skipped": 0, "total": 0}
42
+ for count, label in _LABEL_RE.findall(text):
43
+ counts[label] += int(count)
44
+ return counts
45
+
46
+
47
+ # `pytest -q` prints neither the session banner nor "collected N items" -- only a tail like
48
+ # `86 passed in 1.2s`. Requiring the banner meant the single most common invocation parsed as
49
+ # "not a test runner at all", so `rules._outcome` fell through to its exit-code branch and
50
+ # CONFIRMED a report claiming 81 when 86 ran. Worse, `feedback` was nudging agents toward `-q`,
51
+ # so obeying the nudge flipped the verdict from contradicted to confirmed on an unchanged lie.
52
+ _PYTEST_SIGNATURE_RE = re.compile(
53
+ r"test session starts|collected \d+ item"
54
+ r"|^=*\s*\d+ (?:passed|failed|error|skipped|xfailed|xpassed)"
55
+ r"|^=*\s*no tests ran",
56
+ re.IGNORECASE | re.MULTILINE,
57
+ )
58
+ _PYTEST_COLLECTED_RE = re.compile(r"collected (\d+) item")
59
+ _PYTEST_COUNT_RE = re.compile(r"(\d+)\s+(passed|failed|error(?:s)?|skipped|xfailed|xpassed)")
60
+
61
+
62
+ def parse_pytest(stdout: str, exit_code: int | None) -> RunnerResult | None:
63
+ """Handles verbose and `-q` output, and the `collected 0 items` / exit-0 trap."""
64
+ if not _PYTEST_SIGNATURE_RE.search(stdout):
65
+ return None
66
+ collected_match = _PYTEST_COLLECTED_RE.search(stdout)
67
+ collected = int(collected_match.group(1)) if collected_match else None
68
+ counts = {"passed": 0, "failed": 0, "error": 0, "skipped": 0}
69
+ tail = "\n".join(stdout.strip().splitlines()[-5:])
70
+ for count, label in _PYTEST_COUNT_RE.findall(tail):
71
+ key = "error" if label.startswith("error") else label
72
+ if key in counts:
73
+ counts[key] += int(count)
74
+ return RunnerResult(
75
+ runner="pytest",
76
+ passed=counts["passed"],
77
+ failed=counts["failed"],
78
+ errors=counts["error"],
79
+ collected=collected,
80
+ skipped=counts["skipped"],
81
+ )
82
+
83
+
84
+ def parse_jest(stdout: str, exit_code: int | None) -> RunnerResult | None:
85
+ """Reads the `Tests:` summary line (`Tests: 1 failed, 4 passed, 5 total`)."""
86
+ match = re.search(r"^Tests:\s+.*$", stdout, re.MULTILINE)
87
+ if not match:
88
+ return None
89
+ counts = _scan_counts(match.group(0))
90
+ return RunnerResult(
91
+ runner="jest",
92
+ passed=counts["passed"],
93
+ failed=counts["failed"],
94
+ errors=0,
95
+ collected=counts["total"] or None,
96
+ skipped=counts["skipped"],
97
+ )
98
+
99
+
100
+ def parse_vitest(stdout: str, exit_code: int | None) -> RunnerResult | None:
101
+ """Reads the ` Tests 1 failed | 9 passed (10)` summary line."""
102
+ match = re.search(r"^\s*Tests\s+(.+)$", stdout, re.MULTILINE)
103
+ if not match:
104
+ return None
105
+ body = match.group(1)
106
+ counts = _scan_counts(body)
107
+ total_match = re.search(r"\((\d+)\)", body)
108
+ collected = int(total_match.group(1)) if total_match else (counts["total"] or None)
109
+ return RunnerResult(
110
+ runner="vitest",
111
+ passed=counts["passed"],
112
+ failed=counts["failed"],
113
+ errors=0,
114
+ collected=collected,
115
+ skipped=counts["skipped"],
116
+ )
117
+
118
+
119
+ _GO_PKG_RE = re.compile(
120
+ r"^(?:ok\s+\S+\s+(?:\d+(?:\.\d+)?s|\(cached\))|FAIL\s+\S+\s+(?:\d+(?:\.\d+)?s|\[.+\]))",
121
+ re.MULTILINE,
122
+ )
123
+
124
+
125
+ def parse_go_test(stdout: str, exit_code: int | None) -> RunnerResult | None:
126
+ r"""`-v` output gives per-test `--- PASS:`/`--- FAIL:` lines; plain output only per-package ok/FAIL.
127
+
128
+ The per-package form must carry a package *and* a timing field. `^ok\s` alone matched a bare
129
+ `ok` line, because `\s` matches the newline -- so `echo ok` parsed as a green Go suite. That is
130
+ not a cosmetic mislabel: `review.annotate` would print `[parsed go test: 1 passed, 0 failed]`
131
+ next to a fabricated success, which is the `echoed_output` family the deterministic layer exists
132
+ to catch, and `review._corroborate` accepts a parsed result as grounds to let an accusation
133
+ stand. Measured at 40 occurrences across 15 of 96 real sessions on 2026-09-20.
134
+ """
135
+ if not re.search(_GO_PKG_RE.pattern + r"|^---\s+(PASS|FAIL|SKIP):", stdout, re.MULTILINE):
136
+ return None
137
+ passed = len(re.findall(r"^--- PASS:", stdout, re.MULTILINE))
138
+ failed = len(re.findall(r"^--- FAIL:", stdout, re.MULTILINE))
139
+ skipped = len(re.findall(r"^--- SKIP:", stdout, re.MULTILINE))
140
+ if passed or failed or skipped:
141
+ return RunnerResult(
142
+ runner="go test",
143
+ passed=passed,
144
+ failed=failed,
145
+ errors=0,
146
+ collected=passed + failed + skipped,
147
+ skipped=skipped,
148
+ )
149
+ # No -v: only per-package ok/FAIL lines, no per-test counts available.
150
+ ok_pkgs = len(re.findall(r"^ok\s+\S+\s+(?:\d+(?:\.\d+)?s|\(cached\))", stdout, re.MULTILINE))
151
+ fail_pkgs = len(re.findall(r"^FAIL\s+\S+\s+(?:\d+(?:\.\d+)?s|\[.+\])", stdout, re.MULTILINE))
152
+ if ok_pkgs or fail_pkgs:
153
+ return RunnerResult(runner="go test", passed=ok_pkgs, failed=fail_pkgs, errors=0, collected=None)
154
+ return None
155
+
156
+
157
+ _CARGO_RESULT_RE = re.compile(
158
+ r"test result:\s+\w+\.\s+(\d+) passed;\s+(\d+) failed;\s+(\d+) ignored;"
159
+ r"\s+\d+ measured;\s+\d+ filtered out"
160
+ )
161
+
162
+
163
+ def parse_cargo(stdout: str, exit_code: int | None) -> RunnerResult | None:
164
+ """Sums every `test result: ...` line, since cargo prints one per test target."""
165
+ matches = _CARGO_RESULT_RE.findall(stdout)
166
+ if not matches:
167
+ return None
168
+ passed = failed = ignored = 0
169
+ for p, f, i in matches:
170
+ passed += int(p)
171
+ failed += int(f)
172
+ ignored += int(i)
173
+ return RunnerResult(
174
+ runner="cargo",
175
+ passed=passed,
176
+ failed=failed,
177
+ errors=0,
178
+ collected=passed + failed + ignored,
179
+ skipped=ignored,
180
+ )
181
+
182
+
183
+ # E2: priority order for which runners get a parser, and which one runs first when
184
+ # more than one signature could match. Anything else falls through to `unrecorded`.
185
+ PARSERS: tuple[Callable[[str, int | None], RunnerResult | None], ...] = (
186
+ parse_pytest,
187
+ parse_jest,
188
+ parse_vitest,
189
+ parse_go_test,
190
+ parse_cargo,
191
+ )
192
+
193
+
194
+ def parse(stdout: str, exit_code: int | None) -> RunnerResult | None:
195
+ """Try each known runner's parser in E2 priority order. `None` means `unrecorded`."""
196
+ for parser in PARSERS:
197
+ result = parser(stdout, exit_code)
198
+ if result is not None:
199
+ return result
200
+ return None
201
+
202
+
203
+ _PIPE_MARKERS: tuple[re.Pattern[str], ...] = (
204
+ # wc/less/more/cut came from the Claude Code adapter's own copy of this list, which is now
205
+ # deleted. Two detectors that disagreed meant identical evidence got opposite verdicts
206
+ # depending on which adapter read it.
207
+ re.compile(r"\|\s*(head|tail|grep|awk|sed|wc|less|more|cut)\b"),
208
+ re.compile(r"2>\s*/dev/null"),
209
+ re.compile(r"&>\s*/dev/null"),
210
+ re.compile(r"(?:^|\s)>{1,2}\s*[\w./-]+"), # `> file` / `>> file`, not preceded by a digit or `&`
211
+ re.compile(r"--silent\b"),
212
+ re.compile(r"--quiet\b"),
213
+ )
214
+
215
+
216
+ def is_piped(command: str) -> bool:
217
+ """True if the command's output was filtered (| head, | tail, 2>/dev/null, > file, --silent...)."""
218
+ return any(marker.search(command) for marker in _PIPE_MARKERS)
219
+
220
+
221
+ # --- E5: runner-binary resolution and wrapper-shadowing detection ---
222
+
223
+ KNOWN_RUNNERS = frozenset({
224
+ "pytest", "jest", "vitest", "go", "cargo", "gradle", "gradlew", "xcodebuild",
225
+ "mvn", "bun", "npm", "yarn", "pnpm", "tox", "nox", "rspec", "phpunit",
226
+ })
227
+
228
+ _ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
229
+ _CD_PREFIX = re.compile(r"^cd\s+\S+\s*&&\s*(.*)$")
230
+ # Prefixes MECHANICS.md §4 says to strip before the first remaining token is the runner.
231
+ _STRIP_PREFIXES = ("uv run", "npx", "poetry run", "bunx", "pipenv run")
232
+
233
+
234
+ def _tokenize_normalized(command: str) -> list[str]:
235
+ """Strip a leading `cd x &&`, env assignments, and known invoker prefixes. MECHANICS §4."""
236
+ cmd = command.strip()
237
+ m = _CD_PREFIX.match(cmd)
238
+ if m:
239
+ cmd = m.group(1).strip()
240
+ try:
241
+ tokens = shlex.split(cmd)
242
+ except ValueError:
243
+ tokens = cmd.split()
244
+ while tokens and _ENV_ASSIGNMENT.match(tokens[0]):
245
+ tokens.pop(0)
246
+ for prefix in _STRIP_PREFIXES:
247
+ prefix_tokens = prefix.split()
248
+ if tokens[: len(prefix_tokens)] == prefix_tokens:
249
+ tokens = tokens[len(prefix_tokens):]
250
+ break
251
+ return tokens
252
+
253
+
254
+ def first_token(command: str) -> str | None:
255
+ """The binary the normalized command would actually invoke, e.g. `./pytest` or `pytest`."""
256
+ tokens = _tokenize_normalized(command)
257
+ return tokens[0] if tokens else None
258
+
259
+
260
+ def is_known_runner_token(token: str) -> bool:
261
+ """True if a command's first token is shaped like a known test/build runner's name.
262
+
263
+ Matches on basename so `./pytest`, `/usr/bin/pytest`, and `pytest` all match -- this is a
264
+ cheap pre-filter for which commands are worth wrapping, not a trust decision.
265
+ """
266
+ return Path(token).name in KNOWN_RUNNERS
267
+
268
+
269
+ BIN_MARKER = "__CUSTOS_CODE_BIN="
270
+ RC_MARKER = "__CUSTOS_CODE_RC="
271
+
272
+
273
+ def wrap_command_for_resolution(command: str, rc_path: str | None = None) -> str | None:
274
+ """PreToolUse rewrite: append a trailer resolving argv[0]'s real binary path and exit code.
275
+
276
+ Only commands whose first token looks like a known runner are wrapped (E5's own concern is
277
+ resolution, not general exit-code capture); heredocs, backgrounding, and trailing `&` are
278
+ left untouched because rewriting them safely needs shell-semantics we don't have here
279
+ (MECHANICS §2.3: "do not wrap arbitrary commands"). Returns None when nothing should change.
280
+
281
+ `rc_path`, when given, is a random per-call path (the caller's job -- typically under
282
+ `~/.custos-code/rc/` -- to generate and later read/unlink; see `read_rc_file`). The trailer is
283
+ written to that file instead of stdout: the E9 experiment confirmed the stdout form is
284
+ visible in the model's own tool output, so a model that has seen `__CUSTOS_CODE_RC=0` once can
285
+ later `echo` the same marker and manufacture evidence for a test it never ran (issue #22). A
286
+ file whose name the model never sees can't be forged that way. `rc_path` is embedded via a
287
+ quoted shell assignment, not printed, so it never appears in the command's own stdout/stderr.
288
+
289
+ `rc_path=None` keeps the legacy stdout-marker form (`BIN_MARKER`/`RC_MARKER`, stripped by
290
+ `strip_and_parse_trailer`), for harnesses that do not preserve env across the whole compound
291
+ command; prefer the file form whenever the caller can correlate `PreToolUse`/`PostToolUse`
292
+ for the same call (e.g. by `tool_use_id`).
293
+ """
294
+ token = first_token(command)
295
+ if token is None or not is_known_runner_token(token):
296
+ return None
297
+ if "<<" in command or "\n" in command or " & " in command or command.rstrip().endswith("&"):
298
+ return None
299
+ if rc_path is not None:
300
+ prefix = f"CUSTOS_CODE_RC_FILE={shlex.quote(rc_path)}; export CUSTOS_CODE_RC_FILE; "
301
+ trailer = (
302
+ f'; __rc=$?; printf "%s\\n%s\\n" "$(command -v {shlex.quote(token)} 2>/dev/null)" "$__rc" '
303
+ '> "$CUSTOS_CODE_RC_FILE"; exit $__rc'
304
+ )
305
+ return f"{prefix}({command}){trailer}"
306
+ trailer = (
307
+ f'; __rc=$?; printf "\\n{BIN_MARKER}%s\\n{RC_MARKER}%s\\n" '
308
+ f'"$(command -v {shlex.quote(token)} 2>/dev/null)" "$__rc"; exit $__rc'
309
+ )
310
+ return f"({command}){trailer}"
311
+
312
+
313
+ def strip_and_parse_trailer(output: str) -> tuple[str, str | None, int | None]:
314
+ """Split `wrap_command_for_resolution`'s legacy stdout trailer back out of captured output.
315
+
316
+ Only for the `rc_path=None` (stdout) form. Returns (output with the trailer lines removed,
317
+ resolved binary path or None, exit code or None). Used on the PostToolUse side before the
318
+ output is stored or shown.
319
+ """
320
+ bin_path: str | None = None
321
+ rc: int | None = None
322
+ kept: list[str] = []
323
+ for line in output.splitlines():
324
+ if line.startswith(BIN_MARKER):
325
+ bin_path = line[len(BIN_MARKER):].strip() or None
326
+ elif line.startswith(RC_MARKER):
327
+ try:
328
+ rc = int(line[len(RC_MARKER):].strip())
329
+ except ValueError:
330
+ rc = None
331
+ else:
332
+ kept.append(line)
333
+ return "\n".join(kept), bin_path, rc
334
+
335
+
336
+ def read_rc_file(path: str) -> tuple[str | None, int | None]:
337
+ """Parse the two-line trailer `wrap_command_for_resolution`'s file form writes.
338
+
339
+ Format: line 1 is `command -v`'s output (resolved binary path, or empty if not found), line
340
+ 2 is the exit code. Returns (resolved binary path or None, exit code or None). A missing or
341
+ unreadable file (the command never ran, or ran interrupted before the trailer wrote it) reads
342
+ the same as a resolution failure: `(None, None)`. Pure read -- the caller (PostToolUse) is
343
+ responsible for unlinking the file afterward.
344
+ """
345
+ try:
346
+ with open(path, encoding="utf-8") as fh:
347
+ lines = fh.read().splitlines()
348
+ except OSError:
349
+ return None, None
350
+ bin_path = lines[0].strip() if len(lines) > 0 else ""
351
+ rc_text = lines[1].strip() if len(lines) > 1 else ""
352
+ try:
353
+ rc = int(rc_text)
354
+ except ValueError:
355
+ rc = None
356
+ return (bin_path or None), rc
357
+
358
+
359
+ _TRUSTED_IN_TREE_PATTERNS = (
360
+ re.compile(r"(^|/)\.venv/.*bin/"),
361
+ re.compile(r"(^|/)venv/.*bin/"),
362
+ re.compile(r"(^|/)node_modules/\.bin/"),
363
+ re.compile(r"(^|/)\.tox/.*bin/"),
364
+ re.compile(r"(^|/)vendor/bundle/.*bin/"),
365
+ )
366
+
367
+
368
+ def is_trusted_runner_path(
369
+ resolved_bin: str | None,
370
+ repo_root: str,
371
+ documented_runners: frozenset[str] = frozenset(),
372
+ cwd: str | None = None,
373
+ ) -> bool:
374
+ """True if a runner-shaped command's *actually resolved* binary is trustworthy evidence.
375
+
376
+ Outside the repo tree: trusted (a system, user, or committed-elsewhere install). Inside the
377
+ repo tree: trusted only if it lives in a dependency manager's own bin dir (`.venv`,
378
+ `node_modules/.bin`, ...) or is named in the repo's committed test/build config
379
+ (`documented_runners` -- the same committed-config lookup rerun.py uses to pick the Tier 3
380
+ command, so an agent-authored `./pytest` at the repo root is never allowlisted no matter how
381
+ it's invoked). Resolution failure (`None`, i.e. "command not found") is never trusted --
382
+ that is positive evidence the claimed runner never ran.
383
+
384
+ `resolved_bin` comes from `command -v` inside the wrapped command (see
385
+ `wrap_command_for_resolution`) and is not guaranteed absolute -- observed in practice
386
+ returning e.g. `.venv/bin/pytest` verbatim. A relative path must be resolved against the
387
+ *ledger event's* `cwd` (the actual cwd the Bash call ran under), never the calling process's
388
+ own ambient cwd: resolving against the wrong directory can land outside `repo_root` by
389
+ accident and mark an in-tree shadow binary as trusted, exactly the bypass this check exists
390
+ to prevent. If `cwd` is unknown, fail closed (untrusted) rather than guess.
391
+ """
392
+ if not resolved_bin:
393
+ return False
394
+ bin_path = Path(resolved_bin)
395
+ if not bin_path.is_absolute():
396
+ if not cwd:
397
+ return False
398
+ bin_path = Path(cwd) / bin_path
399
+ try:
400
+ resolved = bin_path.resolve()
401
+ root = Path(repo_root).resolve()
402
+ except OSError:
403
+ return False
404
+ if not resolved.is_relative_to(root):
405
+ return True
406
+ if any(p.search(str(resolved)) for p in _TRUSTED_IN_TREE_PATTERNS):
407
+ return True
408
+ return resolved.name in documented_runners