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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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:", text)
|
|
668
|
+
text = re.sub(r"(?i)\bwww\.", lambda match: match.group(0)[:-1] + ".", text)
|
|
669
|
+
return text.replace("@", "@")
|
|
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
|