git-worklog 1.6.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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
"""Exchange Day Subagent results through files, and validate them.
|
|
2
|
+
|
|
3
|
+
Why files rather than return values
|
|
4
|
+
-----------------------------------
|
|
5
|
+
A Day Subagent's result is the whole point of the expensive part of a run: it
|
|
6
|
+
represents real patches read and real code understood. Passing it back as the
|
|
7
|
+
subagent's *reply text* makes that result hostage to the host's return channel,
|
|
8
|
+
which is the weakest link in the pipeline:
|
|
9
|
+
|
|
10
|
+
* it can drop or truncate content (observed in practice — a subagent that did
|
|
11
|
+
63k tokens of correct analysis returned nothing),
|
|
12
|
+
* a day's object is routinely 15KB+ and a large day is far bigger,
|
|
13
|
+
* the skill targets several hosts (Claude Code / Codex / Gemini) whose return
|
|
14
|
+
semantics and size limits all differ,
|
|
15
|
+
* a dropped reply loses the analysis outright, forcing a full re-run.
|
|
16
|
+
|
|
17
|
+
A file has none of those properties. It also survives the run, so a failure
|
|
18
|
+
downstream (rendering, preview, apply) never costs the analysis again, and a
|
|
19
|
+
human can read exactly what a subagent concluded.
|
|
20
|
+
|
|
21
|
+
So: the orchestrator mints a run directory with :func:`mint_run`, hands each
|
|
22
|
+
subagent its own output path, and collects the lot with :func:`read_run`.
|
|
23
|
+
Results live outside the repository, under
|
|
24
|
+
``~/.git-worklog/analysis/<run_id>/<date>.json``, alongside the preview state —
|
|
25
|
+
the worklog directory is for the worklog, not for scratch.
|
|
26
|
+
|
|
27
|
+
What :func:`read_run` guarantees
|
|
28
|
+
--------------------------------
|
|
29
|
+
A missing or malformed file is reported as **that day failing**, explicitly. This
|
|
30
|
+
is the deterministic half of the orchestrator's completeness check
|
|
31
|
+
(`references/subagent-contract.md` §1): a day whose result never arrived must
|
|
32
|
+
never be silently skipped, and must never be back-filled from commit messages.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
|
|
44
|
+
from git_worklog import language, markers as wm, paths
|
|
45
|
+
from git_worklog.analysis import AnalysisError
|
|
46
|
+
|
|
47
|
+
# Top-level keys required by the Day Subagent return schema
|
|
48
|
+
# (references/subagent-contract.md §6). All must be present even when empty.
|
|
49
|
+
REQUIRED_KEYS = [
|
|
50
|
+
"date", "timezone", "language", "status", "confidence",
|
|
51
|
+
"escalation_recommended", "escalation_reasons", "has_changes", "commits",
|
|
52
|
+
"work_items", "fixes", "refactors", "tests", "database_changes",
|
|
53
|
+
"configuration_changes", "deployment_changes", "uncommitted_changes",
|
|
54
|
+
"handoff_notes", "uncertainties", "evidence",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Keys required on each work_items[] entry (§6).
|
|
58
|
+
REQUIRED_WORK_ITEM_KEYS = [
|
|
59
|
+
"title", "summary", "behavior_change", "implementation", "impact", "files",
|
|
60
|
+
"commits", "tests", "risks", "maintenance_notes", "follow_ups",
|
|
61
|
+
"confidence", "evidence",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
VALID_STATUS = {"complete", "partial", "failed"}
|
|
65
|
+
VALID_CONFIDENCE = {"verified", "inferred", "unknown"}
|
|
66
|
+
|
|
67
|
+
# Evidence entries are objects, not sentences (subagent-contract.md §8). commit
|
|
68
|
+
# and file are required because they are the two facts always knowable and always
|
|
69
|
+
# checkable against the repository. Left as free text, the field reliably decays
|
|
70
|
+
# into a restatement of the commit subject -- observed in a real run:
|
|
71
|
+
# "commit 4d08ee4: 完整改造" cites nothing a reader can open.
|
|
72
|
+
REQUIRED_EVIDENCE_KEYS = ["commit", "file"]
|
|
73
|
+
|
|
74
|
+
# An identifier inside a `symbol` field. Short tokens are ignored: `get` in
|
|
75
|
+
# `CacheLayer.get` says nothing, and a qualified name never appears verbatim in
|
|
76
|
+
# source anyway (the file holds `class CacheLayer:` and `def get`), so the field
|
|
77
|
+
# is checked token by token rather than as one string.
|
|
78
|
+
_SYMBOL_TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
|
|
79
|
+
|
|
80
|
+
# A `code span` in the prose. The worklog is written from these fields, and
|
|
81
|
+
# nothing checked them: evidence[] was validated while `implementation` was free
|
|
82
|
+
# to invent `PreviewStore` and `read_config()`, and did (#19, #22). A check is
|
|
83
|
+
# only worth the surface it measures, and this was the surface the reader sees.
|
|
84
|
+
_CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
|
|
85
|
+
|
|
86
|
+
# The work_item fields a reader actually reads. `files` and `commits` are paths
|
|
87
|
+
# and hashes (coverage's job), and `evidence` gets the stronger commit+file check
|
|
88
|
+
# above -- checking them here would only report the same fault twice.
|
|
89
|
+
PROSE_KEYS = ["title", "summary", "behavior_change", "implementation", "impact",
|
|
90
|
+
"risks", "maintenance_notes", "follow_ups"]
|
|
91
|
+
|
|
92
|
+
# Prose fields outside work_items, same reasoning.
|
|
93
|
+
TOP_LEVEL_PROSE_KEYS = ["handoff_notes", "uncertainties"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def analysis_dir() -> str:
|
|
97
|
+
"""Where runs live. Resolved per call so ``GIT_WORKLOG_HOME`` stays live."""
|
|
98
|
+
return paths.analysis_dir()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def result_path(run_dir: str, date: str) -> str:
|
|
102
|
+
return os.path.join(run_dir, f"{date}.json")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_dates(raw: str) -> "list[str]":
|
|
106
|
+
"""Split and validate a comma-separated date list, preserving caller order."""
|
|
107
|
+
dates = [d.strip() for d in raw.split(",") if d.strip()]
|
|
108
|
+
if not dates:
|
|
109
|
+
raise AnalysisError("NO_DATES", "Provide at least one date via --dates.")
|
|
110
|
+
for d in dates:
|
|
111
|
+
if not wm.is_valid_date(d):
|
|
112
|
+
raise AnalysisError("INVALID_DATE", f"Not an ISO YYYY-MM-DD date: {d}.",
|
|
113
|
+
date=d)
|
|
114
|
+
return list(dict.fromkeys(dates))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def mint_run(dates: "list[str]", run_dir: "str | None" = None) -> dict:
|
|
118
|
+
"""Create a run directory and one output path per date."""
|
|
119
|
+
now = datetime.now()
|
|
120
|
+
basis = hashlib.sha256(
|
|
121
|
+
f"{now.isoformat()}|{','.join(dates)}".encode("utf-8")).hexdigest()[:6]
|
|
122
|
+
run_id = f"rw-{now.strftime('%Y%m%d')}-{basis}"
|
|
123
|
+
run_dir = run_dir or os.path.join(analysis_dir(), run_id)
|
|
124
|
+
paths.ensure_dir(run_dir)
|
|
125
|
+
return {
|
|
126
|
+
"ok": True,
|
|
127
|
+
"run_id": run_id,
|
|
128
|
+
"run_dir": run_dir,
|
|
129
|
+
"dates": dates,
|
|
130
|
+
# One path per date: hand each Day Subagent exactly its own, so two days
|
|
131
|
+
# can never race on one file.
|
|
132
|
+
"paths": {d: result_path(run_dir, d) for d in dates},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Issues that mean "this could not be checked", not "this was found to be
|
|
137
|
+
# false". They are reported but never fail a day: `analysis-pipeline.md` has
|
|
138
|
+
# promised exactly that for EVIDENCE_UNVERIFIABLE since it was written, while
|
|
139
|
+
# the code failed the day anyway.
|
|
140
|
+
UNVERIFIED_CODES = {
|
|
141
|
+
"EVIDENCE_UNVERIFIABLE",
|
|
142
|
+
"EVIDENCE_FILE_NOT_TEXT",
|
|
143
|
+
"PROSE_SCOPE_TRUNCATED",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# `file_at` has three answers, not two: the file was there, it was not there, or
|
|
147
|
+
# it was there but is not UTF-8 text. The third needs a value of its own --
|
|
148
|
+
# returning ``None`` would report a real binary file as "no such file", and
|
|
149
|
+
# decoding it strictly used to take the whole of `collect` down without naming
|
|
150
|
+
# the commit or the path it died on.
|
|
151
|
+
NOT_TEXT = object()
|
|
152
|
+
|
|
153
|
+
# Above this many trees, `day_scope` stops searching every state the day passed
|
|
154
|
+
# through and searches only its boundaries. One grep over 27 trees costs ~25s on
|
|
155
|
+
# a 250k-line repository; the cost is linear in the tree count, and a day large
|
|
156
|
+
# enough to pass this is a day where the answer is worth less than the wait.
|
|
157
|
+
MAX_DAY_TREES = 50
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Tree:
|
|
161
|
+
"""Reads files as they were at a commit, and remembers what it read.
|
|
162
|
+
|
|
163
|
+
Evidence must be checked against the day's tree, never the checkout: the
|
|
164
|
+
working tree holds every change made since, so a symbol that exists today
|
|
165
|
+
proves nothing about the day being described. One subprocess per distinct
|
|
166
|
+
(commit, file) — a day's evidence cites the same few files repeatedly.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, repo: str):
|
|
170
|
+
self.repo = repo
|
|
171
|
+
self._files: dict = {}
|
|
172
|
+
self._commits: dict = {}
|
|
173
|
+
self._greps: dict = {}
|
|
174
|
+
self._shallow = None
|
|
175
|
+
|
|
176
|
+
def _git_bytes(self, *args) -> "tuple[int, bytes]":
|
|
177
|
+
p = subprocess.run(["git", "-C", self.repo, *args], capture_output=True)
|
|
178
|
+
return p.returncode, p.stdout
|
|
179
|
+
|
|
180
|
+
def _git(self, *args) -> "tuple[int, str]":
|
|
181
|
+
code, out = self._git_bytes(*args)
|
|
182
|
+
# Replacement rather than strict decoding: every caller here reads Git's
|
|
183
|
+
# own control output -- hashes, exit codes, matched tokens -- where one
|
|
184
|
+
# undecodable byte must not end the run. `file_at` is the single caller
|
|
185
|
+
# that reads *file content*, and it decodes strictly itself, because
|
|
186
|
+
# there "not text" is an answer rather than an accident.
|
|
187
|
+
return code, out.decode("utf-8", "replace")
|
|
188
|
+
|
|
189
|
+
def is_shallow(self) -> bool:
|
|
190
|
+
if self._shallow is None:
|
|
191
|
+
_, out = self._git("rev-parse", "--is-shallow-repository")
|
|
192
|
+
self._shallow = out.strip() == "true"
|
|
193
|
+
return self._shallow
|
|
194
|
+
|
|
195
|
+
def has_commit(self, commit: str) -> bool:
|
|
196
|
+
if commit not in self._commits:
|
|
197
|
+
code, _ = self._git("cat-file", "-e", f"{commit}^{{commit}}")
|
|
198
|
+
self._commits[commit] = code == 0
|
|
199
|
+
return self._commits[commit]
|
|
200
|
+
|
|
201
|
+
def file_at(self, commit: str, path: str):
|
|
202
|
+
"""The file's text at that commit, ``None`` if absent, ``NOT_TEXT`` if
|
|
203
|
+
it is there but not UTF-8."""
|
|
204
|
+
key = (commit, path)
|
|
205
|
+
if key not in self._files:
|
|
206
|
+
code, out = self._git_bytes("show", f"{commit}:{path}")
|
|
207
|
+
if code != 0:
|
|
208
|
+
self._files[key] = None
|
|
209
|
+
else:
|
|
210
|
+
try:
|
|
211
|
+
self._files[key] = out.decode("utf-8")
|
|
212
|
+
except UnicodeDecodeError:
|
|
213
|
+
self._files[key] = NOT_TEXT
|
|
214
|
+
return self._files[key]
|
|
215
|
+
|
|
216
|
+
def names_anything(self, token: str, trees: "tuple[str, ...]") -> bool:
|
|
217
|
+
"""Does ``token`` appear anywhere in these trees?
|
|
218
|
+
|
|
219
|
+
Prose has no ``file`` to check a symbol against the way evidence does --
|
|
220
|
+
"PreviewStore stores the payload" names no location -- so the question a
|
|
221
|
+
prose symbol can answer is the weaker one: does this identifier exist in
|
|
222
|
+
the project at all, on the day being described. That is enough to catch
|
|
223
|
+
the fabrication this exists for, because an invented name exists nowhere.
|
|
224
|
+
|
|
225
|
+
``git grep`` over whole trees rather than the cached file contents:
|
|
226
|
+
prose legitimately refers to code the day did not change ("consistent
|
|
227
|
+
with `detect_timezone`"), and checking only the day's own files would
|
|
228
|
+
report those as invented.
|
|
229
|
+
|
|
230
|
+
``-w`` matches whole words, so a fabricated ``Cache`` is not masked by a
|
|
231
|
+
real ``CacheLayer`` -- ``-F`` alone is a substring match, and that
|
|
232
|
+
substring is exactly how a plausible-but-wrong name hides. Every token
|
|
233
|
+
reaching here is a single identifier (a dotted ``Tree.file_at`` was split
|
|
234
|
+
upstream), and an underscore is a word character, so ``_verify`` still
|
|
235
|
+
will not match inside ``_verify_against_tree`` -- which is correct, since
|
|
236
|
+
that is a different name.
|
|
237
|
+
"""
|
|
238
|
+
key = (token, trees)
|
|
239
|
+
if key not in self._greps:
|
|
240
|
+
code, _ = self._git("grep", "-qwFI", "--", token, *trees)
|
|
241
|
+
self._greps[key] = code == 0
|
|
242
|
+
return self._greps[key]
|
|
243
|
+
|
|
244
|
+
def prime(self, tokens, trees: "tuple[str, ...]") -> None:
|
|
245
|
+
"""Answer many tokens with one grep instead of one grep each.
|
|
246
|
+
|
|
247
|
+
`names_anything` asks the right question; asking it one token at a time
|
|
248
|
+
was the expense. ``-q`` makes a token that *is* present cheap (Git stops
|
|
249
|
+
at the first hit) and a token that is *absent* expensive (it scans
|
|
250
|
+
everything) -- so the loop paid most for exactly the tokens a false
|
|
251
|
+
report is made of. Measured on a 250k-line repository, one day of 27
|
|
252
|
+
commits and 128 tokens: 128 greps over 2 trees took 35s and missed 20
|
|
253
|
+
real names; one grep over all 28 trees took 25s and missed none.
|
|
254
|
+
Batching is what makes searching every state the day passed through
|
|
255
|
+
cheaper than searching two.
|
|
256
|
+
|
|
257
|
+
``-o`` prints the matched text, and under ``-w -F`` that text is the
|
|
258
|
+
token itself, so Git's reply is simply the set of tokens that exist.
|
|
259
|
+
"""
|
|
260
|
+
if not trees:
|
|
261
|
+
return
|
|
262
|
+
pending = [t for t in dict.fromkeys(tokens)
|
|
263
|
+
if (t, trees) not in self._greps]
|
|
264
|
+
if not pending:
|
|
265
|
+
return
|
|
266
|
+
patterns = [arg for t in pending for arg in ("-e", t)]
|
|
267
|
+
code, out = self._git("grep", "-howFI", *patterns, *trees)
|
|
268
|
+
# 0 = matched, 1 = nothing matched, anything higher = Git refused. Only
|
|
269
|
+
# the first two are answers. On a refusal, leave the cache untouched so
|
|
270
|
+
# `names_anything` falls back to its own grep per token: recording
|
|
271
|
+
# "absent" here would turn one bad invocation into a page of
|
|
272
|
+
# fabrication reports against real code.
|
|
273
|
+
if code > 1:
|
|
274
|
+
return
|
|
275
|
+
present = set(out.split())
|
|
276
|
+
for token in pending:
|
|
277
|
+
self._greps[(token, trees)] = token in present
|
|
278
|
+
|
|
279
|
+
def _boundary_parents(self, commits: "list[str]") -> "list[str]":
|
|
280
|
+
"""The day's starting states: parents of the day that the day excludes.
|
|
281
|
+
|
|
282
|
+
Plural, because a day can begin in more than one place. A day that
|
|
283
|
+
*deletes* `parse_legacy` describes it correctly and it is gone from
|
|
284
|
+
every tree the day ends in, so without the state before, every removal
|
|
285
|
+
reads as a fabrication.
|
|
286
|
+
|
|
287
|
+
Each output line begins with the commit's own full hash, so the set of
|
|
288
|
+
"inside" hashes is built from Git's own normalisation -- the caller's
|
|
289
|
+
list may be abbreviated, and comparing those against full parent hashes
|
|
290
|
+
would call every parent a boundary.
|
|
291
|
+
"""
|
|
292
|
+
code, out = self._git("rev-list", "--no-walk", "--parents", *commits)
|
|
293
|
+
if code != 0:
|
|
294
|
+
return []
|
|
295
|
+
inside, parents = set(), []
|
|
296
|
+
for line in out.splitlines():
|
|
297
|
+
fields = line.split()
|
|
298
|
+
if fields:
|
|
299
|
+
inside.add(fields[0])
|
|
300
|
+
parents.extend(fields[1:])
|
|
301
|
+
return sorted({p for p in parents if p not in inside})
|
|
302
|
+
|
|
303
|
+
def _tips(self, commits: "list[str]") -> "list[str]":
|
|
304
|
+
"""The day's end states: the commits no other day commit reaches."""
|
|
305
|
+
code, out = self._git("merge-base", "--independent", *commits)
|
|
306
|
+
return out.split() if code == 0 else list(commits)
|
|
307
|
+
|
|
308
|
+
def day_scope(self, commits: "list[str]") -> dict:
|
|
309
|
+
"""Every tree the day's prose may be checked against.
|
|
310
|
+
|
|
311
|
+
The day's own commits plus the parents it started from -- *all* of its
|
|
312
|
+
commits, not its first and its last. Taking only the ends assumes the
|
|
313
|
+
day is one straight line of history, and a day that lands a branch is
|
|
314
|
+
not: in a real repository on 2026-08-27 two tips existed that were not
|
|
315
|
+
ancestors of one another, the later-committed one took the "last" slot,
|
|
316
|
+
and all 26 real symbols living on the other line were reported as
|
|
317
|
+
invented (issue #36).
|
|
318
|
+
|
|
319
|
+
Searching every state also closes the hole this used to document as
|
|
320
|
+
acceptable: a symbol created and deleted within the same day lives only
|
|
321
|
+
in a middle state, and middle states are now searched.
|
|
322
|
+
|
|
323
|
+
Past :data:`MAX_DAY_TREES` the scope narrows to the day's boundaries --
|
|
324
|
+
its starting states and its tips -- which is the old shape generalised
|
|
325
|
+
to every line rather than assumed to be one. That is reported, never
|
|
326
|
+
silent: a check that quietly stopped looking is how this began.
|
|
327
|
+
"""
|
|
328
|
+
usable = [c for c in commits if c and self.has_commit(c)]
|
|
329
|
+
if not usable:
|
|
330
|
+
return {"trees": (), "truncated": False, "commits": 0}
|
|
331
|
+
boundary = self._boundary_parents(usable)
|
|
332
|
+
full = tuple(boundary) + tuple(usable)
|
|
333
|
+
if len(full) <= MAX_DAY_TREES:
|
|
334
|
+
return {"trees": full, "truncated": False, "commits": len(usable)}
|
|
335
|
+
ends = tuple(boundary) + tuple(self._tips(usable))
|
|
336
|
+
return {"trees": ends, "truncated": True, "commits": len(usable)}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def validate_evidence(entries, where: str, tree: "Tree | None" = None) -> "list[dict]":
|
|
340
|
+
"""Evidence must be checkable citations, not prose — and must check out.
|
|
341
|
+
|
|
342
|
+
Presence of ``commit`` and ``file`` was all this ever enforced, which left
|
|
343
|
+
``symbol`` and ``lines`` decorative: a real run cited `migrate_directory`
|
|
344
|
+
for a function actually called `parse_legacy`, `preview_dir` for
|
|
345
|
+
`previews_dir`, and a line range past the end of the file — and all of it
|
|
346
|
+
passed (issue #15). Every fabrication was a *plausible* name, which is
|
|
347
|
+
precisely why reading cannot catch them and why this has to be mechanical.
|
|
348
|
+
"""
|
|
349
|
+
if entries is None:
|
|
350
|
+
return []
|
|
351
|
+
if not isinstance(entries, list):
|
|
352
|
+
return [{"code": "EVIDENCE_INVALID",
|
|
353
|
+
"message": f"{where} must be an array."}]
|
|
354
|
+
issues: "list[dict]" = []
|
|
355
|
+
for idx, e in enumerate(entries):
|
|
356
|
+
at = f"{where}[{idx}]"
|
|
357
|
+
if not isinstance(e, dict):
|
|
358
|
+
issues.append({
|
|
359
|
+
"code": "EVIDENCE_INVALID",
|
|
360
|
+
"message": f"{at} must be an object with at least "
|
|
361
|
+
f"{REQUIRED_EVIDENCE_KEYS}; prose is not evidence "
|
|
362
|
+
f"(got {type(e).__name__}).",
|
|
363
|
+
"path": at,
|
|
364
|
+
})
|
|
365
|
+
continue
|
|
366
|
+
missing = [k for k in REQUIRED_EVIDENCE_KEYS
|
|
367
|
+
if not str(e.get(k) or "").strip()]
|
|
368
|
+
if missing:
|
|
369
|
+
issues.append({
|
|
370
|
+
"code": "EVIDENCE_INVALID",
|
|
371
|
+
"message": f"{at} is missing: {', '.join(missing)}.",
|
|
372
|
+
"path": at,
|
|
373
|
+
"missing_keys": missing,
|
|
374
|
+
})
|
|
375
|
+
continue
|
|
376
|
+
if tree is not None:
|
|
377
|
+
issues.extend(_verify_against_tree(e, at, tree))
|
|
378
|
+
return issues
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _verify_against_tree(e: dict, at: str, tree: Tree) -> "list[dict]":
|
|
382
|
+
"""Check one evidence entry against the repository it cites."""
|
|
383
|
+
commit = str(e["commit"]).strip()
|
|
384
|
+
path = str(e["file"]).strip()
|
|
385
|
+
|
|
386
|
+
if not tree.has_commit(commit):
|
|
387
|
+
# A missing commit in a shallow clone is the environment's doing, not
|
|
388
|
+
# the subagent's, and failing the day for it would punish the wrong
|
|
389
|
+
# party. In a full clone there is no such excuse: the hash is invented.
|
|
390
|
+
if tree.is_shallow():
|
|
391
|
+
return [{
|
|
392
|
+
"code": "EVIDENCE_UNVERIFIABLE",
|
|
393
|
+
"message": f"{at} cites commit {commit}, which this shallow "
|
|
394
|
+
f"clone does not have, so its evidence could not be "
|
|
395
|
+
f"checked. `git fetch --unshallow` to verify it.",
|
|
396
|
+
"path": at, "commit": commit,
|
|
397
|
+
}]
|
|
398
|
+
return [{
|
|
399
|
+
"code": "EVIDENCE_COMMIT_UNKNOWN",
|
|
400
|
+
"message": f"{at} cites commit {commit}, which is not in this "
|
|
401
|
+
f"repository.",
|
|
402
|
+
"path": at, "commit": commit,
|
|
403
|
+
}]
|
|
404
|
+
|
|
405
|
+
src = tree.file_at(commit, path)
|
|
406
|
+
if src is NOT_TEXT:
|
|
407
|
+
return [{
|
|
408
|
+
"code": "EVIDENCE_FILE_NOT_TEXT",
|
|
409
|
+
"message": f"{at} cites {path} at commit {commit}, which is there "
|
|
410
|
+
f"but is not text, so its symbol and line range could "
|
|
411
|
+
f"not be checked. Citing it is not an error; it simply "
|
|
412
|
+
f"proves nothing.",
|
|
413
|
+
"path": at, "commit": commit, "file": path,
|
|
414
|
+
}]
|
|
415
|
+
if src is None:
|
|
416
|
+
return [{
|
|
417
|
+
"code": "EVIDENCE_FILE_NOT_IN_COMMIT",
|
|
418
|
+
"message": f"{at} cites {path} at commit {commit}, but that commit's "
|
|
419
|
+
f"tree has no such file. A file that exists today may not "
|
|
420
|
+
f"have existed then.",
|
|
421
|
+
"path": at, "commit": commit, "file": path,
|
|
422
|
+
}]
|
|
423
|
+
|
|
424
|
+
issues: "list[dict]" = []
|
|
425
|
+
symbol = str(e.get("symbol") or "").strip()
|
|
426
|
+
if symbol:
|
|
427
|
+
absent = [t for t in _SYMBOL_TOKEN_RE.findall(symbol) if t not in src]
|
|
428
|
+
if absent:
|
|
429
|
+
issues.append({
|
|
430
|
+
"code": "EVIDENCE_SYMBOL_NOT_FOUND",
|
|
431
|
+
"message": f"{at} cites symbol {symbol!r} in {path} at {commit}, "
|
|
432
|
+
f"but {', '.join(repr(a) for a in absent)} does not "
|
|
433
|
+
f"appear there. Cite what the code is actually called.",
|
|
434
|
+
"path": at, "commit": commit, "file": path,
|
|
435
|
+
"symbol": symbol, "absent": absent,
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
lines = str(e.get("lines") or "").strip()
|
|
439
|
+
m = re.match(r"^(\d+)\s*-\s*(\d+)$", lines) if lines else None
|
|
440
|
+
if m:
|
|
441
|
+
lo, hi = int(m.group(1)), int(m.group(2))
|
|
442
|
+
total = len(src.splitlines())
|
|
443
|
+
if lo < 1 or hi > total or lo > hi:
|
|
444
|
+
issues.append({
|
|
445
|
+
"code": "EVIDENCE_LINES_OUT_OF_RANGE",
|
|
446
|
+
"message": f"{at} cites lines {lines} of {path} at {commit}, "
|
|
447
|
+
f"which has {total} line(s).",
|
|
448
|
+
"path": at, "commit": commit, "file": path,
|
|
449
|
+
"lines": lines, "file_lines": total,
|
|
450
|
+
})
|
|
451
|
+
return issues
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _prose_strings(obj, keys: "list[str]") -> "list[str]":
|
|
455
|
+
"""The prose under ``keys``, flattened. A field may be a string or a list."""
|
|
456
|
+
out: "list[str]" = []
|
|
457
|
+
for k in keys:
|
|
458
|
+
v = obj.get(k)
|
|
459
|
+
if isinstance(v, str):
|
|
460
|
+
out.append(v)
|
|
461
|
+
elif isinstance(v, list):
|
|
462
|
+
out.extend(x for x in v if isinstance(x, str))
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def cited_symbols(text: str) -> "list[str]":
|
|
467
|
+
"""Identifier-shaped code spans in prose, in order, without duplicates.
|
|
468
|
+
|
|
469
|
+
Only spans that are *entirely* an identifier (optionally dotted, optionally
|
|
470
|
+
with a trailing call) are returned. A span is not a symbol when it holds a
|
|
471
|
+
path (`.git-worklog/days/`), a flag (`--language`), a tag (`zh-TW`), a date,
|
|
472
|
+
or a phrase -- all of which are legitimately backticked and none of which
|
|
473
|
+
Git's index would find. Being strict here is what keeps the check quiet
|
|
474
|
+
enough that its output means something.
|
|
475
|
+
"""
|
|
476
|
+
seen, out = set(), []
|
|
477
|
+
for span in _CODE_SPAN_RE.findall(text):
|
|
478
|
+
name = span.strip()
|
|
479
|
+
if name.endswith("()"):
|
|
480
|
+
name = name[:-2]
|
|
481
|
+
if not name or not all(
|
|
482
|
+
_SYMBOL_TOKEN_RE.fullmatch(part) for part in name.split(".") if part):
|
|
483
|
+
continue
|
|
484
|
+
for token in _SYMBOL_TOKEN_RE.findall(name):
|
|
485
|
+
if token not in seen:
|
|
486
|
+
seen.add(token)
|
|
487
|
+
out.append(token)
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _all_prose_tokens(obj) -> "list[str]":
|
|
492
|
+
"""Every symbol the whole result names, so one grep can answer them all.
|
|
493
|
+
|
|
494
|
+
Deliberately the same two functions `validate_prose` uses over the same two
|
|
495
|
+
key sets: a priming pass that asked a different question would leave the
|
|
496
|
+
tokens it missed to be greped one at a time, quietly restoring the cost it
|
|
497
|
+
exists to remove.
|
|
498
|
+
"""
|
|
499
|
+
tokens: "list[str]" = []
|
|
500
|
+
for text in _prose_strings(obj, TOP_LEVEL_PROSE_KEYS):
|
|
501
|
+
tokens.extend(cited_symbols(text))
|
|
502
|
+
for item in obj.get("work_items") or []:
|
|
503
|
+
if isinstance(item, dict):
|
|
504
|
+
for text in _prose_strings(item, PROSE_KEYS):
|
|
505
|
+
tokens.extend(cited_symbols(text))
|
|
506
|
+
return tokens
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _stamp(issues: "list[dict]") -> "list[dict]":
|
|
510
|
+
"""Mark each issue blocking or unverified.
|
|
511
|
+
|
|
512
|
+
The line is "found to be false" against "could not be checked", and only
|
|
513
|
+
the first should stop a run. Failing a day because the clone was shallow,
|
|
514
|
+
or because a cited file is a `.docx`, punishes the subagent for the
|
|
515
|
+
environment -- and a day held back for a reason no re-analysis can fix is a
|
|
516
|
+
day whose correct work is thrown away and paid for again.
|
|
517
|
+
"""
|
|
518
|
+
for issue in issues:
|
|
519
|
+
issue.setdefault("severity",
|
|
520
|
+
"unverified" if issue.get("code") in UNVERIFIED_CODES
|
|
521
|
+
else "blocking")
|
|
522
|
+
return issues
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def validate_prose(obj, keys: "list[str]", where: str, tree: "Tree | None",
|
|
526
|
+
trees: "tuple[str, ...]") -> "list[dict]":
|
|
527
|
+
"""Every symbol the prose names must exist in the day it describes.
|
|
528
|
+
|
|
529
|
+
The weaker half of the evidence check, and the one that guards what the
|
|
530
|
+
worklog is actually written from. It asks only "does this name exist here",
|
|
531
|
+
because prose names no file -- but every fabrication observed so far was a
|
|
532
|
+
plausible name for code that does not exist at all, which this catches.
|
|
533
|
+
"""
|
|
534
|
+
if tree is None or not trees:
|
|
535
|
+
return []
|
|
536
|
+
issues: "list[dict]" = []
|
|
537
|
+
for text in _prose_strings(obj, keys):
|
|
538
|
+
for token in cited_symbols(text):
|
|
539
|
+
if not tree.names_anything(token, trees):
|
|
540
|
+
issues.append({
|
|
541
|
+
"code": "PROSE_SYMBOL_NOT_FOUND",
|
|
542
|
+
"message": f"{where} names `{token}`, which appears nowhere "
|
|
543
|
+
f"in the project on this day. The worklog is "
|
|
544
|
+
f"written from this prose, so cite what the code "
|
|
545
|
+
f"is actually called.",
|
|
546
|
+
"path": where, "symbol": token,
|
|
547
|
+
})
|
|
548
|
+
return issues
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def mentioned_files(obj) -> "set[str]":
|
|
552
|
+
"""Every file path the analysis refers to, anywhere in the result.
|
|
553
|
+
|
|
554
|
+
Both `files[]` lists and `evidence[].file` count. They do different jobs --
|
|
555
|
+
`files[]` inventories what a work item touched, `evidence[]` cites proof of
|
|
556
|
+
a specific claim -- and coverage asks the weaker question: was this file
|
|
557
|
+
accounted for at all?
|
|
558
|
+
|
|
559
|
+
The walk is deliberately structure-agnostic. The contract pins `files[]` only
|
|
560
|
+
on `work_items[]`, but leaves the shape of `fixes[]`, `refactors[]`,
|
|
561
|
+
`configuration_changes[]` and friends open, and a real run put `files[]` on
|
|
562
|
+
those too. Reading only the specified location would count an honestly
|
|
563
|
+
recorded config change as an omission.
|
|
564
|
+
"""
|
|
565
|
+
found: "set[str]" = set()
|
|
566
|
+
|
|
567
|
+
def add(value):
|
|
568
|
+
if isinstance(value, str) and value.strip():
|
|
569
|
+
found.add(value.strip())
|
|
570
|
+
elif isinstance(value, dict) and value.get("path"):
|
|
571
|
+
found.add(str(value["path"]).strip())
|
|
572
|
+
|
|
573
|
+
def walk(node):
|
|
574
|
+
if isinstance(node, dict):
|
|
575
|
+
for key, value in node.items():
|
|
576
|
+
if key == "files" and isinstance(value, list):
|
|
577
|
+
for item in value:
|
|
578
|
+
add(item)
|
|
579
|
+
elif key == "evidence" and isinstance(value, list):
|
|
580
|
+
for e in value:
|
|
581
|
+
if isinstance(e, dict) and e.get("file"):
|
|
582
|
+
add(str(e["file"]))
|
|
583
|
+
walk(value)
|
|
584
|
+
elif isinstance(node, list):
|
|
585
|
+
for item in node:
|
|
586
|
+
walk(item)
|
|
587
|
+
|
|
588
|
+
walk(obj)
|
|
589
|
+
return found
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def validate_coverage(obj, required: "set[str] | None") -> "list[dict]":
|
|
593
|
+
"""Every source file the manifest required must be accounted for.
|
|
594
|
+
|
|
595
|
+
This is the other half of the evidence problem. #15 fixed *accuracy* -- a
|
|
596
|
+
citation now has to resolve against the tree it names. Accuracy says nothing
|
|
597
|
+
about *completeness*: a day can cite three real files perfectly and never
|
|
598
|
+
mention the other twenty it changed, and read as confident, verified work.
|
|
599
|
+
|
|
600
|
+
The bar is what measurement supports rather than what the roadmap's wording
|
|
601
|
+
suggests. Requiring every changed file to appear in `evidence[]` sounds
|
|
602
|
+
stricter and is: measured against a real, good run it would have rejected it
|
|
603
|
+
(31% cited). Measured the same way, this rule passes that run at 91% while
|
|
604
|
+
still naming the two files its analysis genuinely never reached.
|
|
605
|
+
"""
|
|
606
|
+
if not required:
|
|
607
|
+
return []
|
|
608
|
+
missing = sorted(required - mentioned_files(obj))
|
|
609
|
+
if not missing:
|
|
610
|
+
return []
|
|
611
|
+
return [{
|
|
612
|
+
"code": "COVERAGE_INCOMPLETE",
|
|
613
|
+
"message": f"{len(missing)} source file(s) the day changed are not "
|
|
614
|
+
f"mentioned anywhere in the analysis: "
|
|
615
|
+
f"{', '.join(missing[:5])}"
|
|
616
|
+
f"{f' (+{len(missing) - 5} more)' if len(missing) > 5 else ''}. "
|
|
617
|
+
f"A file that was changed but never described may not have "
|
|
618
|
+
f"been read.",
|
|
619
|
+
"missing_files": missing,
|
|
620
|
+
"required_count": len(required),
|
|
621
|
+
"covered_count": len(required) - len(missing),
|
|
622
|
+
}]
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _validate_language(obj, expected: "str | None") -> "list[dict]":
|
|
626
|
+
"""Check the result's language field against the manifest's (§6.2.9).
|
|
627
|
+
|
|
628
|
+
Structural only, on purpose. The roadmap is explicit that a natural-language
|
|
629
|
+
detector must not decide this: engineering prose is full of English
|
|
630
|
+
identifiers, paths and API names by contract, so a detector reading a
|
|
631
|
+
correct zh-TW work item would see enough English to doubt it. What is
|
|
632
|
+
checkable is that the subagent declared a language, that the tag is
|
|
633
|
+
well-formed, and that it is the one it was asked for.
|
|
634
|
+
"""
|
|
635
|
+
issues: "list[dict]" = []
|
|
636
|
+
raw = obj.get("language")
|
|
637
|
+
if raw is None:
|
|
638
|
+
return issues # absence is already reported by the required-keys check
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
tag = language.normalize(raw)
|
|
642
|
+
except language.LanguageError as exc:
|
|
643
|
+
return [{"code": "RESULT_BAD_LANGUAGE", "message": exc.message,
|
|
644
|
+
"language": raw}]
|
|
645
|
+
|
|
646
|
+
if expected is not None and tag != expected:
|
|
647
|
+
issues.append({
|
|
648
|
+
"code": "RESULT_LANGUAGE_MISMATCH",
|
|
649
|
+
"message": f"Result is in {tag!r} but the manifest asked for "
|
|
650
|
+
f"{expected!r}.",
|
|
651
|
+
"language": tag,
|
|
652
|
+
"expected_language": expected,
|
|
653
|
+
})
|
|
654
|
+
return issues
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def validate(obj, date: str, expected_language: "str | None" = None,
|
|
658
|
+
tree: "Tree | None" = None,
|
|
659
|
+
required: "set[str] | None" = None,
|
|
660
|
+
commits: "list[str] | None" = None) -> "list[dict]":
|
|
661
|
+
"""Structural check against the §6 return schema. Returns issue dicts.
|
|
662
|
+
|
|
663
|
+
``required`` is the manifest's required file set. It is optional because a
|
|
664
|
+
caller without a manifest genuinely cannot know it -- `collect_day_results.py`
|
|
665
|
+
is handed a run directory and nothing else, so it checks what it can and does
|
|
666
|
+
not pretend to check coverage. `analyze collect` reads the manifests, so it
|
|
667
|
+
can, and does.
|
|
668
|
+
|
|
669
|
+
``commits`` is the day's commit list **from the manifest**, and bounds the
|
|
670
|
+
trees the prose check searches. Deliberately not ``obj["commits"]``: that is
|
|
671
|
+
written by the subagent being checked, and a check whose scope its subject
|
|
672
|
+
chooses is not a check.
|
|
673
|
+
"""
|
|
674
|
+
issues: "list[dict]" = []
|
|
675
|
+
if not isinstance(obj, dict):
|
|
676
|
+
return _stamp([{"code": "RESULT_NOT_OBJECT",
|
|
677
|
+
"message": "The result file must contain a JSON "
|
|
678
|
+
"object."}])
|
|
679
|
+
|
|
680
|
+
scope = tree.day_scope(commits) if (tree and commits) else None
|
|
681
|
+
trees = scope["trees"] if scope else ()
|
|
682
|
+
if trees:
|
|
683
|
+
# One grep for the whole result, before anything asks about a token.
|
|
684
|
+
tree.prime(_all_prose_tokens(obj), trees)
|
|
685
|
+
if scope and scope["truncated"]:
|
|
686
|
+
issues.append({
|
|
687
|
+
"code": "PROSE_SCOPE_TRUNCATED",
|
|
688
|
+
"message": f"This day has {scope['commits']} commits, past the "
|
|
689
|
+
f"{MAX_DAY_TREES}-tree limit, so only its starting "
|
|
690
|
+
f"states and its tips were searched. A symbol that "
|
|
691
|
+
f"existed only part-way through the day is neither "
|
|
692
|
+
f"reported as missing nor confirmed.",
|
|
693
|
+
"commits": scope["commits"], "trees_searched": len(trees),
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
issues.extend(_validate_language(obj, expected_language))
|
|
697
|
+
issues.extend(validate_coverage(obj, required))
|
|
698
|
+
|
|
699
|
+
missing = [k for k in REQUIRED_KEYS if k not in obj]
|
|
700
|
+
if missing:
|
|
701
|
+
issues.append({
|
|
702
|
+
"code": "RESULT_SCHEMA_INVALID",
|
|
703
|
+
"message": f"Missing required keys: {', '.join(missing)}.",
|
|
704
|
+
"missing_keys": missing,
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
if obj.get("date") != date:
|
|
708
|
+
issues.append({
|
|
709
|
+
"code": "RESULT_DATE_MISMATCH",
|
|
710
|
+
"message": f"Result says date={obj.get('date')!r} but it was "
|
|
711
|
+
f"produced for {date!r}.",
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
status = obj.get("status")
|
|
715
|
+
if status is not None and status not in VALID_STATUS:
|
|
716
|
+
issues.append({
|
|
717
|
+
"code": "RESULT_BAD_STATUS",
|
|
718
|
+
"message": f"status must be one of {sorted(VALID_STATUS)} (got {status!r}).",
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
confidence = obj.get("confidence")
|
|
722
|
+
if confidence is not None and confidence not in VALID_CONFIDENCE:
|
|
723
|
+
issues.append({
|
|
724
|
+
"code": "RESULT_BAD_CONFIDENCE",
|
|
725
|
+
"message": f"confidence must be one of {sorted(VALID_CONFIDENCE)} "
|
|
726
|
+
f"(got {confidence!r}).",
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
issues.extend(validate_evidence(obj.get("evidence"), "evidence", tree))
|
|
730
|
+
issues.extend(validate_prose(obj, TOP_LEVEL_PROSE_KEYS, "handoff_notes/"
|
|
731
|
+
"uncertainties", tree, trees))
|
|
732
|
+
|
|
733
|
+
work_items = obj.get("work_items")
|
|
734
|
+
if work_items is not None and not isinstance(work_items, list):
|
|
735
|
+
issues.append({"code": "RESULT_SCHEMA_INVALID",
|
|
736
|
+
"message": "work_items must be an array."})
|
|
737
|
+
elif isinstance(work_items, list):
|
|
738
|
+
for idx, item in enumerate(work_items):
|
|
739
|
+
if not isinstance(item, dict):
|
|
740
|
+
issues.append({
|
|
741
|
+
"code": "WORK_ITEM_INVALID",
|
|
742
|
+
"message": f"work_items[{idx}] must be an object.",
|
|
743
|
+
})
|
|
744
|
+
continue
|
|
745
|
+
item_missing = [k for k in REQUIRED_WORK_ITEM_KEYS if k not in item]
|
|
746
|
+
if item_missing:
|
|
747
|
+
issues.append({
|
|
748
|
+
"code": "WORK_ITEM_SCHEMA_INVALID",
|
|
749
|
+
"message": f"work_items[{idx}] is missing: "
|
|
750
|
+
f"{', '.join(item_missing)}.",
|
|
751
|
+
"index": idx,
|
|
752
|
+
"missing_keys": item_missing,
|
|
753
|
+
})
|
|
754
|
+
issues.extend(
|
|
755
|
+
validate_evidence(item.get("evidence"),
|
|
756
|
+
f"work_items[{idx}].evidence", tree))
|
|
757
|
+
issues.extend(
|
|
758
|
+
validate_prose(item, PROSE_KEYS, f"work_items[{idx}]",
|
|
759
|
+
tree, trees))
|
|
760
|
+
return _stamp(issues)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def read_run(run_dir: str, dates: "list[str]", repo: str,
|
|
764
|
+
expected_language: "str | None" = None,
|
|
765
|
+
required_by_date: "dict[str, set[str]] | None" = None,
|
|
766
|
+
commits_by_date: "dict[str, list[str]] | None" = None) -> dict:
|
|
767
|
+
"""Read every dispatched day's result file and validate it.
|
|
768
|
+
|
|
769
|
+
``required_by_date`` maps a date to the source files its manifest requires
|
|
770
|
+
the analysis to account for. ``commits_by_date`` maps a date to the commits
|
|
771
|
+
its manifest dispatched, which bound the prose check's search. Both are
|
|
772
|
+
omitted by callers that never saw a manifest, which then check what they can
|
|
773
|
+
rather than pretend.
|
|
774
|
+
"""
|
|
775
|
+
if not os.path.isdir(run_dir):
|
|
776
|
+
raise AnalysisError("RUN_DIR_MISSING",
|
|
777
|
+
f"No such analysis run directory: {run_dir}.",
|
|
778
|
+
run_dir=run_dir)
|
|
779
|
+
|
|
780
|
+
tree = Tree(repo)
|
|
781
|
+
if not tree.has_commit("HEAD"):
|
|
782
|
+
raise AnalysisError(
|
|
783
|
+
"NOT_A_GIT_REPO",
|
|
784
|
+
f"{repo} is not a readable Git repository, so evidence cannot be "
|
|
785
|
+
f"checked against the commits it cites.",
|
|
786
|
+
repo=repo)
|
|
787
|
+
|
|
788
|
+
results: "dict[str, dict]" = {}
|
|
789
|
+
missing: "list[str]" = []
|
|
790
|
+
invalid: "list[dict]" = []
|
|
791
|
+
unverified: "list[dict]" = []
|
|
792
|
+
|
|
793
|
+
for date in dates:
|
|
794
|
+
path = result_path(run_dir, date)
|
|
795
|
+
if not os.path.isfile(path):
|
|
796
|
+
# The subagent never delivered. This is a failed day, not an empty
|
|
797
|
+
# one -- the caller must not treat it as "nothing happened".
|
|
798
|
+
missing.append(date)
|
|
799
|
+
continue
|
|
800
|
+
try:
|
|
801
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
802
|
+
obj = json.load(fh)
|
|
803
|
+
except json.JSONDecodeError as exc:
|
|
804
|
+
invalid.append({"date": date, "path": path,
|
|
805
|
+
"code": "RESULT_NOT_JSON",
|
|
806
|
+
"message": f"Result file is not valid JSON: {exc}"})
|
|
807
|
+
continue
|
|
808
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
809
|
+
invalid.append({"date": date, "path": path,
|
|
810
|
+
"code": "RESULT_UNREADABLE",
|
|
811
|
+
"message": f"Could not read result file: {exc}"})
|
|
812
|
+
continue
|
|
813
|
+
|
|
814
|
+
issues = validate(obj, date, expected_language, tree,
|
|
815
|
+
(required_by_date or {}).get(date),
|
|
816
|
+
(commits_by_date or {}).get(date))
|
|
817
|
+
blocking = [i for i in issues if i["severity"] == "blocking"]
|
|
818
|
+
if blocking:
|
|
819
|
+
invalid.append({"date": date, "path": path,
|
|
820
|
+
"code": blocking[0]["code"],
|
|
821
|
+
"message": blocking[0]["message"],
|
|
822
|
+
"issues": issues})
|
|
823
|
+
continue
|
|
824
|
+
if issues:
|
|
825
|
+
# Checks that could not be run. The day stands, but silence here is
|
|
826
|
+
# how a validator starts lying about what it verified.
|
|
827
|
+
unverified.append({"date": date, "path": path, "issues": issues})
|
|
828
|
+
results[date] = obj
|
|
829
|
+
|
|
830
|
+
# Every day in a run must be written in one language (§6.2.8). When the
|
|
831
|
+
# manifest's language was passed in, each day was already checked against
|
|
832
|
+
# it, so this can only fire for a run collected without one -- but it fires
|
|
833
|
+
# then, because a worklog whose days silently switch language mid-run is not
|
|
834
|
+
# something to discover after apply.
|
|
835
|
+
languages = sorted({results[d]["language"] for d in results
|
|
836
|
+
if isinstance(results[d].get("language"), str)})
|
|
837
|
+
run_language = languages[0] if len(languages) == 1 else None
|
|
838
|
+
language_inconsistent = len(languages) > 1
|
|
839
|
+
|
|
840
|
+
complete = [d for d in dates if d in results
|
|
841
|
+
and results[d].get("status") == "complete"]
|
|
842
|
+
degraded = [d for d in dates if d in results
|
|
843
|
+
and results[d].get("status") in ("partial", "failed")]
|
|
844
|
+
failed_dates = missing + [i["date"] for i in invalid]
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
"ok": True,
|
|
848
|
+
"run_dir": run_dir,
|
|
849
|
+
"dates": dates,
|
|
850
|
+
"language": run_language,
|
|
851
|
+
"language_inconsistent": language_inconsistent,
|
|
852
|
+
"languages_seen": languages,
|
|
853
|
+
"results": results,
|
|
854
|
+
"complete": complete,
|
|
855
|
+
"degraded": degraded,
|
|
856
|
+
"missing": missing,
|
|
857
|
+
"invalid": invalid,
|
|
858
|
+
"unverified": unverified,
|
|
859
|
+
"failed_dates": failed_dates,
|
|
860
|
+
# A run is partial if any day failed to arrive, arrived malformed, or
|
|
861
|
+
# reported its own status as partial/failed. Apply is blocked by default
|
|
862
|
+
# for a partial run (subagent-contract.md §11) -- which is also the lever
|
|
863
|
+
# that stops a mixed-language run from reaching the worklog, since a day
|
|
864
|
+
# in the wrong language is wrong in a way no amount of retrying the
|
|
865
|
+
# other days fixes.
|
|
866
|
+
"partial_run": bool(failed_dates or degraded or language_inconsistent),
|
|
867
|
+
"escalation_suggested_dates": [
|
|
868
|
+
d for d in dates
|
|
869
|
+
if d in results and results[d].get("escalation_recommended")
|
|
870
|
+
],
|
|
871
|
+
}
|