code-jig 0.12.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.
Files changed (52) hide show
  1. code_jig-0.12.0.dist-info/METADATA +154 -0
  2. code_jig-0.12.0.dist-info/RECORD +52 -0
  3. code_jig-0.12.0.dist-info/WHEEL +4 -0
  4. code_jig-0.12.0.dist-info/entry_points.txt +2 -0
  5. code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
  6. codejig/__init__.py +1 -0
  7. codejig/__main__.py +106 -0
  8. codejig/_core/VERSION +2 -0
  9. codejig/_core/bench/requirements.toml +4 -0
  10. codejig/_core/core/AGENTS.core.md +9 -0
  11. codejig/_core/core/agents/explorer.toml +24 -0
  12. codejig/_core/core/agents/judge.toml +19 -0
  13. codejig/_core/core/agents/reviewer.toml +10 -0
  14. codejig/_core/core/tiers.toml +4 -0
  15. codejig/_core/skills/bug-hunt/SKILL.md +131 -0
  16. codejig/_core/skills/code-analysis/SKILL.md +72 -0
  17. codejig/_core/skills/codegen/SKILL.md +46 -0
  18. codejig/_core/skills/cross-review/SKILL.md +32 -0
  19. codejig/_core/skills/grilling/SKILL.md +60 -0
  20. codejig/_core/skills/hld-trace/SKILL.md +69 -0
  21. codejig/_core/skills/improve/SKILL.md +38 -0
  22. codejig/_core/skills/redacao/SKILL.md +114 -0
  23. codejig/_core/skills/redacao/checar.py +75 -0
  24. codejig/_core/skills/redacao/reference.md +118 -0
  25. codejig/_core/skills/regression-tests/SKILL.md +53 -0
  26. codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
  27. codejig/_core/skills/skill-validate/SKILL.md +58 -0
  28. codejig/_core/skills/web-consensus/SKILL.md +55 -0
  29. codejig/analyze.py +499 -0
  30. codejig/atomicwrite.py +49 -0
  31. codejig/collect.py +311 -0
  32. codejig/doctor.py +370 -0
  33. codejig/evidence.py +418 -0
  34. codejig/feedback.py +165 -0
  35. codejig/gitgate.py +278 -0
  36. codejig/hooks.py +64 -0
  37. codejig/humanize.py +206 -0
  38. codejig/install.py +381 -0
  39. codejig/managed.py +64 -0
  40. codejig/migrate.py +515 -0
  41. codejig/overlay.py +43 -0
  42. codejig/paths.py +40 -0
  43. codejig/probe.py +136 -0
  44. codejig/review.py +73 -0
  45. codejig/scrub.py +255 -0
  46. codejig/skillcheck.py +400 -0
  47. codejig/skilllint.py +99 -0
  48. codejig/sources.py +248 -0
  49. codejig/tiers.py +63 -0
  50. codejig/tokens.py +118 -0
  51. codejig/trace.py +137 -0
  52. codejig/watermark.py +147 -0
