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.
Files changed (44) hide show
  1. git_worklog/__init__.py +58 -0
  2. git_worklog/__main__.py +10 -0
  3. git_worklog/analysis/__init__.py +48 -0
  4. git_worklog/analysis/commits.py +118 -0
  5. git_worklog/analysis/coverage.py +149 -0
  6. git_worklog/analysis/history.py +339 -0
  7. git_worklog/analysis/manifest.py +432 -0
  8. git_worklog/analysis/reconcile.py +211 -0
  9. git_worklog/analysis/refs.py +178 -0
  10. git_worklog/analysis/results.py +871 -0
  11. git_worklog/analysis/worktree.py +114 -0
  12. git_worklog/cli/__init__.py +414 -0
  13. git_worklog/cli/analyze.py +340 -0
  14. git_worklog/cli/apply.py +61 -0
  15. git_worklog/cli/coverage.py +62 -0
  16. git_worklog/cli/doctor.py +348 -0
  17. git_worklog/cli/migrate.py +43 -0
  18. git_worklog/cli/models.py +112 -0
  19. git_worklog/cli/preview.py +124 -0
  20. git_worklog/cli/refs.py +55 -0
  21. git_worklog/cli/reindex.py +72 -0
  22. git_worklog/cli/report.py +227 -0
  23. git_worklog/cli/validate.py +252 -0
  24. git_worklog/cli/version.py +47 -0
  25. git_worklog/cli/view.py +218 -0
  26. git_worklog/config.py +79 -0
  27. git_worklog/data/provider_models.json +34 -0
  28. git_worklog/dates.py +237 -0
  29. git_worklog/language.py +295 -0
  30. git_worklog/markers.py +765 -0
  31. git_worklog/mdhtml.py +266 -0
  32. git_worklog/migrate.py +305 -0
  33. git_worklog/paths.py +85 -0
  34. git_worklog/preview.py +776 -0
  35. git_worklog/providers.py +337 -0
  36. git_worklog/viewer.py +1198 -0
  37. git_worklog/viewserver.py +120 -0
  38. git_worklog/writer.py +420 -0
  39. git_worklog-1.6.0.dist-info/METADATA +352 -0
  40. git_worklog-1.6.0.dist-info/RECORD +44 -0
  41. git_worklog-1.6.0.dist-info/WHEEL +5 -0
  42. git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
  43. git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
  44. 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
+ }