whykit 0.3.1__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 (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/diff.py ADDED
@@ -0,0 +1,823 @@
1
+ """What changed in the decisions between two Git revisions.
2
+
3
+ ``whykit diff --base REV`` answers the reviewer's question on a pull request:
4
+ which decisions were added, superseded or archived, whose review date moved,
5
+ which evidence was added, retired or re-sourced, which decisions now rest on
6
+ evidence that changed, and which lint findings the change introduces or fixes.
7
+
8
+ Both sides are read straight from Git objects (``git ls-tree`` and ``git
9
+ cat-file``) into private temporary directories; the working tree, the index
10
+ and ``HEAD`` are never touched, so the command is safe in a dirty checkout and
11
+ compares exactly what was committed. Like ``whykit history`` it compares the
12
+ merge base of the two revisions with the head, which is what a pull request
13
+ shows.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import json
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ from collections import Counter
26
+ from dataclasses import asdict, dataclass, field
27
+ from pathlib import Path, PurePosixPath
28
+ from typing import Any
29
+
30
+ from .console import emit_machine, one_line
31
+ from .contract import CONTRACT_VERSION, emit_error
32
+ from .immutability import _diff_entries, _git_arg, _git_prefix, _require_revisions, git
33
+ from .lint import (
34
+ DECISION_ID_RE,
35
+ EVIDENCE_ID_RE,
36
+ TEXT_SECRET_EXTENSIONS,
37
+ Finding,
38
+ _as_list,
39
+ _parse_date,
40
+ evidence_register,
41
+ find_vault_root,
42
+ is_vault_root,
43
+ lint,
44
+ )
45
+
46
+ FORMATS = ("text", "json", "markdown", "github")
47
+ # The first line of every Markdown report. The Action finds its own sticky
48
+ # comment by this marker, so it must stay stable.
49
+ MARKER_PREFIX = "<!-- whykit-diff"
50
+ # A pull request comment is capped at 65,536 characters by GitHub.
51
+ MARKDOWN_LIMIT = 60000
52
+ # Entries shown per Markdown section before "... and N more".
53
+ SECTION_LIMIT = 25
54
+ # Evidence fields whose change means the claim now rests on another source.
55
+ SOURCE_FIELDS = ("source", "location")
56
+ OTHER_FIELDS = ("type", "date", "accessed", "claims")
57
+ _LINE_REF_RE = re.compile(r"\bline \d+\b")
58
+
59
+
60
+ @dataclass
61
+ class _Side:
62
+ """One revision of the vault, read from Git."""
63
+
64
+ present: bool = False
65
+ decisions: dict[str, dict[str, Any]] = field(default_factory=dict)
66
+ active: dict[str, dict[str, str]] = field(default_factory=dict)
67
+ retired: dict[str, dict[str, str]] = field(default_factory=dict)
68
+ findings: list[Finding] = field(default_factory=list)
69
+
70
+
71
+ # --------------------------------------------------------------------------
72
+ # Reading a revision without checking it out
73
+
74
+
75
+ def _tree_spec(rev: str, prefix: str) -> str:
76
+ return f"{rev}:{prefix.rstrip('/')}" if prefix else f"{rev}^{{tree}}"
77
+
78
+
79
+ def _list_tree(rev: str, prefix: str, root: str) -> list[tuple[str, str, str, str]] | None:
80
+ """``(mode, type, object, path)`` for every entry below the vault, or None.
81
+
82
+ None means the vault directory does not exist in *rev* (a vault added or
83
+ removed by the change). ``--full-tree`` matters: run from a subdirectory,
84
+ ``ls-tree`` would otherwise filter the listing by the working directory.
85
+ """
86
+ spec = _tree_spec(rev, prefix)
87
+ try:
88
+ if git("cat-file", "-t", spec, root=root).strip() != "tree":
89
+ return None
90
+ except subprocess.CalledProcessError:
91
+ return None
92
+ out = git("ls-tree", "-r", "-z", "--full-tree", spec, root=root)
93
+ entries: list[tuple[str, str, str, str]] = []
94
+ for token in out.split("\0"):
95
+ if not token:
96
+ continue
97
+ meta, _, path = token.partition("\t")
98
+ parts = meta.split()
99
+ if len(parts) != 3 or not path:
100
+ raise subprocess.CalledProcessError(
101
+ 128, ["git", "ls-tree", "-r", "-z"], output=out,
102
+ stderr="could not parse `git ls-tree -r -z` output",
103
+ )
104
+ entries.append((parts[0], parts[1], parts[2], path))
105
+ return entries
106
+
107
+
108
+ def _read_blobs(oids: list[str], root: str) -> dict[str, bytes]:
109
+ """Read many blobs with one ``git cat-file --batch`` process."""
110
+ if not oids:
111
+ return {}
112
+ unique = list(dict.fromkeys(oids))
113
+ result = subprocess.run(
114
+ ["git", "-C", _git_arg(root), "cat-file", "--batch"],
115
+ input=("\n".join(unique) + "\n").encode("ascii"),
116
+ capture_output=True, check=False,
117
+ )
118
+ if result.returncode != 0:
119
+ raise subprocess.CalledProcessError(
120
+ result.returncode, ["git", "cat-file", "--batch"], output="",
121
+ stderr=result.stderr.decode("utf-8", "replace"),
122
+ )
123
+ data = result.stdout
124
+ blobs: dict[str, bytes] = {}
125
+ at = 0
126
+ for oid in unique:
127
+ newline = data.index(b"\n", at)
128
+ header = data[at:newline].decode("ascii", "replace").split()
129
+ at = newline + 1
130
+ if len(header) != 3:
131
+ raise subprocess.CalledProcessError(
132
+ 128, ["git", "cat-file", "--batch"], output="", stderr=f"cannot read object {oid}",
133
+ )
134
+ size = int(header[2])
135
+ blobs[oid] = data[at : at + size]
136
+ at += size + 1 # the content is followed by a newline
137
+ return blobs
138
+
139
+
140
+ def _safe_parts(path: str) -> tuple[str, ...] | None:
141
+ """The path's components, or None for one that must not be written."""
142
+ parts = tuple(path.split("/"))
143
+ if any(part in ("", ".", "..", ".git") for part in parts):
144
+ return None
145
+ if os.name == "nt" and any(("\\" in part or ":" in part) for part in parts):
146
+ return None
147
+ return parts
148
+
149
+
150
+ def _filesystem_encoding() -> str:
151
+ return sys.getfilesystemencoding()
152
+
153
+
154
+ def _os_name(part: str) -> str:
155
+ """The file name that writes *part*'s original Git bytes to disk.
156
+
157
+ Git names are bytes, decoded here as UTF-8 with surrogateescape. Under a
158
+ C/ASCII locale the interpreter cannot encode such a name for the file
159
+ system and the file would be lost, so decode the same bytes the way the
160
+ file system codec will encode them back. Windows names are text.
161
+ """
162
+ if os.name == "nt":
163
+ return part
164
+ raw = part.encode("utf-8", "surrogateescape")
165
+ return raw.decode(_filesystem_encoding(), "surrogateescape")
166
+
167
+
168
+ def _repo_text(value: str) -> str:
169
+ """Undo :func:`_os_name` for display: surrogates back to UTF-8 text.
170
+
171
+ Only escaped bytes change; text that is already decoded is untouched, and
172
+ bytes that are not valid UTF-8 stay escaped so they never merge.
173
+ """
174
+ if not any("\udc80" <= char <= "\udcff" for char in value):
175
+ return value
176
+ return value.encode("utf-8", "surrogateescape").decode("utf-8", "surrogateescape")
177
+
178
+
179
+ def _wanted_content(name: str) -> bool:
180
+ """Whether lint reads the file; anything else only needs to exist."""
181
+ suffix = PurePosixPath(name).suffix.lower()
182
+ return name.startswith(".env") or suffix in TEXT_SECRET_EXTENSIONS
183
+
184
+
185
+ def materialize(rev: str, prefix: str, root: str, dest: Path) -> bool:
186
+ """Write the vault as it is in *rev* under *dest*; False when it is absent.
187
+
188
+ Only Git objects are read. Symbolic links and submodules are skipped (the
189
+ linter refuses to follow links anyway), and files the linter never opens
190
+ are written empty so a large attachment costs nothing.
191
+ """
192
+ entries = _list_tree(rev, prefix, root)
193
+ if entries is None:
194
+ return False
195
+ files: list[tuple[tuple[str, ...], str, bool]] = []
196
+ for mode, kind, oid, path in entries:
197
+ if kind != "blob" or mode == "120000":
198
+ continue
199
+ parts = _safe_parts(path)
200
+ if parts is None:
201
+ continue
202
+ files.append((parts, oid, _wanted_content(parts[-1])))
203
+ blobs = _read_blobs([oid for _, oid, wanted in files if wanted], root)
204
+ base = dest.resolve()
205
+ for parts, oid, wanted in files:
206
+ target = base.joinpath(*(_os_name(part) for part in parts))
207
+ try:
208
+ target.parent.mkdir(parents=True, exist_ok=True)
209
+ with open(target, "wb") as handle:
210
+ if wanted:
211
+ handle.write(blobs[oid])
212
+ except (OSError, UnicodeError, ValueError):
213
+ # A name this filesystem cannot hold (surrogates on Windows, a
214
+ # case-insensitive clash) is skipped, not fatal.
215
+ continue
216
+ return True
217
+
218
+
219
+ # --------------------------------------------------------------------------
220
+ # What a revision says
221
+
222
+
223
+ def _text(value: object) -> str:
224
+ if value is None:
225
+ return ""
226
+ if isinstance(value, dt.date):
227
+ return value.isoformat()
228
+ return str(value).strip()
229
+
230
+
231
+ def _read_side(directory: Path, present: bool, today: dt.date) -> _Side:
232
+ if not present or not is_vault_root(directory):
233
+ return _Side(present=False)
234
+ from .vault_index import VaultIndex
235
+
236
+ vault = VaultIndex.load(directory)
237
+ decisions: dict[str, dict[str, Any]] = {}
238
+ for note in sorted(vault.notes, key=lambda item: vault.relative(item.path)):
239
+ own = _text(note.front.get("decision_id"))
240
+ if not DECISION_ID_RE.fullmatch(own) or _text(note.front.get("status")) == "template":
241
+ continue
242
+ if own in decisions:
243
+ continue # lint reports the duplicate; the first record wins here
244
+ decisions[own] = {
245
+ "id": own,
246
+ "title": _text(note.front.get("title")),
247
+ "status": _text(note.front.get("status")),
248
+ "review_by": _text(note.front.get("review_by")) or None,
249
+ "supersedes": sorted({sid for sid in _as_list(note.front.get("supersedes")) if DECISION_ID_RE.fullmatch(sid)}),
250
+ "superseded_by": sorted({sid for sid in _as_list(note.front.get("superseded_by")) if DECISION_ID_RE.fullmatch(sid)}),
251
+ "path": _repo_text(vault.relative(note.path)),
252
+ "cites": list(note.cited_evidence),
253
+ }
254
+ active, retired, _ = evidence_register(directory)
255
+ _, found = lint(directory, today=today, vault=vault)
256
+ # Paths read back from disk carry the escapes _os_name wrote; report the
257
+ # names Git has, which is also what the rename map is keyed by.
258
+ findings = [
259
+ Finding(_repo_text(item.path), item.line, item.level, item.code, _repo_text(item.message))
260
+ for item in found
261
+ ]
262
+ return _Side(True, decisions, active, retired, findings)
263
+
264
+
265
+ # --------------------------------------------------------------------------
266
+ # Comparing two revisions
267
+
268
+
269
+ def _id_key(identifier: str) -> tuple[int, str]:
270
+ digits = identifier.split("-", 1)[-1]
271
+ return (int(digits) if digits.isdigit() else 0, identifier)
272
+
273
+
274
+ def _chain(decision_id: str, decisions: dict[str, dict[str, Any]]) -> list[str]:
275
+ """The supersession line through *decision_id*, newest first.
276
+
277
+ Both directions count: a newer record's ``supersedes`` and an older
278
+ record's optional ``superseded_by``.
279
+ """
280
+ newer: dict[str, set[str]] = {}
281
+ older: dict[str, set[str]] = {}
282
+ for record in decisions.values():
283
+ for old in record["supersedes"]:
284
+ newer.setdefault(old, set()).add(record["id"])
285
+ older.setdefault(record["id"], set()).add(old)
286
+ for new in record["superseded_by"]:
287
+ newer.setdefault(record["id"], set()).add(new)
288
+ older.setdefault(new, set()).add(record["id"])
289
+ tip = decision_id
290
+ seen = {tip}
291
+ while newer.get(tip):
292
+ candidates = sorted(newer[tip] - seen, key=_id_key)
293
+ if not candidates:
294
+ break
295
+ tip = candidates[-1]
296
+ seen.add(tip)
297
+ chain = [tip]
298
+ while older.get(chain[-1]):
299
+ candidates = sorted(older[chain[-1]] - set(chain), key=_id_key)
300
+ if not candidates:
301
+ break
302
+ chain.append(candidates[-1])
303
+ return chain
304
+
305
+
306
+ def _decision_summary(record: dict[str, Any]) -> dict[str, Any]:
307
+ return {key: record[key] for key in ("id", "title", "status", "path")}
308
+
309
+
310
+ def _review_direction(before: str | None, after: str | None) -> str:
311
+ if not before:
312
+ return "set"
313
+ if not after:
314
+ return "cleared"
315
+ old, new = _parse_date(before), _parse_date(after)
316
+ if old is None or new is None:
317
+ return "changed"
318
+ return "later" if new > old else "earlier"
319
+
320
+
321
+ def _compare_decisions(base: _Side, head: _Side) -> dict[str, list[dict[str, Any]]]:
322
+ out: dict[str, list[dict[str, Any]]] = {
323
+ "added": [], "removed": [], "superseded": [], "archived": [],
324
+ "status_changed": [], "review_moved": [], "moved": [],
325
+ }
326
+ for did in sorted(set(base.decisions) | set(head.decisions), key=_id_key):
327
+ before, after = base.decisions.get(did), head.decisions.get(did)
328
+ if after is None and before is not None:
329
+ out["removed"].append(_decision_summary(before))
330
+ continue
331
+ assert after is not None
332
+ if before is None:
333
+ out["added"].append({
334
+ **_decision_summary(after),
335
+ "supersedes": after["supersedes"],
336
+ "chain": _chain(did, head.decisions),
337
+ })
338
+ continue
339
+ if before["status"] != after["status"]:
340
+ change = {**_decision_summary(after), "from": before["status"] or None, "to": after["status"] or None}
341
+ if after["status"] in ("superseded", "archived"):
342
+ replaced_by = sorted(
343
+ set(after["superseded_by"])
344
+ | {other["id"] for other in head.decisions.values() if did in other["supersedes"]},
345
+ key=_id_key,
346
+ )
347
+ out[after["status"]].append({**change, "superseded_by": replaced_by, "chain": _chain(did, head.decisions)})
348
+ else:
349
+ out["status_changed"].append(change)
350
+ if before["review_by"] != after["review_by"]:
351
+ out["review_moved"].append({
352
+ **_decision_summary(after),
353
+ "from": before["review_by"],
354
+ "to": after["review_by"],
355
+ "direction": _review_direction(before["review_by"], after["review_by"]),
356
+ })
357
+ if before["path"] != after["path"]:
358
+ out["moved"].append({"id": did, "title": after["title"], "from": before["path"], "to": after["path"]})
359
+ return out
360
+
361
+
362
+ def _evidence_row(eid: str, row: dict[str, str]) -> dict[str, Any]:
363
+ return {"id": eid, **{key: row.get(key, "") for key in ("source", "type", "date", "accessed", "location", "claims")}}
364
+
365
+
366
+ def _retired_row(eid: str, row: dict[str, str]) -> dict[str, Any]:
367
+ replaced = row.get("replaced_by", "").strip()
368
+ return {
369
+ "id": eid,
370
+ "source": row.get("source", ""),
371
+ "retired_on": row.get("retired_on", ""),
372
+ "why": row.get("why", ""),
373
+ "replaced_by": replaced if EVIDENCE_ID_RE.fullmatch(replaced) else None,
374
+ }
375
+
376
+
377
+ def _changes(before: dict[str, str], after: dict[str, str], fields: tuple[str, ...]) -> dict[str, dict[str, str]]:
378
+ return {
379
+ key: {"from": before.get(key, ""), "to": after.get(key, "")}
380
+ for key in fields
381
+ if before.get(key, "") != after.get(key, "")
382
+ }
383
+
384
+
385
+ def _compare_evidence(base: _Side, head: _Side) -> dict[str, list[dict[str, Any]]]:
386
+ out: dict[str, list[dict[str, Any]]] = {"added": [], "retired": [], "resourced": [], "updated": [], "removed": []}
387
+ ids = set(base.active) | set(base.retired) | set(head.active) | set(head.retired)
388
+ for eid in sorted(ids, key=_id_key):
389
+ if eid in head.retired:
390
+ if eid not in base.retired:
391
+ out["retired"].append(_retired_row(eid, head.retired[eid]))
392
+ continue
393
+ if eid in head.active:
394
+ after = head.active[eid]
395
+ if eid in base.active:
396
+ before = base.active[eid]
397
+ resourced = _changes(before, after, SOURCE_FIELDS)
398
+ if resourced:
399
+ out["resourced"].append({"id": eid, "changes": resourced | _changes(before, after, OTHER_FIELDS)})
400
+ elif updated := _changes(before, after, OTHER_FIELDS):
401
+ out["updated"].append({"id": eid, "changes": updated})
402
+ elif eid in base.retired:
403
+ # Back from retirement: a restored ID is a change to review.
404
+ out["updated"].append({"id": eid, "changes": {"state": {"from": "retired", "to": "active"}}})
405
+ else:
406
+ out["added"].append(_evidence_row(eid, after))
407
+ continue
408
+ # Registered before, gone now: IDs are never supposed to disappear.
409
+ row = base.active.get(eid) or base.retired.get(eid) or {}
410
+ out["removed"].append({"id": eid, "source": row.get("source", "")})
411
+ return out
412
+
413
+
414
+ def _affected_decisions(head: _Side, base: _Side, evidence: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
415
+ changed: dict[str, str] = {}
416
+ for change in ("removed", "updated", "resourced", "retired"):
417
+ for item in evidence[change]:
418
+ changed[item["id"]] = change
419
+ if not changed:
420
+ return []
421
+ affected = []
422
+ decisions = head.decisions if head.present else base.decisions
423
+ for did in sorted(decisions, key=_id_key):
424
+ record = decisions[did]
425
+ hits = [{"id": eid, "change": changed[eid]} for eid in record["cites"] if eid in changed]
426
+ if hits:
427
+ affected.append({**_decision_summary(record), "evidence": hits})
428
+ return affected
429
+
430
+
431
+ def _finding_key(finding: Finding, rename: dict[str, str]) -> tuple[str, str, str, str]:
432
+ path = rename.get(finding.path, finding.path)
433
+ return (finding.level, finding.code, path, _LINE_REF_RE.sub("line N", finding.message))
434
+
435
+
436
+ def _compare_findings(base: _Side, head: _Side, rename: dict[str, str]) -> dict[str, Any]:
437
+ def counts(side: _Side) -> dict[str, int]:
438
+ return {
439
+ "errors": sum(1 for finding in side.findings if finding.level == "error"),
440
+ "warnings": sum(1 for finding in side.findings if finding.level == "warning"),
441
+ }
442
+
443
+ before = Counter(_finding_key(finding, rename) for finding in base.findings)
444
+ after = Counter(_finding_key(finding, {}) for finding in head.findings)
445
+ introduced: list[dict[str, Any]] = []
446
+ budget = before.copy()
447
+ for finding in head.findings:
448
+ key = _finding_key(finding, {})
449
+ if budget[key] > 0:
450
+ budget[key] -= 1
451
+ else:
452
+ introduced.append(asdict(finding))
453
+ fixed: list[dict[str, Any]] = []
454
+ budget = after.copy()
455
+ for finding in base.findings:
456
+ key = _finding_key(finding, rename)
457
+ if budget[key] > 0:
458
+ budget[key] -= 1
459
+ else:
460
+ fixed.append({**asdict(finding), "path": rename.get(finding.path, finding.path)})
461
+
462
+ def order(item: dict[str, Any]) -> tuple:
463
+ return (item["level"] != "error", item["path"], item["line"] or 0, item["code"])
464
+
465
+ return {
466
+ "base": counts(base),
467
+ "head": counts(head),
468
+ "introduced": sorted(introduced, key=order),
469
+ "fixed": sorted(fixed, key=order),
470
+ }
471
+
472
+
473
+ def _renames(merge_base: str, head: str, root: str) -> dict[str, str]:
474
+ out = git("diff", "--relative", "--name-status", "-z", "--no-ext-diff", "-M", merge_base, head, "--", ".", root=root)
475
+ return {old: new for status, old, new in _diff_entries(out) if status.startswith("R")}
476
+
477
+
478
+ def _short(rev: str, root: str) -> str:
479
+ return git("rev-parse", "--verify", f"{rev}^{{commit}}", root=root).strip()
480
+
481
+
482
+ def _merge_base(base: str, head: str, root: str) -> str:
483
+ try:
484
+ return git("merge-base", base, head, root=root).strip()
485
+ except subprocess.CalledProcessError as exc:
486
+ try:
487
+ shallow = git("rev-parse", "--is-shallow-repository", root=root).strip() == "true"
488
+ except subprocess.CalledProcessError:
489
+ shallow = False
490
+ detail = f"no merge base between {base} and {head}" + (" in this shallow clone" if shallow else "")
491
+ hint = (
492
+ "hint: fetch full history (actions/checkout `fetch-depth: 0`, or `git fetch --unshallow`)"
493
+ if shallow
494
+ else "hint: the two revisions share no history; pass a --base on the same line of history"
495
+ )
496
+ raise subprocess.CalledProcessError(exc.returncode, exc.cmd, output="", stderr=f"{detail}\n{hint}") from None
497
+
498
+
499
+ def build_diff(root: Path, base: str, head: str = "HEAD", *, today: dt.date | None = None) -> dict[str, Any]:
500
+ """Compare the vault at the merge base of *base* and *head* with *head*."""
501
+ today = today or dt.date.today()
502
+ where = str(root)
503
+ _require_revisions(where, base, head)
504
+ merge_base = _merge_base(base, head, where)
505
+ head_commit = _short(head, where)
506
+ prefix = _git_prefix(where)
507
+ with tempfile.TemporaryDirectory(prefix="whykit-diff-") as scratch:
508
+ base_dir, head_dir = Path(scratch) / "base", Path(scratch) / "head"
509
+ base_dir.mkdir()
510
+ head_dir.mkdir()
511
+ base_side = _read_side(base_dir, materialize(merge_base, prefix, where, base_dir), today)
512
+ head_side = _read_side(head_dir, materialize(head_commit, prefix, where, head_dir), today)
513
+ rename = _renames(merge_base, head_commit, where)
514
+ decisions = _compare_decisions(base_side, head_side)
515
+ evidence = _compare_evidence(base_side, head_side)
516
+ affected = _affected_decisions(head_side, base_side, evidence)
517
+ findings = _compare_findings(base_side, head_side, rename)
518
+ changed = any(decisions.values()) or any(evidence.values()) or bool(findings["introduced"] or findings["fixed"])
519
+ return {
520
+ "contract_version": CONTRACT_VERSION,
521
+ "root": str(root),
522
+ "prefix": prefix,
523
+ "base": base,
524
+ "head": head,
525
+ "merge_base": merge_base,
526
+ "head_commit": head_commit,
527
+ "today": today.isoformat(),
528
+ "vault_at_base": base_side.present,
529
+ "vault_at_head": head_side.present,
530
+ "changed": changed,
531
+ "decisions": decisions,
532
+ "evidence": evidence,
533
+ "affected_decisions": affected,
534
+ "lint": findings,
535
+ }
536
+
537
+
538
+ # --------------------------------------------------------------------------
539
+ # Rendering
540
+
541
+
542
+ def _plural(count: int, word: str) -> str:
543
+ return f"{count} {word}" + ("" if count == 1 else "s")
544
+
545
+
546
+ def _summary_parts(report: dict[str, Any]) -> list[str]:
547
+ decisions, evidence, findings = report["decisions"], report["evidence"], report["lint"]
548
+ parts = []
549
+ for key, label in (("added", "added"), ("superseded", "superseded"), ("archived", "archived"), ("removed", "removed")):
550
+ if decisions[key]:
551
+ parts.append(f"{len(decisions[key])} decision(s) {label}")
552
+ if decisions["status_changed"]:
553
+ parts.append(_plural(len(decisions["status_changed"]), "status change"))
554
+ if decisions["review_moved"]:
555
+ parts.append(_plural(len(decisions["review_moved"]), "review date move"))
556
+ for key in ("added", "retired", "resourced", "updated", "removed"):
557
+ if evidence[key]:
558
+ parts.append(f"{len(evidence[key])} evidence {'re-sourced' if key == 'resourced' else key}")
559
+ if report["affected_decisions"]:
560
+ parts.append(f"{len(report['affected_decisions'])} decision(s) cite changed evidence")
561
+ if findings["introduced"]:
562
+ parts.append(f"{len(findings['introduced'])} new lint finding(s)")
563
+ if findings["fixed"]:
564
+ parts.append(f"{len(findings['fixed'])} lint finding(s) fixed")
565
+ return parts
566
+
567
+
568
+ def _chain_text(chain: list[str]) -> str:
569
+ return " → ".join(chain)
570
+
571
+
572
+ def _lines(report: dict[str, Any], fmt: str) -> list[tuple[str, list[str]]]:
573
+ """Section title and entries, shared by the text and Markdown renderers."""
574
+ esc = _md if fmt == "markdown" else (lambda value: value)
575
+ code = (lambda value: f"`{_md_code(value)}`") if fmt == "markdown" else (lambda value: value)
576
+
577
+ def name(item: dict[str, Any]) -> str:
578
+ # Titles often repeat the ID ("D-003 — No paid acquisition").
579
+ title = re.sub(rf"^{re.escape(item['id'])}\s*[\u2014\u2013:-]?\s*", "", item.get("title") or "")
580
+ return f"**{esc(item['id'])}** {esc(title)}".rstrip() if fmt == "markdown" else f"{item['id']} {title}".rstrip()
581
+
582
+ decisions, evidence = report["decisions"], report["evidence"]
583
+ sections: list[tuple[str, list[str]]] = []
584
+ entries = []
585
+ for item in decisions["added"]:
586
+ extra = f" (status {esc(item['status'])})" if item["status"] else ""
587
+ chain = f"; chain {esc(_chain_text(item['chain']))}" if len(item["chain"]) > 1 else ""
588
+ entries.append(f"{name(item)}{extra}{chain}")
589
+ sections.append(("New decisions", entries))
590
+ entries = []
591
+ for key in ("superseded", "archived"):
592
+ for item in decisions[key]:
593
+ by = f" by {esc(', '.join(item['superseded_by']))}" if item["superseded_by"] else ""
594
+ chain = f"; chain {esc(_chain_text(item['chain']))}" if len(item["chain"]) > 1 else ""
595
+ entries.append(f"{name(item)}: {esc(item['from'] or 'none')} → {key}{by}{chain}")
596
+ sections.append(("Superseded or archived", entries))
597
+ entries = [f"{name(item)}: {esc(item['from'] or 'none')} → {esc(item['to'] or 'none')}" for item in decisions["status_changed"]]
598
+ sections.append(("Status changes", entries))
599
+ entries = [
600
+ f"{name(item)}: {esc(item['from'] or 'none')} → {esc(item['to'] or 'none')} ({item['direction']})"
601
+ for item in decisions["review_moved"]
602
+ ]
603
+ sections.append(("Review dates moved", entries))
604
+ entries = [f"{name(item)} (record deleted)" for item in decisions["removed"]]
605
+ entries += [f"{name(item)} moved {code(item['from'])} → {code(item['to'])}" for item in decisions["moved"]]
606
+ sections.append(("Removed or moved records", entries))
607
+
608
+ entries = [f"{name({'id': item['id'], 'title': item['source']})} added ({esc(item['type'])})" for item in evidence["added"]]
609
+ for item in evidence["retired"]:
610
+ replaced = f", replaced by {esc(item['replaced_by'])}" if item["replaced_by"] else ""
611
+ why = f": {esc(item['why'])}" if item["why"] else ""
612
+ entries.append(f"{name({'id': item['id'], 'title': item['source']})} retired{replaced}{why}")
613
+ for key, verb in (("resourced", "re-sourced"), ("updated", "updated")):
614
+ for item in evidence[key]:
615
+ fields = ", ".join(
616
+ f"{field_name} {esc(change['from'] or 'none')} → {esc(change['to'] or 'none')}"
617
+ for field_name, change in item["changes"].items()
618
+ )
619
+ entries.append(f"{name({'id': item['id'], 'title': ''})} {verb}: {fields}")
620
+ entries += [f"{name({'id': item['id'], 'title': item['source']})} removed from the register" for item in evidence["removed"]]
621
+ sections.append(("Evidence", entries))
622
+
623
+ entries = []
624
+ for item in report["affected_decisions"]:
625
+ cited = ", ".join(f"{esc(hit['id'])} {hit['change']}" for hit in item["evidence"])
626
+ status = f" ({esc(item['status'])})" if item["status"] else ""
627
+ entries.append(f"{name(item)}{status} cites {cited}")
628
+ sections.append(("Decisions whose evidence changed", entries))
629
+
630
+ def finding(item: dict[str, Any]) -> str:
631
+ where = f"{item['path']}:{item['line']}" if item["line"] else item["path"]
632
+ return f"{item['level']} {code(item['code'])} {code(where)} {esc(item['message'])}"
633
+
634
+ sections.append(("New lint findings", [finding(item) for item in report["lint"]["introduced"]]))
635
+ sections.append(("Fixed lint findings", [finding(item) for item in report["lint"]["fixed"]]))
636
+ return sections
637
+
638
+
639
+ def render_text(report: dict[str, Any]) -> str:
640
+ out = [f"whykit diff: {report['merge_base'][:12]}..{report['head_commit'][:12]} ({report['root']})"]
641
+ for title, entries in _lines(report, "text"):
642
+ if entries:
643
+ out.append(f"\n{title}")
644
+ out.extend(f" {one_line(entry)}" for entry in entries)
645
+ parts = _summary_parts(report)
646
+ lint = report["lint"]
647
+ out.append("")
648
+ out.append("; ".join(parts) if parts else "no decision, evidence or lint changes")
649
+ out.append(
650
+ f"lint: {lint['base']['errors']} → {lint['head']['errors']} error(s), "
651
+ f"{lint['base']['warnings']} → {lint['head']['warnings']} warning(s)"
652
+ )
653
+ return "\n".join(out)
654
+
655
+
656
+ def _md(value: object) -> str:
657
+ """Make vault text inert in a pull request comment.
658
+
659
+ Titles and messages come from the change under review. They must not open
660
+ HTML, break a list item, mention people or teams, or inject links.
661
+ """
662
+ text = " ".join(str(value).split())
663
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
664
+ text = re.sub(r"([\\`*_\[\]#|~!])", r"\\\1", text)
665
+ # GitHub turns bare URLs and `www.` hosts into links on its own; an
666
+ # entity in the scheme or after `www` keeps them as plain text.
667
+ text = re.sub(r"(?i)\b(https?|ftp|mailto|xmpp|file|javascript|data)(:)", r"\1&#58;", text)
668
+ text = re.sub(r"(?i)\bwww\.", lambda match: match.group(0)[:-1] + "&#46;", text)
669
+ return text.replace("@", "&#64;")
670
+
671
+
672
+ def _md_code(value: object) -> str:
673
+ """Text inside a code span: no backticks, no line breaks."""
674
+ return " ".join(str(value).split()).replace("`", "'")
675
+
676
+
677
+ def marker(report: dict[str, Any]) -> str:
678
+ """The hidden first line that identifies this vault's sticky comment."""
679
+ vault = report["prefix"].rstrip("/") or "."
680
+ # Any run of hyphens collapses to one: a single replace of `--` would
681
+ # turn `--->` into `-->` and close the comment early.
682
+ return f"{MARKER_PREFIX} root={re.sub('-{2,}', '-', _md_code(vault))} -->"
683
+
684
+
685
+ def render_markdown(report: dict[str, Any]) -> str:
686
+ vault = report["prefix"].rstrip("/") or "."
687
+ out = [
688
+ marker(report),
689
+ "### WhyKit: what changed in the decisions",
690
+ "",
691
+ f"Vault `{_md_code(vault)}`, comparing `{report['merge_base'][:12]}` (merge base) with `{report['head_commit'][:12]}`.",
692
+ "",
693
+ ]
694
+ parts = _summary_parts(report)
695
+ out.append(("**Summary:** " + "; ".join(parts) + ".") if parts else "No decision, evidence or lint changes.")
696
+ lint = report["lint"]
697
+ out.append("")
698
+ out.append(
699
+ f"Lint: {lint['base']['errors']} → {lint['head']['errors']} error(s), "
700
+ f"{lint['base']['warnings']} → {lint['head']['warnings']} warning(s) "
701
+ f"(as of {report['today']})."
702
+ )
703
+ for title, entries in _lines(report, "markdown"):
704
+ if not entries:
705
+ continue
706
+ out.append("")
707
+ out.append(f"#### {title}")
708
+ out.append("")
709
+ out.extend(f"- {entry}" for entry in entries[:SECTION_LIMIT])
710
+ if len(entries) > SECTION_LIMIT:
711
+ out.append(f"- … and {len(entries) - SECTION_LIMIT} more (run `whykit diff` locally for the full list)")
712
+ out.append("")
713
+ out.append("<sub>Generated by `whykit diff`; this comment is updated on every push.</sub>")
714
+ text = "\n".join(out) + "\n"
715
+ if len(text) > MARKDOWN_LIMIT:
716
+ cut = text.rfind("\n", 0, MARKDOWN_LIMIT - 200)
717
+ text = text[:cut] + "\n\n… truncated; run `whykit diff` locally for the full report.\n"
718
+ return text
719
+
720
+
721
+ def render_github(report: dict[str, Any]) -> str:
722
+ from .ci_formats import _source_path, rule_help_uri, workflow_command
723
+
724
+ prefix = report["prefix"]
725
+ out: list[str] = []
726
+ decisions = report["decisions"]
727
+ for item in decisions["added"]:
728
+ chain = f"; chain {_chain_text(item['chain'])}" if len(item["chain"]) > 1 else ""
729
+ out.append(workflow_command("notice", f"{item['id']} added: {item['title']}{chain}", file=_source_path(item["path"], prefix), title="WhyKit new decision"))
730
+ for key in ("superseded", "archived"):
731
+ for item in decisions[key]:
732
+ by = f" by {', '.join(item['superseded_by'])}" if item["superseded_by"] else ""
733
+ out.append(workflow_command("notice", f"{item['id']} {key}{by}: {item['title']}", file=_source_path(item["path"], prefix), title=f"WhyKit decision {key}"))
734
+ for item in decisions["review_moved"]:
735
+ out.append(workflow_command(
736
+ "notice", f"{item['id']} review_by {item['from'] or 'none'} -> {item['to'] or 'none'} ({item['direction']})",
737
+ file=_source_path(item["path"], prefix), title="WhyKit review date moved",
738
+ ))
739
+ for item in report["affected_decisions"]:
740
+ cited = ", ".join(f"{hit['id']} {hit['change']}" for hit in item["evidence"])
741
+ out.append(workflow_command(
742
+ "warning", f"{item['id']} cites evidence that changed: {cited}",
743
+ file=_source_path(item["path"], prefix), title="WhyKit evidence changed",
744
+ ))
745
+ for finding in report["lint"]["introduced"]:
746
+ out.append(workflow_command(
747
+ "error" if finding["level"] == "error" else "warning",
748
+ f"{finding['message']} ({rule_help_uri(finding['code'])})",
749
+ file=_source_path(finding["path"], prefix), line=finding["line"],
750
+ title=f"WhyKit {finding['code']} (new)",
751
+ ))
752
+ parts = _summary_parts(report)
753
+ out.append("whykit diff: " + ("; ".join(parts) if parts else "no decision, evidence or lint changes"))
754
+ return "\n".join(out)
755
+
756
+
757
+ # --------------------------------------------------------------------------
758
+ # Command line
759
+
760
+
761
+ def _resolve_root(explicit: str | None) -> Path:
762
+ if explicit:
763
+ return Path(explicit).expanduser().resolve()
764
+ return find_vault_root() or Path.cwd().resolve()
765
+
766
+
767
+ def main(argv: list[str] | None = None) -> int:
768
+ parser = argparse.ArgumentParser(
769
+ prog="whykit diff",
770
+ description="Show what changed in decisions, evidence and lint findings between two Git revisions.",
771
+ )
772
+ parser.add_argument("--base", required=True, help="base commit or ref, e.g. origin/main")
773
+ parser.add_argument("--head", default="HEAD", help="head commit or ref (default: HEAD)")
774
+ parser.add_argument("--root", help="vault root (may be a subdirectory of the Git work tree)")
775
+ parser.add_argument("--today", help="evaluate lint review dates as of this ISO date")
776
+ parser.add_argument("--format", choices=FORMATS, default=None)
777
+ parser.add_argument("--json", action="store_true")
778
+ args = parser.parse_args(argv)
779
+ if args.json and args.format not in (None, "json"):
780
+ return emit_error(
781
+ "usage",
782
+ f"--json conflicts with --format {args.format}\nhint: pass one of them to `whykit diff`",
783
+ json_mode=True,
784
+ )
785
+ fmt = "json" if args.json else (args.format or "text")
786
+ json_mode = fmt == "json"
787
+ today = None
788
+ if args.today:
789
+ today = _parse_date(args.today)
790
+ if today is None:
791
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=json_mode)
792
+ root = _resolve_root(args.root)
793
+ if not root.is_dir():
794
+ return emit_error(
795
+ "invalid_argument",
796
+ f"--root is not a directory: {args.root}\nhint: pass the vault directory as it exists in the working tree",
797
+ json_mode=json_mode,
798
+ )
799
+ try:
800
+ report = build_diff(root, args.base, args.head, today=today)
801
+ except subprocess.CalledProcessError as exc:
802
+ return emit_error("git_error", (exc.stderr or str(exc)).rstrip(), json_mode=json_mode)
803
+ except FileNotFoundError:
804
+ return emit_error(
805
+ "missing_dependency",
806
+ "git is not installed or not on PATH\nhint: `whykit diff` reads both revisions from Git",
807
+ json_mode=json_mode,
808
+ )
809
+ if not report["vault_at_base"] and not report["vault_at_head"]:
810
+ print(
811
+ f"warning: no WhyKit vault at {report['prefix'] or './'} in either revision; nothing was compared\n"
812
+ "hint: pass --root <vault> when the vault is a subdirectory of the repository",
813
+ file=sys.stderr,
814
+ )
815
+ if fmt == "json":
816
+ emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
817
+ elif fmt == "markdown":
818
+ emit_machine(render_markdown(report).rstrip("\n"))
819
+ elif fmt == "github":
820
+ emit_machine(render_github(report))
821
+ else:
822
+ print(render_text(report))
823
+ return 0