codejig/evidence.py ADDED
@@ -0,0 +1,418 @@
1
+ import codecs
2
+ import json
3
+ import re
4
+ import statistics
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from collections import Counter, defaultdict
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime, timedelta
11
+ from functools import partial
12
+ from pathlib import Path
13
+
14
+ from codejig import analyze, collect, paths
15
+
16
+ TEST_MARKERS = ("pytest", "unittest", "dotnet test", "npm test", "pnpm test", "yarn test", "run test", "vitest", "jest")
17
+ GRACE = timedelta(hours=2)
18
+ REWORK_DAYS = 14
19
+ MIN_COMMITS = 20
20
+ CAVEAT = "This is correlation, not causation: session work and baseline work are different tasks."
21
+ HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
22
+ SESSIONS = """
23
+ SELECT t.id, t.cwd, t.started, MAX(x.ts) FROM threads t
24
+ LEFT JOIN (
25
+ SELECT thread_id AS owner, ts FROM usage
26
+ UNION ALL SELECT thread_id, ts FROM calls
27
+ UNION ALL SELECT c.parent_id, u.ts FROM usage u JOIN threads c ON c.id = u.thread_id WHERE c.kind != 'main'
28
+ ) x ON x.owner = t.id
29
+ WHERE t.kind = 'main' AND t.started >= ?
30
+ GROUP BY t.id
31
+ """
32
+ RECORD = "\x00\x01"
33
+ LOG_FORMAT = "--format=%x00%x01%H%x00%aI%x00%s%x00%b%x00"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Commit:
38
+ sha: str
39
+ when: datetime
40
+ subject: str
41
+ body: str
42
+ hunks: dict[str, list[tuple[int, int, int, int]]] = field(default_factory=dict)
43
+
44
+
45
+ def is_test(cmd: str) -> bool:
46
+ return any(m in cmd for m in TEST_MARKERS)
47
+
48
+
49
+ def _unquote(name: str) -> str:
50
+ if len(name) > 1 and name[0] == name[-1] == '"':
51
+ return codecs.escape_decode(name[1:-1].encode("utf-8"))[0].decode("utf-8", "replace")
52
+ return name
53
+
54
+
55
+ def _file(line: str, old: str | None) -> tuple[str | None, str | None]:
56
+ raw = _unquote(line[4:].rstrip("\t"))
57
+ name = raw[2:] if raw[:2] in ("a/", "b/") else None
58
+ return (name, None) if line.startswith("---") else (old, name or old)
59
+
60
+
61
+ def _add_hunk(hunks: dict, path: str | None, line: str) -> None:
62
+ m = HUNK.match(line)
63
+ if m and path:
64
+ a, b, n, d = m.groups()
65
+ hunks.setdefault(path, []).append((int(a), int(b or 1), int(n), int(d or 1)))
66
+
67
+
68
+ def _hunks(diff: str) -> dict[str, list[tuple[int, int, int, int]]]:
69
+ hunks: dict[str, list[tuple[int, int, int, int]]] = {}
70
+ old = path = None
71
+ header = False
72
+ for line in diff.splitlines():
73
+ if line.startswith("diff --git"):
74
+ old = path = None
75
+ header = True
76
+ elif header and line.startswith(("--- ", "+++ ")):
77
+ old, path = _file(line, old)
78
+ elif line.startswith("@@"):
79
+ header = False
80
+ _add_hunk(hunks, path, line)
81
+ return hunks
82
+
83
+
84
+ def parse_log(text: str) -> list[Commit]:
85
+ commits = []
86
+ for chunk in text.split(RECORD)[1:]:
87
+ sha, when, subject, body, diff = chunk.split("\x00", 4)
88
+ commits.append(Commit(sha, datetime.fromisoformat(when), subject, body, _hunks(diff)))
89
+ return commits
90
+
91
+
92
+ def in_window(when: datetime, windows: list[tuple[datetime, datetime]]) -> bool:
93
+ return any(start <= when <= end + GRACE for start, end in windows)
94
+
95
+
96
+ def _same_lines(target: Commit, later: Commit) -> bool:
97
+ for path, hunks in later.hunks.items():
98
+ mine = target.hunks.get(path, ())
99
+ for a, b, _, _ in hunks:
100
+ if b and any(d and a <= n + d - 1 and n <= a + b - 1 for _, _, n, d in mine):
101
+ return True
102
+ return False
103
+
104
+
105
+ def reworked(target: Commit, every: list[Commit], days: int = REWORK_DAYS) -> bool:
106
+ end = target.when + timedelta(days=days)
107
+ names = (f'Revert "{target.subject}"', f"fixup! {target.subject}", f"squash! {target.subject}")
108
+ for later in every:
109
+ if not target.when < later.when <= end:
110
+ continue
111
+ if later.subject in names or target.sha in later.body or _same_lines(target, later):
112
+ return True
113
+ return False
114
+
115
+
116
+ def rate(commits: int, mature: int, reworked: int) -> dict:
117
+ return {"commits": commits, "mature": mature, "reworked": reworked,
118
+ "rework_rate": reworked / mature if mature >= MIN_COMMITS else None}
119
+
120
+
121
+ def group_stats(group: list[Commit], every: list[Commit], now: datetime) -> dict:
122
+ mature = [c for c in group if c.when <= now - timedelta(days=REWORK_DAYS)]
123
+ return rate(len(group), len(mature), sum(reworked(c, every) for c in mature))
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Session:
128
+ id: str
129
+ cwd: str | None
130
+ start: datetime
131
+ end: datetime
132
+
133
+
134
+ def _ts(text: str) -> datetime:
135
+ return datetime.fromisoformat(text)
136
+
137
+
138
+ def _iso(when: datetime) -> str:
139
+ return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
140
+
141
+
142
+ def sessions(con, since: str) -> list[Session]:
143
+ out = []
144
+ for tid, cwd, started, last in con.execute(SESSIONS, (since,)):
145
+ start = _ts(started)
146
+ out.append(Session(tid, cwd, start, max(start, _ts(last)) if last else start))
147
+ return out
148
+
149
+
150
+ def _git(*args: str) -> subprocess.CompletedProcess | None:
151
+ try:
152
+ return subprocess.run(["git", "-c", "core.quotepath=false", *args], capture_output=True, text=True,
153
+ encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, check=False)
154
+ except OSError:
155
+ return None
156
+
157
+
158
+ def repo_root(cwd: str | None) -> Path | None:
159
+ if not cwd or not Path(cwd).is_dir():
160
+ return None
161
+ r = _git("-C", cwd, "rev-parse", "--show-toplevel")
162
+ return Path(r.stdout.strip()).resolve() if r and r.returncode == 0 and r.stdout.strip() else None
163
+
164
+
165
+ def git_log(root: Path, since: datetime) -> list[Commit]:
166
+ r = _git("-C", str(root), "log", "--no-merges", f"--since={_iso(since)}", LOG_FORMAT, "-p", "--unified=0",
167
+ "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "--src-prefix=a/", "--dst-prefix=b/")
168
+ return parse_log(r.stdout) if r and r.returncode == 0 else []
169
+
170
+
171
+ def by_repo(sess: list[Session]) -> tuple[dict[Path, list[Session]], list[str]]:
172
+ repos: dict[Path, list[Session]] = {}
173
+ unreachable: list[str] = []
174
+ roots: dict[str | None, Path | None] = {}
175
+ for s in sess:
176
+ if s.cwd not in roots:
177
+ roots[s.cwd] = repo_root(s.cwd)
178
+ root = roots[s.cwd]
179
+ if root is None:
180
+ unreachable.append(s.cwd or "(no cwd)")
181
+ else:
182
+ repos.setdefault(root, []).append(s)
183
+ return repos, sorted(set(unreachable))
184
+
185
+
186
+ def _owners(con, since: str | None = None):
187
+ query = "SELECT c.thread_id, t.kind, t.parent_id, c.cmd, c.exit_code, c.escalated, c.ts FROM call_view c LEFT JOIN threads t ON t.id = c.thread_id"
188
+ for tid, kind, parent, cmd, code, esc, ts in con.execute(query):
189
+ if since is None or (ts or "") >= since:
190
+ yield (parent if kind != "main" and parent else tid), cmd or "", code, esc
191
+
192
+
193
+ def _skill_reads(con, since: str):
194
+ query = ("SELECT s.skill, c.thread_id, t.kind, t.parent_id FROM skill_reads s "
195
+ "JOIN calls c ON c.thread_id = s.thread_id AND c.call_id = s.call_id LEFT JOIN threads t ON t.id = c.thread_id WHERE c.ts >= ?")
196
+ for skill, tid, kind, parent in con.execute(query, (since,)):
197
+ yield skill, (parent if kind != "main" and parent else tid)
198
+
199
+
200
+ def discipline(con, owners: set[str]) -> dict:
201
+ with_tests, runs, passed = set(), 0, 0
202
+ for owner, cmd, code, _ in _owners(con):
203
+ if owner in owners and is_test(cmd):
204
+ with_tests.add(owner)
205
+ if code is not None:
206
+ runs += 1
207
+ passed += code == 0
208
+ return {"sessions": len(owners), "with_tests": len(with_tests), "test_runs": runs, "passed": passed}
209
+
210
+
211
+ def installed_skills() -> list[str]:
212
+ root = paths.agents_skills()
213
+ return sorted(p.parent.name for p in root.glob("*/SKILL.md")) if root.is_dir() else []
214
+
215
+
216
+ def use(con, since: str, installed: list[str]) -> dict:
217
+ fired: dict[str, set[str]] = defaultdict(set)
218
+ calls = escalated = 0
219
+ for _, _, _, esc in _owners(con, since):
220
+ calls += 1
221
+ escalated += esc
222
+ for skill, owner in _skill_reads(con, since):
223
+ fired[skill].add(owner)
224
+ skills = {k: len(v) for k, v in sorted(fired.items())}
225
+ guardian = dict(con.execute("SELECT outcome, COUNT(*) FROM guardian WHERE ts >= ? GROUP BY outcome ORDER BY outcome", (since,)))
226
+ return {"skills": skills, "never": sorted(set(installed) - set(skills)), "guardian": guardian,
227
+ "calls": calls, "escalated": escalated}
228
+
229
+
230
+ def _tokens(con, since: str, ids: set[str]) -> tuple[dict[str, int], int, int]:
231
+ tokens: dict[str, int] = defaultdict(int)
232
+ inp = cached = 0
233
+ for tid, kind, parent, i, c, o in con.execute("SELECT thread_id, kind, parent_id, input, cached, output FROM turn_usage WHERE ts >= ?", (since,)):
234
+ owner = parent if kind != "main" and parent else tid
235
+ if owner in ids:
236
+ tokens[owner] += i + o
237
+ inp += i
238
+ cached += c
239
+ return tokens, inp, cached
240
+
241
+
242
+ def cost(con, since: str, sess: list[Session], in_repos: list[Session], harness_commits: int) -> dict:
243
+ ids, repo_ids = {s.id for s in sess}, {s.id for s in in_repos}
244
+ tokens, inp, cached = _tokens(con, since, ids)
245
+ total = sum(tokens.values())
246
+ repo_total = sum(v for k, v in tokens.items() if k in repo_ids)
247
+ minutes = [(s.end - s.start).total_seconds() / 60 for s in sess]
248
+ return {"sessions": len(ids), "tokens": total, "tokens_per_session": total / len(ids) if ids else 0,
249
+ "tokens_per_harness_commit": repo_total / harness_commits if harness_commits else None,
250
+ "cache_hit": cached / inp if inp else 0.0,
251
+ "compactions": con.execute("SELECT COUNT(*) FROM compactions WHERE ts >= ?", (since,)).fetchone()[0],
252
+ "median_minutes": statistics.median(minutes) if minutes else 0}
253
+
254
+
255
+ def sample(commits: list[Commit], n: int) -> list[Commit]:
256
+ if n <= 0 or not commits:
257
+ return []
258
+ if n >= len(commits):
259
+ return list(commits)
260
+ step = len(commits) / n
261
+ return [commits[int(i * step)] for i in range(n)]
262
+
263
+
264
+ def changed_lines(c: Commit) -> int:
265
+ return sum(d for hunks in c.hunks.values() for _, _, _, d in hunks)
266
+
267
+
268
+ def in_changed_lines(found: list, c: Commit) -> list:
269
+ return [f for f in found if any(d and n <= f.line < n + d for _, _, n, d in c.hunks.get(f.path, ()))]
270
+
271
+
272
+ def analyze_commit(root: Path, c: Commit, timeout: int = 600, path: str | None = None) -> list | None:
273
+ tools = [t for t in analyze.TOOLS if t.name != "gitleaks-history"]
274
+ with tempfile.TemporaryDirectory() as tmp:
275
+ hooks = Path(tmp) / "hooks"
276
+ hooks.mkdir()
277
+ wt = Path(tmp) / "wt"
278
+ g = ("-c", f"core.hooksPath={hooks}", "-C", str(root))
279
+ made = _git(*g, "worktree", "add", "--detach", "--quiet", str(wt), c.sha)
280
+ if not made or made.returncode != 0:
281
+ return None
282
+ try:
283
+ ran = analyze.run_tools(tools, wt, analyze.detect(wt), path=path, timeout=timeout)
284
+ return in_changed_lines(ran.findings, c) if ran.ran and not ran.errors else None
285
+ finally:
286
+ _git(*g, "worktree", "remove", "--force", str(wt))
287
+
288
+
289
+ def _quality(cats: Counter, lines: int, sampled: int, failed: int) -> dict:
290
+ return {"sampled": sampled, "failed": failed, "changed_lines": lines, "findings": dict(cats),
291
+ "per_100_lines": {k: v * 100 / lines for k, v in cats.items()} if lines else {}}
292
+
293
+
294
+ def quality(root: Path, group: list[Commit], n: int, runner) -> dict | None:
295
+ if n <= 0:
296
+ return None
297
+ picked = sample(group, n)
298
+ cats: Counter = Counter()
299
+ done = []
300
+ for c in picked:
301
+ found = runner(root, c)
302
+ if found is not None:
303
+ done.append(c)
304
+ cats.update(f.category for f in found)
305
+ return _quality(cats, sum(changed_lines(c) for c in done), len(done), len(picked) - len(done))
306
+
307
+
308
+ def _total_quality(items: list[dict | None]) -> dict | None:
309
+ items = [q for q in items if q]
310
+ if not items:
311
+ return None
312
+ cats: Counter = Counter()
313
+ for q in items:
314
+ cats.update(q["findings"])
315
+ return _quality(cats, sum(q["changed_lines"] for q in items), sum(q["sampled"] for q in items), sum(q["failed"] for q in items))
316
+
317
+
318
+ def repo_report(root: Path, sess: list[Session], since: datetime, now: datetime, analyze_n: int = 0, runner=None) -> dict:
319
+ every = git_log(root, since)
320
+ windows = [(s.start, s.end) for s in sess]
321
+ groups = {"harness": [c for c in every if in_window(c.when, windows)],
322
+ "baseline": [c for c in every if not in_window(c.when, windows)]}
323
+ runner = runner or analyze_commit
324
+ out = {"root": str(root), "sessions": len(sess)}
325
+ for name, group in groups.items():
326
+ out[name] = group_stats(group, every, now) | {"quality": quality(root, group, analyze_n, runner)}
327
+ return out
328
+
329
+
330
+ def _total(groups: list[dict]) -> dict:
331
+ return rate(sum(g["commits"] for g in groups), sum(g["mature"] for g in groups), sum(g["reworked"] for g in groups))
332
+
333
+
334
+ def report(con, since: datetime, now: datetime, repo: Path | None = None, analyze_n: int = 0, runner=None, timeout: int = 600) -> dict:
335
+ iso = _iso(since)
336
+ sess = sessions(con, iso)
337
+ repos, unreachable = by_repo(sess)
338
+ if repo is not None:
339
+ root = repo_root(str(repo))
340
+ repos = {root: repos.get(root, [])} if root else {}
341
+ runner = runner or partial(analyze_commit, timeout=timeout)
342
+ per = [repo_report(root, s, since, now, analyze_n, runner) for root, s in sorted(repos.items())]
343
+ totals = {g: _total([r[g] for r in per]) | {"quality": _total_quality([r[g]["quality"] for r in per])} for g in ("harness", "baseline")}
344
+ in_repos = [s for group in repos.values() for s in group]
345
+ return {"since": iso, "days": (now - since).days, "repos": per, "totals": totals, "unreachable": unreachable,
346
+ "discipline": discipline(con, {s.id for s in in_repos}),
347
+ "use": use(con, iso, installed_skills()),
348
+ "cost": cost(con, iso, sess, in_repos, totals["harness"]["commits"])}
349
+
350
+
351
+ def _pct(a: float, b: float) -> str:
352
+ return f"{100 * a / b:.1f}%" if b else "n/a"
353
+
354
+
355
+ def _group(name: str, g: dict) -> list[str]:
356
+ if g["rework_rate"] is None:
357
+ rework = f"rework insufficient (n={g['mature']} mature, need {MIN_COMMITS})"
358
+ else:
359
+ rework = f"rework {_pct(g['reworked'], g['mature'])} ({g['reworked']} of {g['mature']} mature)"
360
+ lines = [f" {name:<9} commits {g['commits']}, {rework}"]
361
+ q = g.get("quality")
362
+ if q:
363
+ cats = ", ".join(f"{k} {v:.1f}" for k, v in sorted(q["per_100_lines"].items())) or "none"
364
+ failed = f", {q['failed']} could not be analyzed" if q["failed"] else ""
365
+ lines.append(f" {'':<9} findings per 100 changed lines: {cats} ({q['sampled']} commits, {q['changed_lines']} lines{failed})")
366
+ return lines
367
+
368
+
369
+ def _usage_lines(d: dict, u: dict) -> list[str]:
370
+ skills = ", ".join(f"{n} {v}" for n, v in u["skills"].items()) or "none fired"
371
+ never = f"; never fired: {', '.join(u['never'])}" if u["never"] else ""
372
+ guardian = ", ".join(f"{n} {v}" for n, v in u["guardian"].items()) or "no reviews"
373
+ return [f"Testing {d['with_tests']} of {d['sessions']} sessions ran tests; {d['passed']} of {d['test_runs']} runs passed ({_pct(d['passed'], d['test_runs'])})",
374
+ f"Skills {skills}{never}",
375
+ f"Guardian {guardian}; {u['escalated']} of {u['calls']} calls escalated ({_pct(u['escalated'], u['calls'])})"]
376
+
377
+
378
+ def _cost_line(k: dict) -> str:
379
+ per_commit = f", {k['tokens_per_harness_commit']:,.0f} per harness commit" if k["tokens_per_harness_commit"] else ""
380
+ return (f"Cost {k['sessions']} sessions, {k['tokens']:,} tokens ({k['tokens_per_session']:,.0f} per session{per_commit}), "
381
+ f"cache {100 * k['cache_hit']:.1f}%, {k['compactions']} compactions, median {k['median_minutes']:.0f} min")
382
+
383
+
384
+ def render(data: dict) -> list[str]:
385
+ out = [f"Evidence for the last {data['days']} days (since {data['since']})", ""]
386
+ for r in data["repos"]:
387
+ out.append(f"{analyze.printable(r['root'])} ({r['sessions']} sessions)")
388
+ out += _group("harness", r["harness"]) + _group("baseline", r["baseline"])
389
+ out.append("All repos")
390
+ out += _group("harness", data["totals"]["harness"]) + _group("baseline", data["totals"]["baseline"])
391
+ out += ["", *_usage_lines(data["discipline"], data["use"]), _cost_line(data["cost"])]
392
+ if data["unreachable"]:
393
+ out += ["", "Sessions outside any git repo: " + ", ".join(analyze.printable(p) for p in data["unreachable"])]
394
+ return out + ["", CAVEAT]
395
+
396
+
397
+ def cli(args) -> int:
398
+ analyze.utf8_stdout()
399
+ repo = None
400
+ if args.repo:
401
+ repo = repo_root(args.repo)
402
+ if repo is None:
403
+ print(f"code-jig: {args.repo} is not a git repository", file=sys.stderr)
404
+ return 2
405
+ home = paths.codex_home(args.codex_home)
406
+ con = collect.connect(collect.db_path())
407
+ try:
408
+ collect.scan(con, home)
409
+ now = datetime.now(UTC)
410
+ data = report(con, now - timedelta(days=args.days), now, repo, args.analyze, timeout=args.timeout)
411
+ finally:
412
+ con.close()
413
+ if args.json:
414
+ print(json.dumps(data, ensure_ascii=False, indent=1))
415
+ else:
416
+ for line in render(data):
417
+ print(line)
418
+ return 0
codejig/feedback.py ADDED
@@ -0,0 +1,165 @@
1
+ import ast
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ from codejig.atomicwrite import resolve_inside
11
+
12
+ MAX_LINES = 10
13
+ MAX_BYTES = 1_000_000
14
+ RUFF_SELECT = "E9,F63,F7,F82"
15
+ DELETE_PATH = re.compile(r"^\*\*\* Delete File: (.+?)\s*$", re.MULTILINE)
16
+ FILE_HEADER = re.compile(r"^\*\*\* (?:Add File|Update File|Move to): (.+?)\s*$")
17
+ TEST_PATH = re.compile(
18
+ r"(?:^|/)(?:tests?|__tests__|specs?)/"
19
+ r"|(?:^|/)test_[^/]*\.py$"
20
+ r"|_test\.(?:py|go)$"
21
+ r"|\.(?:test|spec)\.[cm]?[jt]sx?$"
22
+ r"|Tests?\.cs$"
23
+ r"|\.Tests?/"
24
+ )
25
+ SKIP_MARKER = re.compile(
26
+ r"@pytest\.mark\.(?:skip|skipif|xfail)\b"
27
+ r"|\bpytest\.skip\("
28
+ r"|@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b"
29
+ r"|\b(?:it|test|describe)\.(?:skip|todo)\("
30
+ r"|\bx(?:it|describe|test)\("
31
+ r"|\[(?:Fact|Theory|Test)\([^)\]]*\bSkip\s*="
32
+ r"|\[Ignore\b"
33
+ r"|\bAssert\.Inconclusive\("
34
+ r"|@(?:Disabled|Ignore)\b"
35
+ r"|\bt\.Skip(?:Now)?\("
36
+ )
37
+
38
+
39
+ def patch_bodies(tool_input: object) -> list[str]:
40
+ values = tool_input.values() if isinstance(tool_input, dict) else [tool_input]
41
+ bodies = []
42
+ for value in values:
43
+ for item in value if isinstance(value, list) else [value]:
44
+ if isinstance(item, str) and item.lstrip().startswith("*** Begin Patch"):
45
+ bodies.append(item)
46
+ return bodies
47
+
48
+
49
+ def cap(lines: list[str]) -> str:
50
+ shown = lines[:MAX_LINES]
51
+ if len(lines) > MAX_LINES:
52
+ shown.append(f"... {len(lines) - MAX_LINES} more")
53
+ return "\n".join(shown)
54
+
55
+
56
+ def _ruff(root: Path) -> str | None:
57
+ found = shutil.which("ruff")
58
+ if not found or Path(os.path.realpath(found)).is_relative_to(root):
59
+ return None
60
+ return found
61
+
62
+
63
+ def _python(real: Path, rel: str, text: str, root: Path) -> list[str]:
64
+ ruff = _ruff(root)
65
+ if ruff:
66
+ try:
67
+ r = subprocess.run([ruff, "check", "--isolated", "--no-cache", "--quiet", "--no-fix",
68
+ "--output-format", "concise", "--select", RUFF_SELECT, "--", rel],
69
+ cwd=root, capture_output=True, text=True, encoding="utf-8",
70
+ errors="replace", timeout=5, stdin=subprocess.DEVNULL, check=False)
71
+ if r.returncode in (0, 1):
72
+ return [ln for ln in r.stdout.splitlines() if ln.strip()]
73
+ except (OSError, subprocess.TimeoutExpired):
74
+ pass
75
+ try:
76
+ ast.parse(text, filename=rel)
77
+ except SyntaxError as e:
78
+ return [f"{rel}:{e.lineno}:{e.offset}: SyntaxError {e.msg}"]
79
+ return []
80
+
81
+
82
+ def _json(rel: str, text: str) -> list[str]:
83
+ try:
84
+ json.loads(text)
85
+ except json.JSONDecodeError as e:
86
+ return [f"{rel}:{e.lineno}:{e.colno}: {e.msg}"]
87
+ return []
88
+
89
+
90
+ def _toml(rel: str, text: str) -> list[str]:
91
+ try:
92
+ tomllib.loads(text)
93
+ except tomllib.TOMLDecodeError as e:
94
+ return [f"{rel}: {e}"]
95
+ return []
96
+
97
+
98
+ def lint(path: str, root: str | None) -> str | None:
99
+ """Errors in a file the agent just wrote, or None when it is clean or not checkable.
100
+
101
+ Only parsers that never run repository code: ruff with no config and a fixed
102
+ rule set, or the standard library. The file must be a regular file inside root.
103
+ """
104
+ base = Path(os.path.realpath(root if root is not None else os.getcwd()))
105
+ real = resolve_inside(path, base)
106
+ if real is None or real.suffix not in (".py", ".json", ".toml") or real.stat().st_size > MAX_BYTES:
107
+ return None
108
+ try:
109
+ text = real.read_text(encoding="utf-8-sig")
110
+ except (OSError, UnicodeDecodeError):
111
+ return None
112
+ rel = real.relative_to(base).as_posix()
113
+ if real.suffix == ".py":
114
+ found = _python(real, rel, text, base)
115
+ elif real.suffix == ".json":
116
+ found = _json(rel, text)
117
+ else:
118
+ found = _toml(rel, text)
119
+ return f"lint: {rel}\n{cap(found)}" if found else None
120
+
121
+
122
+ def is_test_path(path: str) -> bool:
123
+ return bool(TEST_PATH.search(path.replace("\\", "/")))
124
+
125
+
126
+ def _note(path: str, what: str) -> str:
127
+ return (f"tests: {path}: {what}. Unless the user asked for this, restore the test "
128
+ f"and fix the code instead.")
129
+
130
+
131
+ def _patch_notes(body: str) -> list[str]:
132
+ notes = [_note(p, f"deleted test file {p}") for p in DELETE_PATH.findall(body) if is_test_path(p)]
133
+ current, added = None, {}
134
+ for line in body.splitlines():
135
+ header = FILE_HEADER.match(line)
136
+ if header:
137
+ current = header.group(1)
138
+ elif current and line.startswith("+"):
139
+ for m in SKIP_MARKER.finditer(line):
140
+ added.setdefault(current, []).append(m.group(0))
141
+ notes += [_note(p, "added " + ", ".join(dict.fromkeys(ms))) for p, ms in added.items()]
142
+ return notes
143
+
144
+
145
+ def _edit_notes(path: str, pairs: list[tuple[str, str]]) -> list[str]:
146
+ found = []
147
+ for old, new in pairs:
148
+ before = {m.group(0) for m in SKIP_MARKER.finditer(old)}
149
+ found += [m.group(0) for m in SKIP_MARKER.finditer(new) if m.group(0) not in before]
150
+ return [_note(path, "added " + ", ".join(dict.fromkeys(found)))] if found else []
151
+
152
+
153
+ def _edit_pairs(tool_name: object, tool_input: dict) -> list[tuple[str, str]]:
154
+ edits = tool_input.get("edits") if tool_name == "MultiEdit" else [tool_input]
155
+ pairs = [(e.get("old_string") or "", e.get("new_string") or "") for e in edits or [] if isinstance(e, dict)]
156
+ return [(o, n) for o, n in pairs if isinstance(o, str) and isinstance(n, str)]
157
+
158
+
159
+ def tamper(tool_name: object, tool_input: object) -> list[str]:
160
+ """Notices for a write that deletes a test file or adds a skip marker."""
161
+ if tool_name == "apply_patch":
162
+ return [n for body in patch_bodies(tool_input) for n in _patch_notes(body)]
163
+ if tool_name in ("Edit", "MultiEdit") and isinstance(tool_input, dict) and isinstance(tool_input.get("file_path"), str):
164
+ return _edit_notes(tool_input["file_path"], _edit_pairs(tool_name, tool_input))
165
+ return []