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/immutability.py ADDED
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env python3
2
+ """Reject semantic rewrites of historical decision records on a pull request.
3
+
4
+ Decision *reasoning* becomes immutable once approved. A small lifecycle envelope
5
+ remains mutable so the system can be operated without falsifying history:
6
+
7
+ - an approved decision may move to ``superseded`` or ``archived`` while only
8
+ lifecycle metadata changes;
9
+ - an approved decision may move its next ``review_by`` date after a re-review;
10
+ - superseded/archived records are fully immutable.
11
+
12
+ Everything else must be represented by a new D-NNN record.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .tables import review_table_header, split_table_row
17
+
18
+ import argparse
19
+ import datetime as dt
20
+ import hashlib
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+
26
+ from .console import emit_machine, one_line
27
+
28
+ from .contract import emit_error
29
+
30
+ RECORD_RE = re.compile(r"(?:^|/)06-decisions/d-[0-9]{3,}-.+\.[mM][dD]$")
31
+ RECORD_SUFFIX = "06-decisions/"
32
+ REVIEW_LOG_SUFFIX = "00-context/review-log.md"
33
+ STATUS_VALUE_RE = re.compile(
34
+ r'(?mi)^status:\s*(?:"(approved|superseded|archived)"|\'(approved|superseded|archived)\'|(approved|superseded|archived))\s*(?:#.*)?$'
35
+ )
36
+ STATUS_KEY_RE = re.compile(r"(?m)^status\s*:")
37
+
38
+
39
+ def _git_arg(value: str) -> str | bytes:
40
+ # Under a C/ASCII locale the interpreter cannot encode a non-ASCII argument
41
+ # for exec. Git treats paths as bytes, so hand them over as UTF-8 (argv
42
+ # surrogates turn back into their original bytes). Windows needs str.
43
+ if os.name == "nt" or value.isascii():
44
+ return value
45
+ return value.encode("utf-8", "surrogateescape")
46
+
47
+
48
+ def git(*args: str, root: str | None = None) -> str:
49
+ prefix = ["-C", root] if root else []
50
+ # Decode as UTF-8 regardless of the locale: record names and contents are
51
+ # UTF-8, and a C/ASCII locale on a CI runner must not crash the gate.
52
+ # surrogateescape keeps invalid bytes distinct, so two different blobs
53
+ # never compare equal after decoding, and paths round-trip back to Git.
54
+ return subprocess.run(
55
+ [_git_arg(arg) for arg in ("git", *prefix, *args)], check=True, capture_output=True,
56
+ text=True, encoding="utf-8", errors="surrogateescape",
57
+ ).stdout
58
+
59
+
60
+ def _git_prefix(root: str | None) -> str:
61
+ """Return the path from the Git work tree to *root*, with a trailing slash.
62
+
63
+ When the vault lives in a subdirectory (``examples/northline``), ``git diff
64
+ --relative`` speaks in vault-relative paths but ``git show REV:path`` needs
65
+ work-tree-relative ones. History checks must convert between the two or
66
+ they silently skip every decision record. Without *root* the vault is the
67
+ working directory, which may itself be a subdirectory.
68
+ """
69
+ try:
70
+ prefix = git("rev-parse", "--show-prefix", root=root).strip().replace("\\", "/")
71
+ except subprocess.CalledProcessError:
72
+ return ""
73
+ return prefix if not prefix or prefix.endswith("/") else f"{prefix}/"
74
+
75
+
76
+ def _git_object_path(vault_rel: str, prefix: str) -> str:
77
+ return f"{prefix}{vault_rel}" if prefix else vault_rel
78
+
79
+
80
+ def _front_matter_lines(text: str) -> tuple[list[str], int] | None:
81
+ """Split *text* into lines and return them with the closing-fence index.
82
+
83
+ Mirrors the linter: a UTF-8 byte-order mark is not content, and the front
84
+ matter is the block between a leading ``---`` line and the next one.
85
+ """
86
+ lines = text.removeprefix("\ufeff").splitlines(keepends=True)
87
+ if not lines or lines[0].strip() != "---":
88
+ return None
89
+ end = next((i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"), None)
90
+ if end is None:
91
+ return None
92
+ return lines, end
93
+
94
+
95
+ def _status(text: str) -> str | None:
96
+ """Return the lifecycle status declared in the front matter, if unambiguous.
97
+
98
+ Only the front matter counts. A ``status:`` line in the body (a YAML
99
+ example in a code fence, say) must neither hide nor fake a status.
100
+ """
101
+ parsed = _front_matter_lines(text)
102
+ if parsed is None:
103
+ return None
104
+ lines, end = parsed
105
+ front = "".join(lines[1:end])
106
+ matches = list(STATUS_VALUE_RE.finditer(front))
107
+ if len(matches) != 1 or len(STATUS_KEY_RE.findall(front)) != 1:
108
+ return None
109
+ return next(group for group in matches[0].groups() if group).lower()
110
+
111
+
112
+ def _without_frontmatter_keys(text: str, keys: set[str]) -> str | None:
113
+ """Remove selected *top-level* front-matter lines for semantic comparison."""
114
+ parsed = _front_matter_lines(text)
115
+ if parsed is None:
116
+ return None
117
+ lines, end = parsed
118
+ key_re = re.compile(r"^(" + "|".join(re.escape(key) for key in sorted(keys)) + r")\s*:")
119
+ kept = [lines[0]]
120
+ for line in lines[1:end]:
121
+ if key_re.match(line):
122
+ continue
123
+ kept.append(line)
124
+ kept.extend(lines[end:])
125
+ return "".join(kept)
126
+
127
+
128
+ def allowed_lifecycle_change(base_text: str, head_text: str) -> bool:
129
+ """Return True only for explicitly allowed metadata-only lifecycle changes."""
130
+ base_status = _status(base_text)
131
+ head_status = _status(head_text)
132
+ if base_status != "approved" or head_status is None:
133
+ return False
134
+
135
+ if head_status == "approved":
136
+ # Re-reviewing an unchanged decision may move only the operational review
137
+ # date (and last_updated). Its rationale/evidence/ownership remain frozen.
138
+ allowed = {"review_by", "last_updated"}
139
+ else:
140
+ # superseded/archived (the only other values _status returns).
141
+ # Lifecycle transition only. `superseded_by` is an optional convenience;
142
+ # the authoritative reverse edge remains the newer record's `supersedes`.
143
+ allowed = {"status", "last_updated", "superseded_by"}
144
+
145
+ base_normalized = _without_frontmatter_keys(base_text, allowed)
146
+ head_normalized = _without_frontmatter_keys(head_text, allowed)
147
+ return base_normalized is not None and base_normalized == head_normalized
148
+
149
+
150
+ def _review_log_parts(text: str) -> tuple[list[str], list[list[str]], list[str]] | None:
151
+ normalized = _without_frontmatter_keys(text, {"last_updated"})
152
+ if normalized is None:
153
+ return None
154
+ lines = normalized.splitlines(keepends=True)
155
+ header_idx = review_table_header(lines)
156
+ if header_idx is None or header_idx + 1 >= len(lines):
157
+ return None
158
+ row_start = header_idx + 2
159
+ row_end = row_start
160
+ while row_end < len(lines) and lines[row_end].lstrip().startswith("|"):
161
+ row_end += 1
162
+ rows = [split_table_row(line) for line in lines[row_start:row_end]]
163
+ if any(len(row) != 7 for row in rows):
164
+ return None
165
+ return lines[:header_idx], rows, lines[row_end:]
166
+
167
+
168
+ def allowed_review_log_append(base_text: str, head_text: str) -> bool:
169
+ """Allow only new table rows plus a last_updated refresh."""
170
+ base = _review_log_parts(base_text)
171
+ head = _review_log_parts(head_text)
172
+ if base is None or head is None:
173
+ return False
174
+ base_before, base_rows, base_after = base
175
+ head_before, head_rows, head_after = head
176
+ return (
177
+ base_before == head_before
178
+ and base_after == head_after
179
+ and len(head_rows) >= len(base_rows)
180
+ and head_rows[: len(base_rows)] == base_rows
181
+ )
182
+
183
+
184
+ def _front_scalar(text: str, key: str) -> str | None:
185
+ parsed = _front_matter_lines(text)
186
+ if parsed is None:
187
+ return None
188
+ lines, end = parsed
189
+ values = [line.split(":", 1)[1].strip() for line in lines[1:end] if line.startswith(f"{key}:")]
190
+ if len(values) != 1:
191
+ return None
192
+ return values[0].split(" #", 1)[0].strip().strip("\"'")
193
+
194
+
195
+ def approval_record_hash(text: str) -> str:
196
+ """Bind reasoning and provenance while permitting the lifecycle envelope."""
197
+ normalized = _without_frontmatter_keys(
198
+ text.replace("\r\n", "\n").replace("\r", "\n"),
199
+ {"status", "review_by", "last_updated", "superseded_by"},
200
+ )
201
+ if normalized is None:
202
+ raise ValueError("approval requires readable front matter")
203
+ return hashlib.sha256(normalized.encode("utf-8", "surrogateescape")).hexdigest()
204
+
205
+
206
+ def _approval_event_matches(text: str, path: str, base_log: str | None, head_log: str) -> bool:
207
+ head = _review_log_parts(head_log)
208
+ base = _review_log_parts(base_log) if base_log is not None else None
209
+ if head is None or (base_log is not None and (base is None or not allowed_review_log_append(base_log, head_log))):
210
+ return False
211
+ old_rows = base[1] if base else []
212
+ try:
213
+ digest = approval_record_hash(text)
214
+ created = dt.date.fromisoformat(_front_scalar(text, "created") or "")
215
+ updated = dt.date.fromisoformat(_front_scalar(text, "last_updated") or "")
216
+ review_by = dt.date.fromisoformat(_front_scalar(text, "review_by") or "")
217
+ except ValueError:
218
+ return False
219
+ target = re.sub(r"(?i)\.md$", "", path)
220
+ new_rows = head[1][len(old_rows):]
221
+
222
+ def matches(row: list[str]) -> bool:
223
+ link = re.fullmatch(r"\[\[([^\]|#]+)(?:\|[^\]]*)?\]\]", row[1])
224
+ return link is not None and re.sub(r"(?i)\.md$", "", link.group(1).strip()) == target
225
+
226
+ if (any(row[3] == "approved" and matches(row) for row in old_rows)
227
+ or sum(row[3] == "approved" and matches(row) for row in new_rows) != 1):
228
+ return False
229
+ for at, row in enumerate(new_rows):
230
+ if row[3] != "approved" or not matches(row):
231
+ continue
232
+ try:
233
+ date = dt.date.fromisoformat(row[0])
234
+ next_review = dt.date.fromisoformat(row[5])
235
+ except ValueError:
236
+ continue
237
+ receipt = re.fullmatch(r"record-sha256:([0-9a-f]{64}); snapshot-sha256:([0-9a-f]{64})", row[6])
238
+ if (receipt and receipt.group(1) == digest and row[2].strip()
239
+ and row[2].strip().casefold() != "todo"
240
+ and created <= date <= updated and date < next_review):
241
+ deadline = row[5]
242
+ latest = date
243
+ for later in new_rows[at + 1:]:
244
+ if not matches(later):
245
+ continue
246
+ if later[3] == "approved":
247
+ return False
248
+ if later[3] != "confirmed":
249
+ continue
250
+ try:
251
+ event_date = dt.date.fromisoformat(later[0])
252
+ next_date = dt.date.fromisoformat(later[5])
253
+ except ValueError:
254
+ return False
255
+ if not (latest <= event_date <= updated and event_date < next_date
256
+ and later[4] == deadline and later[2].strip()
257
+ and later[2].strip().casefold() != "todo"):
258
+ return False
259
+ latest, deadline = event_date, later[5]
260
+ return deadline == review_by.isoformat()
261
+ return False
262
+
263
+
264
+ def _review_change_confirmed(base_text: str, head_text: str, path: str, base_log: str | None, head_log: str) -> bool:
265
+ """Require new confirmed events to explain the complete review-date change."""
266
+ before = _front_scalar(base_text, "review_by") or "—"
267
+ after = _front_scalar(head_text, "review_by")
268
+ if before == after:
269
+ return True
270
+ head = _review_log_parts(head_log)
271
+ base = _review_log_parts(base_log) if base_log is not None else None
272
+ if head is None or (base_log is not None and (base is None or not allowed_review_log_append(base_log, head_log))):
273
+ return False
274
+ old_rows = base[1] if base else []
275
+ target = re.sub(r"(?i)\.md$", "", path)
276
+
277
+ def matches(row: list[str]) -> bool:
278
+ link = re.fullmatch(r"\[\[([^\]|#]+)(?:\|[^\]]*)?\]\]", row[1])
279
+ return link is not None and re.sub(r"(?i)\.md$", "", link.group(1).strip()) == target
280
+
281
+ try:
282
+ latest = max(
283
+ [dt.date.fromisoformat(_front_scalar(base_text, "created") or "0001-01-01")]
284
+ + [dt.date.fromisoformat(row[0]) for row in old_rows if matches(row)]
285
+ )
286
+ updated = dt.date.fromisoformat(_front_scalar(head_text, "last_updated") or "0001-01-01")
287
+ found = False
288
+ for row in head[1][len(old_rows):]:
289
+ if not matches(row):
290
+ continue
291
+ date = dt.date.fromisoformat(row[0])
292
+ if date < latest or date > updated or not row[2].strip() or row[2].strip() == "TODO":
293
+ return False
294
+ latest = date
295
+ if row[3] != "confirmed":
296
+ continue
297
+ if row[4] != before or dt.date.fromisoformat(row[5]) <= date:
298
+ return False
299
+ before = row[5]
300
+ found = True
301
+ return found and before == after
302
+ except ValueError:
303
+ return False
304
+
305
+
306
+ def immutable_at(base: str, path: str, root: str | None = None, *, prefix: str | None = None) -> bool:
307
+ git_prefix = _git_prefix(root) if prefix is None else prefix
308
+ try:
309
+ content = git("show", f"{base}:{_git_object_path(path, git_prefix)}", root=root)
310
+ except subprocess.CalledProcessError:
311
+ return False
312
+ return _status(content) in {"approved", "superseded", "archived"}
313
+
314
+
315
+ def _require_revisions(root: str | None, *refs: str) -> None:
316
+ """Fail with an actionable message before ``git diff`` dumps its own usage.
317
+
318
+ The error is a ``CalledProcessError`` so callers that already handle Git
319
+ failures (``whykit check`` included) report it without new plumbing.
320
+ """
321
+ where = root or os.getcwd()
322
+ try:
323
+ git("rev-parse", "--is-inside-work-tree", root=root)
324
+ except subprocess.CalledProcessError as exc:
325
+ raise subprocess.CalledProcessError(
326
+ exc.returncode, exc.cmd, output="",
327
+ stderr=(
328
+ f"not inside a Git work tree: {where}\n"
329
+ "hint: history checks compare commits; run from the vault's repository or pass --root"
330
+ ),
331
+ ) from None
332
+ for ref in refs:
333
+ try:
334
+ if ref.startswith("-"):
335
+ # Never let a revision be read as a Git option.
336
+ raise subprocess.CalledProcessError(128, ["git", "rev-parse", ref])
337
+ git("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", root=root)
338
+ except subprocess.CalledProcessError as exc:
339
+ raise subprocess.CalledProcessError(
340
+ exc.returncode, exc.cmd, output="",
341
+ stderr=(
342
+ f"unknown Git revision: {ref}\n"
343
+ "hint: pass a commit, branch or tag that exists locally; "
344
+ "in a shallow CI checkout, fetch it first (e.g. `git fetch origin main`)"
345
+ ),
346
+ ) from None
347
+
348
+
349
+ def _diff_entries(out: str) -> list[tuple[str, str, str]]:
350
+ """Parse ``git diff --name-status -z`` output into ``(status, old, new)``.
351
+
352
+ NUL-separated output is the only form in which Git never C-quotes a path:
353
+ without ``-z`` a name like ``d-001-café.md`` arrives as
354
+ ``"06-decisions/d-001-caf\\303\\251.md"`` and no longer looks like a record.
355
+ """
356
+ tokens = out.split("\0")
357
+ if tokens and tokens[-1] == "":
358
+ tokens.pop()
359
+ entries: list[tuple[str, str, str]] = []
360
+ index = 0
361
+ while index < len(tokens):
362
+ status = tokens[index]
363
+ width = 2 if status[:1] in {"R", "C"} else 1
364
+ paths = tokens[index + 1 : index + 1 + width]
365
+ if not status or len(paths) != width:
366
+ # Refuse to guess: a half-parsed diff must not pass the gate.
367
+ raise subprocess.CalledProcessError(
368
+ 128, ["git", "diff", "--name-status", "-z"], output=out,
369
+ stderr="could not parse `git diff --name-status -z` output",
370
+ )
371
+ entries.append((status, paths[0], paths[-1]))
372
+ index += 1 + width
373
+ return entries
374
+
375
+
376
+ def _diff_failure(exc: subprocess.CalledProcessError, base: str, head: str, root: str | None) -> subprocess.CalledProcessError:
377
+ """Add the actionable hint a shallow CI checkout needs to Git's own error."""
378
+ detail = (exc.stderr or "").rstrip()
379
+ try:
380
+ shallow = git("rev-parse", "--is-shallow-repository", root=root).strip() == "true"
381
+ except subprocess.CalledProcessError:
382
+ shallow = False
383
+ if shallow or "no merge base" in detail:
384
+ detail += (
385
+ f"\nhint: cannot find the merge base of {base} and {head}"
386
+ + (" in this shallow clone" if shallow else "")
387
+ + "; fetch full history (actions/checkout `fetch-depth: 0`, or `git fetch --unshallow`)"
388
+ )
389
+ return subprocess.CalledProcessError(exc.returncode, exc.cmd, output="", stderr=detail.lstrip("\n"))
390
+
391
+
392
+ # The pseudo-revision a staged check compares against ``base``: Git's index,
393
+ # addressed as ``:path`` by ``git show``.
394
+ INDEX = ""
395
+
396
+
397
+ def has_commits(root: str | None = None) -> bool:
398
+ """False in a repository whose HEAD is unborn (before the first commit)."""
399
+ try:
400
+ git("rev-parse", "--verify", "--quiet", "HEAD^{commit}", root=root)
401
+ except subprocess.CalledProcessError:
402
+ return False
403
+ return True
404
+
405
+
406
+ def _initial_approvals(root: str | None, *, reasons: dict[str, str] | None = None) -> list[tuple[str, str]]:
407
+ """The first commit has no old reasoning, but acceptance still needs review."""
408
+ prefix = _git_prefix(root)
409
+ paths = git("ls-files", "-z", "--cached", "--", RECORD_SUFFIX.rstrip("/"), root=root).split("\0")
410
+ try:
411
+ log = git("show", f":{_git_object_path(REVIEW_LOG_SUFFIX, prefix)}", root=root)
412
+ except subprocess.CalledProcessError:
413
+ log = ""
414
+ blocked = []
415
+ for path in paths:
416
+ if not RECORD_RE.search(path):
417
+ continue
418
+ try:
419
+ text = git("show", f":{_git_object_path(path, prefix)}", root=root)
420
+ except subprocess.CalledProcessError:
421
+ blocked.append(("A", path))
422
+ if reasons is not None:
423
+ reasons[path] = "approval_without_event"
424
+ continue
425
+ if _status(text) is not None and not _approval_event_matches(text, path, None, log):
426
+ blocked.append(("A", path))
427
+ if reasons is not None:
428
+ reasons[path] = "approval_without_event"
429
+ return blocked
430
+
431
+
432
+ def changed_records(base: str, head: str, root: str | None = None, *, staged: bool = False, reasons: dict[str, str] | None = None) -> list[tuple[str, str]]:
433
+ """Immutable records and review-log rows rewritten between two states.
434
+
435
+ With ``staged`` the second state is the index (what the next commit will
436
+ contain), not *head*: that is the pre-commit form of the check.
437
+ """
438
+ if staged:
439
+ _require_revisions(root, base)
440
+ head = INDEX
441
+ else:
442
+ _require_revisions(root, base, head)
443
+ prefix = _git_prefix(root)
444
+ # --relative makes paths (and pathspecs) vault-rooted when the vault is a
445
+ # subdirectory of the work tree; -z keeps every path byte-exact.
446
+ revisions = ["--cached", base] if staged else [f"{base}...{head}"]
447
+ try:
448
+ out = git(
449
+ "diff",
450
+ "--relative",
451
+ "--name-status",
452
+ "-z",
453
+ "--no-ext-diff",
454
+ *revisions,
455
+ "--",
456
+ RECORD_SUFFIX.rstrip("/"),
457
+ REVIEW_LOG_SUFFIX,
458
+ root=root,
459
+ )
460
+ except subprocess.CalledProcessError as exc:
461
+ raise _diff_failure(exc, base, head or "the index", root) from None
462
+
463
+ def show(rev: str, path: str) -> str:
464
+ return git("show", f"{rev}:{_git_object_path(path, prefix)}", root=root)
465
+
466
+ blocked: list[tuple[str, str]] = []
467
+ entries = _diff_entries(out)
468
+ log_added = any(status == "A" and path == REVIEW_LOG_SUFFIX for status, path, _ in entries)
469
+ for status, old_path, new_path in entries:
470
+ kind = status[0]
471
+ display_path = f"{old_path} -> {new_path}" if kind in {"R", "C"} else old_path
472
+ if RECORD_RE.search(new_path) and kind in {"A", "C", "M", "R", "T"}:
473
+ try:
474
+ head_text = show(head, new_path)
475
+ new_acceptance = _status(head_text) is not None and (
476
+ kind in {"A", "C"} or not immutable_at(base, old_path, root, prefix=prefix)
477
+ )
478
+ if new_acceptance:
479
+ try:
480
+ base_log = show(base, REVIEW_LOG_SUFFIX)
481
+ except subprocess.CalledProcessError:
482
+ if not log_added:
483
+ raise
484
+ base_log = None
485
+ if not _approval_event_matches(head_text, new_path, base_log, show(head, REVIEW_LOG_SUFFIX)):
486
+ blocked.append((status, display_path))
487
+ if reasons is not None:
488
+ reasons[display_path] = "approval_without_event"
489
+ continue
490
+ except subprocess.CalledProcessError:
491
+ blocked.append((status, display_path))
492
+ continue
493
+ if kind == "A":
494
+ continue
495
+ if old_path == REVIEW_LOG_SUFFIX:
496
+ if kind == "M":
497
+ try:
498
+ if allowed_review_log_append(show(base, old_path), show(head, new_path)):
499
+ continue
500
+ except subprocess.CalledProcessError:
501
+ pass
502
+ blocked.append((status, display_path))
503
+ continue
504
+ if not RECORD_RE.search(old_path):
505
+ continue
506
+ # A copy does not rewrite the old record. The regular linter is
507
+ # responsible for rejecting the duplicated decision_id on the new file.
508
+ if kind == "C":
509
+ continue
510
+ if not immutable_at(base, old_path, root, prefix=prefix):
511
+ continue
512
+
513
+ if kind == "M":
514
+ try:
515
+ base_text, head_text = show(base, old_path), show(head, new_path)
516
+ if allowed_lifecycle_change(base_text, head_text):
517
+ if _status(head_text) == "approved" and _front_scalar(base_text, "review_by") != _front_scalar(head_text, "review_by"):
518
+ try:
519
+ base_log = show(base, REVIEW_LOG_SUFFIX)
520
+ except subprocess.CalledProcessError:
521
+ if not log_added:
522
+ raise
523
+ base_log = None
524
+ if not _review_change_confirmed(base_text, head_text, new_path, base_log, show(head, REVIEW_LOG_SUFFIX)):
525
+ blocked.append((status, display_path))
526
+ if reasons is not None:
527
+ reasons[display_path] = "review_without_event"
528
+ continue
529
+ continue
530
+ except subprocess.CalledProcessError:
531
+ pass
532
+
533
+ # Rename/delete/type-change or any semantic edit of an immutable record.
534
+ blocked.append((status, display_path))
535
+ return blocked
536
+
537
+
538
+ def _tracks_vault_paths(base: str, head: str, root: str | None, *, staged: bool = False) -> bool:
539
+ """True when either state has decision records or a review log under *root*."""
540
+ pathspec = ["--", RECORD_SUFFIX.rstrip("/"), REVIEW_LOG_SUFFIX]
541
+ listings: list[list[str]] = [["ls-tree", "--name-only", base, *pathspec]]
542
+ listings.append(["ls-files", "--cached", *pathspec] if staged else ["ls-tree", "--name-only", head, *pathspec])
543
+ for listing in listings:
544
+ try:
545
+ if git(*listing, root=root).strip():
546
+ return True
547
+ except subprocess.CalledProcessError:
548
+ return True # Unknown: do not add a misleading warning.
549
+ return False
550
+
551
+
552
+ def main(argv: list[str] | None = None) -> int:
553
+ parser = argparse.ArgumentParser(
554
+ prog="whykit history",
555
+ description="Reject semantic rewrites of historical decisions and review history.",
556
+ )
557
+ parser.add_argument("--base", help="base commit/ref (default with --staged: HEAD)")
558
+ parser.add_argument("--head", default=None, help="head commit/ref (default: HEAD)")
559
+ parser.add_argument("--staged", action="store_true", help="compare the staged index with --base (pre-commit use)")
560
+ parser.add_argument("--root", help="vault root (may be a subdirectory of the Git work tree)")
561
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
562
+ args = parser.parse_args(argv)
563
+ if args.staged and args.head is not None:
564
+ return emit_error(
565
+ "usage",
566
+ "--staged compares the index with --base and does not take --head\nhint: drop --head, or drop --staged to compare two commits",
567
+ json_mode=args.json,
568
+ )
569
+ if not args.staged and args.base is None:
570
+ return emit_error(
571
+ "usage",
572
+ "whykit history needs --base <ref>, or --staged to check what is about to be committed\nhint: in CI pass the pull request base, e.g. --base origin/main",
573
+ json_mode=args.json,
574
+ )
575
+ base = args.base or "HEAD"
576
+ head = "INDEX" if args.staged else (args.head or "HEAD")
577
+ reasons: dict[str, str] = {}
578
+ try:
579
+ if args.staged and args.base is None:
580
+ _require_revisions(args.root) # a work tree, but no ref yet
581
+ if not has_commits(args.root):
582
+ blocked = _initial_approvals(args.root, reasons=reasons)
583
+ return _report(args.json, base, head, blocked, staged=True, reasons=reasons)
584
+ blocked = changed_records(base, head, args.root, staged=args.staged, reasons=reasons)
585
+ if not blocked and not _tracks_vault_paths(base, head, args.root, staged=args.staged):
586
+ print(
587
+ f"warning: no decision records or review log under {args.root or os.getcwd()} "
588
+ "in either revision; nothing was checked\n"
589
+ "hint: pass --root <vault> when the vault is a subdirectory of the repository",
590
+ file=sys.stderr,
591
+ )
592
+ except subprocess.CalledProcessError as exc:
593
+ return emit_error("git_error", (exc.stderr or str(exc)).rstrip(), json_mode=args.json)
594
+ except FileNotFoundError:
595
+ return emit_error(
596
+ "missing_dependency",
597
+ "git is not installed or not on PATH\nhint: `whykit history` needs Git to compare revisions",
598
+ json_mode=args.json,
599
+ )
600
+ return _report(args.json, base, head, blocked, staged=args.staged, reasons=reasons)
601
+
602
+
603
+ def _report(json_mode: bool, base: str, head: str, blocked: list[tuple[str, str]], *, staged: bool, reasons: dict[str, str] | None = None) -> int:
604
+ if json_mode:
605
+ import json
606
+ emit_machine(json.dumps({
607
+ "contract_version": 1,
608
+ "base": base,
609
+ "head": head,
610
+ "staged": staged,
611
+ "passed": not blocked,
612
+ "blocked": [{"status": status, "path": path, **({"reason": reasons[path]} if reasons and path in reasons else {})} for status, path in blocked],
613
+ }, ensure_ascii=False, indent=2))
614
+ return 1 if blocked else 0
615
+ if not blocked:
616
+ scope = "staged changes: " if staged else ""
617
+ print(f"history: {scope}immutable reasoning unchanged; review log append-only")
618
+ return 0
619
+ print("Historical decision reasoning is append-only. Supersede; do not rewrite:", file=sys.stderr)
620
+ for status, path in blocked:
621
+ print(f" {status}\t{one_line(path)}", file=sys.stderr)
622
+ if reasons and path in reasons:
623
+ print(f" {reasons[path]}: include a new matching review event with the record", file=sys.stderr)
624
+ return 1