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.
- code_jig-0.12.0.dist-info/METADATA +154 -0
- code_jig-0.12.0.dist-info/RECORD +52 -0
- code_jig-0.12.0.dist-info/WHEEL +4 -0
- code_jig-0.12.0.dist-info/entry_points.txt +2 -0
- code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
- codejig/__init__.py +1 -0
- codejig/__main__.py +106 -0
- codejig/_core/VERSION +2 -0
- codejig/_core/bench/requirements.toml +4 -0
- codejig/_core/core/AGENTS.core.md +9 -0
- codejig/_core/core/agents/explorer.toml +24 -0
- codejig/_core/core/agents/judge.toml +19 -0
- codejig/_core/core/agents/reviewer.toml +10 -0
- codejig/_core/core/tiers.toml +4 -0
- codejig/_core/skills/bug-hunt/SKILL.md +131 -0
- codejig/_core/skills/code-analysis/SKILL.md +72 -0
- codejig/_core/skills/codegen/SKILL.md +46 -0
- codejig/_core/skills/cross-review/SKILL.md +32 -0
- codejig/_core/skills/grilling/SKILL.md +60 -0
- codejig/_core/skills/hld-trace/SKILL.md +69 -0
- codejig/_core/skills/improve/SKILL.md +38 -0
- codejig/_core/skills/redacao/SKILL.md +114 -0
- codejig/_core/skills/redacao/checar.py +75 -0
- codejig/_core/skills/redacao/reference.md +118 -0
- codejig/_core/skills/regression-tests/SKILL.md +53 -0
- codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
- codejig/_core/skills/skill-validate/SKILL.md +58 -0
- codejig/_core/skills/web-consensus/SKILL.md +55 -0
- codejig/analyze.py +499 -0
- codejig/atomicwrite.py +49 -0
- codejig/collect.py +311 -0
- codejig/doctor.py +370 -0
- codejig/evidence.py +418 -0
- codejig/feedback.py +165 -0
- codejig/gitgate.py +278 -0
- codejig/hooks.py +64 -0
- codejig/humanize.py +206 -0
- codejig/install.py +381 -0
- codejig/managed.py +64 -0
- codejig/migrate.py +515 -0
- codejig/overlay.py +43 -0
- codejig/paths.py +40 -0
- codejig/probe.py +136 -0
- codejig/review.py +73 -0
- codejig/scrub.py +255 -0
- codejig/skillcheck.py +400 -0
- codejig/skilllint.py +99 -0
- codejig/sources.py +248 -0
- codejig/tiers.py +63 -0
- codejig/tokens.py +118 -0
- codejig/trace.py +137 -0
- 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 []
|