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/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
|