tony-cli 0.3.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.
tony_cli/hosted.py ADDED
@@ -0,0 +1,324 @@
1
+ """Publishing to the tony site.
2
+
3
+ The payload is uploaded as JSON over TLS and sealed by the server before it
4
+ reaches storage. An earlier design encrypted here and put the key in the URL
5
+ fragment so the site could never read a review; that was dropped deliberately
6
+ when reading was gated behind an account and a history of past reviews became
7
+ part of the product — both require the server to be able to open a review.
8
+ So: reviews are confidential against a leak of the stored blobs, not against
9
+ the site itself. `tony --publish` says as much the first time you use it.
10
+
11
+ Login is GitHub's device flow, the same shape as `gh auth login`: tony prints a
12
+ code, you approve it in a browser, tony polls. The GitHub token is traded to the
13
+ tony API for tony's own token, which is what gets cached — the GitHub token is
14
+ never stored.
15
+ """
16
+
17
+ import gzip
18
+ import json
19
+ import os
20
+ import stat
21
+ import sys
22
+ import time
23
+
24
+ import httpx
25
+
26
+ GITHUB_DEVICE_CODE = "https://github.com/login/device/code"
27
+ GITHUB_DEVICE_TOKEN = "https://github.com/login/oauth/access_token"
28
+
29
+ # The site tony publishes to. Overridable for local development, but it has a
30
+ # real default because publishing is the default path — a fresh install should
31
+ # work without anyone setting an environment variable.
32
+ DEFAULT_API_URL = "https://tony-cli.com"
33
+
34
+ CONFIG_DIR = os.path.expanduser(os.path.join("~", ".tony"))
35
+ CREDENTIALS = os.path.join(CONFIG_DIR, "credentials.json")
36
+
37
+
38
+ def asJson(response):
39
+ """A response's JSON body, or None if it is not JSON.
40
+
41
+ `httpx` raises `json.JSONDecodeError` rather than an `HTTPError` here, so
42
+ every bare `.json()` was one HTML error page away from a traceback.
43
+ """
44
+ try:
45
+ body = response.json()
46
+ except ValueError:
47
+ return None
48
+ return body if isinstance(body, dict) else None
49
+
50
+
51
+ def apiBase():
52
+ """Where the tony site lives."""
53
+ return (os.environ.get("TONY_API_URL") or DEFAULT_API_URL).rstrip("/")
54
+
55
+
56
+ # --- credentials -----------------------------------------------------------
57
+
58
+ def savedToken():
59
+ try:
60
+ with open(CREDENTIALS, encoding="utf-8") as fh:
61
+ return json.load(fh).get("token")
62
+ except (OSError, json.JSONDecodeError):
63
+ return None
64
+
65
+
66
+ def saveToken(token, login):
67
+ """Write the credential, never leaving it readable by anyone else.
68
+
69
+ `open()` would create at the umask's mercy — 0644 on most machines — and a
70
+ chmod afterwards closes the window a moment too late. Opening with the mode
71
+ up front means the file is never readable by another user on a shared box.
72
+ """
73
+ os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
74
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
75
+ fd = os.open(CREDENTIALS, flags, stat.S_IRUSR | stat.S_IWUSR)
76
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
77
+ json.dump({"token": token, "githubLogin": login}, fh)
78
+
79
+
80
+ def clearToken():
81
+ try:
82
+ os.remove(CREDENTIALS)
83
+ return True
84
+ except OSError:
85
+ return False
86
+
87
+
88
+ # --- login -----------------------------------------------------------------
89
+
90
+ def login():
91
+ """GitHub device flow, then trade the GitHub token for a tony API token.
92
+
93
+ Returns 0 on success, non-zero on failure, and prints its own messages —
94
+ this is a user-facing command, not a library call.
95
+ """
96
+ base = apiBase()
97
+ if not base:
98
+ print("tony: TONY_API_URL is not set — there is no site to log in to yet.",
99
+ file=sys.stderr)
100
+ return 2
101
+
102
+ # The GitHub OAuth client id is public by design; the server owns it so the
103
+ # CLI never has to ship one.
104
+ try:
105
+ config = asJson(httpx.get(f"{base}/api/config", timeout=10)) or {}
106
+ clientId = config.get("githubClientId") or ""
107
+ except Exception as e:
108
+ print(f"tony: could not reach {base}: {e}", file=sys.stderr)
109
+ return 1
110
+ if not clientId:
111
+ print("tony: the site has no GitHub app configured, so login cannot start.",
112
+ file=sys.stderr)
113
+ return 1
114
+
115
+ try:
116
+ device = asJson(httpx.post(
117
+ GITHUB_DEVICE_CODE,
118
+ data={"client_id": clientId, "scope": "read:user"},
119
+ headers={"Accept": "application/json"},
120
+ timeout=10,
121
+ )) or {}
122
+ except httpx.HTTPError as e:
123
+ print(f"tony: could not reach GitHub: {e}", file=sys.stderr)
124
+ return 1
125
+
126
+ # A GitHub OAuth app has device flow OFF by default, and that shows up
127
+ # here rather than at approval time. Say which switch is missing instead
128
+ # of dying on a missing key.
129
+ if "user_code" not in device:
130
+ detail = device.get("error_description") or device.get("error") or "unknown error"
131
+ print(f"tony: GitHub would not start a device login — {detail}.", file=sys.stderr)
132
+ if device.get("error") == "device_flow_disabled":
133
+ print(" Enable 'Device flow' in the GitHub OAuth app's settings.",
134
+ file=sys.stderr)
135
+ return 1
136
+
137
+ print(f"\n First, copy this code: {device['user_code']}")
138
+ print(f" Then approve it at: {device['verification_uri']}\n")
139
+ print(" Waiting for approval — press Ctrl-C to give up.", file=sys.stderr)
140
+
141
+ interval = int(device.get("interval", 5))
142
+ deadline = time.time() + int(device.get("expires_in", 900))
143
+ githubToken = None
144
+ try:
145
+ while time.time() < deadline:
146
+ time.sleep(interval)
147
+ try:
148
+ poll = asJson(httpx.post(
149
+ GITHUB_DEVICE_TOKEN,
150
+ data={
151
+ "client_id": clientId,
152
+ "device_code": device["device_code"],
153
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
154
+ },
155
+ headers={"Accept": "application/json"},
156
+ timeout=10,
157
+ )) or {}
158
+ except httpx.HTTPError:
159
+ # A blip mid-approval should not lose a code the user already
160
+ # typed in; the deadline still bounds this.
161
+ continue
162
+
163
+ error = poll.get("error")
164
+ if error == "authorization_pending":
165
+ continue
166
+ if error == "slow_down":
167
+ interval += 5
168
+ continue
169
+ if error == "expired_token":
170
+ break
171
+ if error:
172
+ detail = poll.get("error_description") or error
173
+ print(f"tony: GitHub declined the login — {detail}.", file=sys.stderr)
174
+ return 1
175
+ githubToken = poll.get("access_token")
176
+ break
177
+ except KeyboardInterrupt:
178
+ print("\ntony: login cancelled.", file=sys.stderr)
179
+ return 1
180
+
181
+ if not githubToken:
182
+ print("tony: the code expired before it was approved. Run tony login again.",
183
+ file=sys.stderr)
184
+ return 1
185
+
186
+ try:
187
+ exchanged = httpx.post(
188
+ f"{base}/api/auth/github",
189
+ json={"accessToken": githubToken},
190
+ timeout=15,
191
+ )
192
+ except httpx.HTTPError as e:
193
+ print(f"tony: approved, but could not reach {base}: {e}", file=sys.stderr)
194
+ return 1
195
+ if exchanged.status_code != 200:
196
+ print(f"tony: the tony API rejected the login ({exchanged.status_code}).",
197
+ file=sys.stderr)
198
+ return 1
199
+
200
+ body = asJson(exchanged) or {}
201
+ if not body.get("token"):
202
+ print("tony: the tony API returned no token.", file=sys.stderr)
203
+ return 1
204
+ saveToken(body["token"], body.get("githubLogin", ""))
205
+ print(f"tony: logged in as {body.get('githubLogin', 'you')}.")
206
+ return 0
207
+
208
+
209
+ def whoami():
210
+ """Who this machine is logged in as, if anyone."""
211
+ try:
212
+ with open(CREDENTIALS, encoding="utf-8") as fh:
213
+ saved = json.load(fh)
214
+ except (OSError, json.JSONDecodeError):
215
+ print("tony: not logged in. Run `tony login` to publish reviews.")
216
+ return 1
217
+ print(f"tony: logged in as {saved.get('githubLogin') or 'unknown'}.")
218
+ return 0
219
+
220
+
221
+ def logout():
222
+ """Revoke this machine's token server-side, then forget it locally.
223
+
224
+ Deleting the local file alone would leave a working token in anyone's hands
225
+ who had already copied it, so the revoke comes first — but a site that is
226
+ unreachable must not strand someone logged in on a laptop they are trying
227
+ to wipe, so local removal happens either way.
228
+ """
229
+ token = savedToken()
230
+ if token and apiBase():
231
+ try:
232
+ httpx.post(
233
+ f"{apiBase()}/api/auth/logout",
234
+ headers={"Authorization": f"Bearer {token}"},
235
+ timeout=10,
236
+ )
237
+ except httpx.HTTPError:
238
+ print("tony: could not reach the site to revoke this token; "
239
+ "removing it locally anyway.", file=sys.stderr)
240
+
241
+ if clearToken():
242
+ print("tony: logged out.")
243
+ else:
244
+ print("tony: you were not logged in.")
245
+ return 0
246
+
247
+
248
+ # --- encrypt and upload ----------------------------------------------------
249
+
250
+ def publish(payloadJson, repo="", rangeLabel=""):
251
+ """Upload one payload. Returns (url, problem)."""
252
+ base = apiBase()
253
+ if not base:
254
+ return None, (
255
+ "TONY_API_URL is not set. Publishing needs a deployed tony site — "
256
+ "the local page in .tony/ works without one."
257
+ )
258
+ token = savedToken()
259
+ if not token:
260
+ return None, "not logged in — run `tony login` first."
261
+
262
+ # Gzip before upload. Review JSON is repetitive source and compresses about
263
+ # ten to one, and the site's size limit is measured on what arrives — so
264
+ # this is what lets a large review publish at all, not just a smaller
265
+ # request. `application/gzip` rather than `Content-Encoding`, so no proxy
266
+ # decides to helpfully inflate it on the way.
267
+ try:
268
+ resp = httpx.post(
269
+ f"{base}/api/reviews",
270
+ content=gzip.compress(payloadJson.encode("utf-8")),
271
+ headers={
272
+ "Authorization": f"Bearer {token}",
273
+ "Content-Type": "application/gzip",
274
+ },
275
+ timeout=60,
276
+ )
277
+ except httpx.HTTPError as e:
278
+ return None, f"could not reach {base}: {e}"
279
+
280
+ if resp.status_code == 401:
281
+ return None, "your login expired — run `tony login` again."
282
+ if resp.status_code == 413:
283
+ return None, "this review is over the upload size limit."
284
+ if resp.status_code != 200:
285
+ return None, f"upload failed ({resp.status_code})."
286
+
287
+ body = asJson(resp) or {}
288
+ if not body.get("id"):
289
+ return None, "the site accepted the upload but returned no review id."
290
+ print(f"tony: to remove it later: tony unpublish {body['id']}", file=sys.stderr)
291
+ return f"{base}/r/{body['id']}", None
292
+
293
+
294
+ def unpublish(reviewId):
295
+ """Delete one of your own published reviews."""
296
+ base = apiBase()
297
+ if not base:
298
+ print("tony: TONY_API_URL is not set.", file=sys.stderr)
299
+ return 2
300
+ token = savedToken()
301
+ if not token:
302
+ print("tony: not logged in — run `tony login` first.", file=sys.stderr)
303
+ return 2
304
+ try:
305
+ resp = httpx.request(
306
+ "DELETE",
307
+ f"{base}/api/reviews/{reviewId}",
308
+ headers={"Authorization": f"Bearer {token}"},
309
+ timeout=15,
310
+ )
311
+ except httpx.HTTPError as e:
312
+ print(f"tony: could not reach {base}: {e}", file=sys.stderr)
313
+ return 1
314
+
315
+ if resp.status_code == 200:
316
+ print("tony: removed.")
317
+ return 0
318
+ if resp.status_code == 403:
319
+ print("tony: that review belongs to someone else.", file=sys.stderr)
320
+ elif resp.status_code == 404:
321
+ print("tony: no such review.", file=sys.stderr)
322
+ else:
323
+ print(f"tony: could not remove it ({resp.status_code}).", file=sys.stderr)
324
+ return 1
tony_cli/install.py ADDED
@@ -0,0 +1,138 @@
1
+ """How this copy of tony was installed, and how to change it.
2
+
3
+ `tony --update` and `tony uninstall` both have to answer the same question
4
+ first: which tool put this here? Guessing from PATH picks whichever installer
5
+ the user happens to also have, so both read the receipt the installer left at
6
+ the root of the environment tony is actually running from.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+
13
+ import httpx
14
+
15
+ PKG = "tony-cli"
16
+
17
+ # The same pinning install.sh does, for the same reason: both installers honour
18
+ # an ambient index setting, so on a machine pointed at an internal mirror an
19
+ # update would pull whatever that mirror serves under the name `tony-cli`.
20
+ INDEX = "https://pypi.org/simple"
21
+ RELEASES = "https://pypi.org/pypi/tony-cli/json"
22
+
23
+
24
+ def indexEnv():
25
+ env = dict(os.environ)
26
+ env["UV_INDEX_URL"] = INDEX
27
+ env["UV_DEFAULT_INDEX"] = INDEX
28
+ env["PIP_INDEX_URL"] = INDEX
29
+ return env
30
+
31
+
32
+ def isSourceCheckout():
33
+ """True when running from `pip install -e .` in tony's own repo.
34
+
35
+ Neither updating nor uninstalling may touch a working tree: one would
36
+ overwrite uncommitted work, the other would delete it.
37
+ """
38
+ pkg = os.path.dirname(os.path.abspath(__file__))
39
+ return os.path.exists(os.path.join(pkg, "..", "..", "pyproject.toml"))
40
+
41
+
42
+ def installer():
43
+ """'uv', 'pipx', or 'pip' — whichever environment tony is running from."""
44
+ if os.path.exists(os.path.join(sys.prefix, "uv-receipt.toml")):
45
+ return "uv"
46
+ if os.path.exists(os.path.join(sys.prefix, "pipx_metadata.json")):
47
+ return "pipx"
48
+ return "pip"
49
+
50
+
51
+ def uninstallCommand(kind):
52
+ if kind == "uv":
53
+ return ["uv", "tool", "uninstall", PKG]
54
+ if kind == "pipx":
55
+ return ["pipx", "uninstall", PKG]
56
+ return [sys.executable, "-m", "pip", "uninstall", "-y", PKG]
57
+
58
+
59
+ def updateCommand(kind):
60
+ if kind == "uv":
61
+ return ["uv", "tool", "upgrade", PKG]
62
+ if kind == "pipx":
63
+ return ["pipx", "upgrade", PKG]
64
+ return [sys.executable, "-m", "pip", "install", "--upgrade", PKG]
65
+
66
+
67
+ def installedVersion():
68
+ """The running version, or None if the metadata is not there to read."""
69
+ try:
70
+ from importlib.metadata import version
71
+ return version(PKG)
72
+ except Exception:
73
+ return None
74
+
75
+
76
+ def latestVersion(timeout=10):
77
+ """The newest version PyPI serves, or None if it cannot be asked.
78
+
79
+ Worth one request: without it there is nothing to check the upgrade
80
+ against, and every installer exits 0 for "already at latest" — which is
81
+ indistinguishable from "upgraded" unless you knew the target beforehand.
82
+ """
83
+ try:
84
+ response = httpx.get(RELEASES, timeout=timeout)
85
+ if response.status_code != 200:
86
+ return None
87
+ return ((response.json() or {}).get("info") or {}).get("version")
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def update(argv=()):
93
+ """`tony update`. Returns an exit code, prints its own messages."""
94
+ if argv:
95
+ print("usage: tony update", file=sys.stderr)
96
+ return 2
97
+
98
+ if isSourceCheckout():
99
+ print(f"tony: this is a source checkout at {sys.prefix} — update it with "
100
+ "`git pull`, not from PyPI.", file=sys.stderr)
101
+ return 2
102
+
103
+ kind = installer()
104
+ command = updateCommand(kind)
105
+ before = installedVersion()
106
+ latest = latestVersion()
107
+
108
+ # Nothing to do is worth saying out loud. Running the installer anyway
109
+ # prints a wall of its output and exits 0, which reads exactly like a
110
+ # successful upgrade.
111
+ if latest and before and latest == before:
112
+ print(f"tony: already on the latest version ({before}).")
113
+ return 0
114
+
115
+ target = f" to {latest}" if latest else ""
116
+ print(f"tony: {before or 'unknown version'}, updating{target} with {kind}...")
117
+ # The installer writes to this same terminal; without a flush its output
118
+ # arrives before the line announcing it.
119
+ sys.stdout.flush()
120
+
121
+ try:
122
+ result = subprocess.run(command, env=indexEnv())
123
+ except OSError as e:
124
+ print(f"tony: could not run {command[0]}: {e}", file=sys.stderr)
125
+ print(f"tony: update by hand with: {' '.join(command)}", file=sys.stderr)
126
+ return 1
127
+ if result.returncode != 0:
128
+ print(f"tony: {kind} could not update tony.", file=sys.stderr)
129
+ return result.returncode
130
+
131
+ # This interpreter still has the old version loaded, so it cannot read the
132
+ # new one back. PyPI's answer is the honest thing to report; without it,
133
+ # say what happened rather than asserting an outcome.
134
+ if latest:
135
+ print(f"\ntony: updated {before or '?'} -> {latest}.")
136
+ else:
137
+ print("\ntony: the installer finished. Check it with `tony --version`.")
138
+ return 0
tony_cli/layout.py ADDED
@@ -0,0 +1,233 @@
1
+ """The deterministic layer: line numbers, spans, and tags, decided from the diff.
2
+
3
+ This is the single place those decisions run. The payload carries the result,
4
+ and every renderer — the local page and the hosted viewer are the same
5
+ TypeScript bundle — consumes it without re-deriving anything. See DESIGN.md,
6
+ "Ground truth": the model can name a line range, it can never supply the
7
+ contents of one, and it never decides where a note lands.
8
+ """
9
+
10
+ import json
11
+ import re
12
+
13
+ JSON_FENCE = re.compile(r"```json\s*\n(.*?)\n```", re.DOTALL)
14
+ HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
15
+
16
+
17
+ def parseReview(review):
18
+ """Pull the JSON block out of a raw review. A malformed block yields {}."""
19
+ match = JSON_FENCE.search(review)
20
+ if not match:
21
+ return {}
22
+ try:
23
+ return json.loads(match.group(1))
24
+ except json.JSONDecodeError:
25
+ return {}
26
+
27
+
28
+ def changedRuns(body):
29
+ """Map each contiguous run of changed lines to its new-file span.
30
+
31
+ Returns a list of (start, end, hadDeletion) in new-file line numbers.
32
+ Deletion-only runs collapse to a single position, since they occupy no line
33
+ in the new file. `hadDeletion` is what lets the page tell an insertion from a
34
+ replacement without taking the model's word for it.
35
+ """
36
+ runs = []
37
+ newLine = None
38
+ run = None
39
+
40
+ for line in body.split("\n"):
41
+ header = HUNK_HEADER.match(line)
42
+ if header:
43
+ if run:
44
+ runs.append(tuple(run))
45
+ run = None
46
+ newLine = int(header.group(1))
47
+ continue
48
+ if newLine is None:
49
+ continue
50
+
51
+ if line.startswith("+"):
52
+ if run:
53
+ run[1] = newLine
54
+ else:
55
+ run = [newLine, newLine, False]
56
+ newLine += 1
57
+ elif line.startswith("-"):
58
+ if run:
59
+ run[2] = True
60
+ else:
61
+ run = [newLine, newLine, True]
62
+ else:
63
+ if run:
64
+ runs.append(tuple(run))
65
+ run = None
66
+ newLine += 1
67
+
68
+ if run:
69
+ runs.append(tuple(run))
70
+ return runs
71
+
72
+
73
+ def spanFor(anchor, runs):
74
+ """The changed run an annotation refers to: the one containing it, else the next."""
75
+ for run in runs:
76
+ if run[0] <= anchor <= run[1]:
77
+ return run
78
+ later = [r for r in runs if r[0] >= anchor]
79
+ return later[0] if later else (anchor, anchor, False)
80
+
81
+
82
+ def kindFor(claimed, span):
83
+ """What the tag says, decided by the diff rather than by the model.
84
+
85
+ A model that inserts a guard ahead of existing code often calls that
86
+ "changed" — true of the behaviour, false of the lines, and the reader ends up
87
+ hunting an all-green block for the thing that was replaced. The diff already
88
+ knows. "removed" is left alone: those runs are deletions by definition.
89
+ """
90
+ if claimed == "removed" or not span or len(span) < 3:
91
+ return claimed or "changed"
92
+ return "changed" if span[2] else "added"
93
+
94
+
95
+ def layout(body, items, gaps=True):
96
+ """One file as ordered blocks: diff rows, with notes above the line they describe.
97
+
98
+ `items` are dicts of {"k": "note"|"risk", "n": index, "data": obj}, where
99
+ `n` indexes back into the payload's own annotations or risks array.
100
+
101
+ Blocks are either {"k": "row", "cls", "g", "text"} or
102
+ {"k": "note"|"risk", "n", "span", "tag"}.
103
+ """
104
+ runs = changedRuns(body) if body else []
105
+
106
+ # Where a note lands.
107
+ #
108
+ # A note that is the only thing said about a run describes that whole run,
109
+ # so it belongs at the run's first line — the model naming a line partway in
110
+ # splits the very block it is explaining.
111
+ #
112
+ # A run with SEVERAL notes is the opposite case: an added file is one
113
+ # contiguous run from line 1 to its end, and the three notes inside it are
114
+ # describing three different parts. Snapping those together would stack them
115
+ # at the top and throw away the only positional information there is. So
116
+ # only a sole note moves.
117
+ resolved = []
118
+ perRun = {}
119
+ for item in items:
120
+ line = item["data"].get("line")
121
+ claimed = int(line) if isinstance(line, (int, float)) else 0
122
+ span = spanFor(claimed, runs) if runs else None
123
+ resolved.append((item, span, claimed))
124
+ if span in runs:
125
+ perRun[span] = perRun.get(span, 0) + 1
126
+
127
+ pending = {}
128
+ covered = set()
129
+ for item, span, claimed in resolved:
130
+ if span in runs:
131
+ covered.add(span)
132
+ alone = span in runs and perRun[span] == 1
133
+ pending.setdefault(span[0] if alone else claimed, []).append(("note", item, span))
134
+
135
+ # A run nothing explained. The promise is that every changed block is
136
+ # accounted for, and a review that quietly covers two thirds of a diff is
137
+ # indistinguishable from one that covers all of it — which is the failure
138
+ # worth making impossible to miss. The gap is placed like any other note,
139
+ # at the first line of the run it marks.
140
+ if gaps:
141
+ for run in runs:
142
+ if run not in covered:
143
+ pending.setdefault(run[0], []).append(("gap", None, run))
144
+
145
+ def block(entry):
146
+ kind, item, span = entry
147
+ if kind == "gap":
148
+ return {"k": "gap", "span": list(span), "tag": kindFor(None, span)}
149
+ return {
150
+ "k": item["k"],
151
+ "n": item["n"],
152
+ "span": list(span) if span else None,
153
+ "tag": kindFor(item["data"].get("kind"), span),
154
+ }
155
+
156
+ blocks = []
157
+ newLine = None
158
+
159
+ for line in body.split("\n"):
160
+ header = HUNK_HEADER.match(line)
161
+ if header:
162
+ newLine = int(header.group(1))
163
+ # anything anchored before this hunk that never matched, flush here
164
+ for anchor in sorted(k for k in pending if k < newLine):
165
+ blocks += [block(e) for e in pending.pop(anchor)]
166
+ blocks.append({"k": "row", "cls": "h", "g": None, "text": line})
167
+ continue
168
+
169
+ if newLine is not None and newLine in pending:
170
+ blocks += [block(e) for e in pending.pop(newLine)]
171
+
172
+ if line.startswith("+"):
173
+ cls, gutter = "a", newLine
174
+ newLine = (newLine or 0) + 1
175
+ elif line.startswith("-"):
176
+ cls, gutter = "d", None
177
+ else:
178
+ cls, gutter = "c", newLine
179
+ if newLine is not None:
180
+ newLine += 1
181
+
182
+ blocks.append({"k": "row", "cls": cls, "g": gutter, "text": line})
183
+
184
+ for anchor in sorted(pending):
185
+ blocks += [block(e) for e in pending.pop(anchor)]
186
+
187
+ return blocks
188
+
189
+
190
+ # Files nobody reads and nobody should be asked to explain. Decided here, from
191
+ # the path alone, rather than left to the model — "skip lockfiles" as a prompt
192
+ # instruction is a judgment call, and a judgment call is what put 4000 lines of
193
+ # package-lock.json into a coverage denominator.
194
+ SKIP_NAMES = {
195
+ "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
196
+ "Cargo.lock", "poetry.lock", "Pipfile.lock", "composer.lock", "Gemfile.lock",
197
+ "go.sum", "flake.lock",
198
+ }
199
+ SKIP_SUFFIXES = (".min.js", ".min.css", ".map", ".snap", ".lock")
200
+
201
+
202
+ def isSkippable(path):
203
+ """True for generated files that are not worth an annotation."""
204
+ name = (path or "").rsplit("/", 1)[-1]
205
+ return name in SKIP_NAMES or name.endswith(SKIP_SUFFIXES)
206
+
207
+
208
+ def runCoverage(body, items):
209
+ """(total runs, unexplained runs) for one file.
210
+
211
+ The same resolution `layout` does, without building a page — this is what
212
+ lets the review loop check its own work before anyone reads it.
213
+ """
214
+ runs = changedRuns(body) if body else []
215
+ covered = set()
216
+ for item in items:
217
+ line = item["data"].get("line")
218
+ claimed = int(line) if isinstance(line, (int, float)) else 0
219
+ span = spanFor(claimed, runs) if runs else None
220
+ if span in runs:
221
+ covered.add(span)
222
+ return runs, [r for r in runs if r not in covered]
223
+
224
+
225
+ def itemsByPath(annotations, risks):
226
+ """Annotations and risks grouped per file, keeping their index in the payload arrays."""
227
+ byPath = {}
228
+ for n, a in enumerate(annotations):
229
+ byPath.setdefault(a.get("path"), []).append({"k": "note", "n": n, "data": a})
230
+ for n, r in enumerate(risks):
231
+ if r.get("path"):
232
+ byPath.setdefault(r["path"], []).append({"k": "risk", "n": n, "data": r})
233
+ return byPath