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/io.py ADDED
@@ -0,0 +1,563 @@
1
+ """Small durability helpers for governed WhyKit writes."""
2
+ from __future__ import annotations
3
+
4
+ import errno
5
+ import contextvars
6
+ import asyncio
7
+ import threading
8
+ from dataclasses import dataclass
9
+ import functools
10
+ import shutil
11
+ import hashlib
12
+ import codecs
13
+ import json
14
+ import os
15
+ import re
16
+ import secrets
17
+ import stat
18
+ import time
19
+ from contextlib import contextmanager
20
+ from collections.abc import Iterator, Mapping
21
+ from pathlib import Path
22
+ from typing import TextIO
23
+
24
+
25
+ def _sync_directory(path: Path) -> None:
26
+ """Persist a rename on systems that support syncing directories."""
27
+ if os.name == "nt":
28
+ return # Windows does not expose directory fsync through os.open.
29
+ fd = os.open(path, os.O_RDONLY)
30
+ try:
31
+ try:
32
+ os.fsync(fd)
33
+ except OSError as exc:
34
+ if exc.errno not in {errno.EINVAL, errno.ENOTSUP}:
35
+ raise
36
+ finally:
37
+ os.close(fd)
38
+
39
+
40
+ def ensure_writable(path: Path) -> None:
41
+ """Refuse to replace a file someone marked read-only.
42
+
43
+ ``os.replace`` would silently override the mark on POSIX, while Windows
44
+ refuses the rename and then cannot delete the read-only temporary file
45
+ either. Checking first gives one answer on every platform, before any
46
+ byte of a multi-file change is written.
47
+ """
48
+ try:
49
+ mode = os.stat(path).st_mode
50
+ except FileNotFoundError:
51
+ return
52
+ if stat.S_ISREG(mode) and (not mode & stat.S_IWUSR or not os.access(path, os.W_OK)):
53
+ raise PermissionError(errno.EACCES, "file is read-only; make it writable before WhyKit updates it", str(path))
54
+
55
+
56
+ def match_line_endings(path: Path, text: str) -> str:
57
+ """Keep an existing CRLF file CRLF when WhyKit rewrites it.
58
+
59
+ Edits read text with universal newlines, so writing it back as-is would
60
+ turn every line of a Windows-authored file into a change. A new file, or
61
+ one whose first line ends in LF, is written with LF.
62
+ """
63
+ try:
64
+ with open(path, "rb") as handle:
65
+ head = handle.read(65536)
66
+ except OSError:
67
+ return text
68
+ first = head.find(b"\n")
69
+ if first <= 0 or head[first - 1:first] != b"\r":
70
+ return text
71
+ return text.replace("\r\n", "\n").replace("\n", "\r\n")
72
+
73
+
74
+ @contextmanager
75
+ def _atomic_text_writer(path: Path, *, encoding: str = "utf-8", newline: str = "") -> Iterator[TextIO]:
76
+ """Flush a complete replacement before the atomic rename, including on cancellation."""
77
+ path = Path(path)
78
+ ensure_writable(path)
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ tmp = path.with_name(f".{path.name}.whykit-tmp-{secrets.token_hex(6)}")
81
+ previous_mode = None
82
+ try:
83
+ try:
84
+ previous_mode = path.stat().st_mode & 0o7777
85
+ except FileNotFoundError:
86
+ pass
87
+ # surrogateescape: under a C/ASCII locale, command-line text (a title,
88
+ # an owner) arrives with surrogates for its UTF-8 bytes; write those
89
+ # bytes back instead of refusing the record.
90
+ errors = "surrogateescape" if codecs.lookup(encoding).name == "utf-8" else "strict"
91
+ with tmp.open("x", encoding=encoding, errors=errors, newline=newline) as handle:
92
+ yield handle
93
+ handle.flush()
94
+ os.fsync(handle.fileno())
95
+ except BaseException:
96
+ try:
97
+ tmp.unlink()
98
+ except FileNotFoundError:
99
+ pass
100
+ raise
101
+ try:
102
+ if previous_mode is not None:
103
+ os.chmod(tmp, previous_mode)
104
+ os.replace(tmp, path)
105
+ _sync_directory(path.parent)
106
+ finally:
107
+ try:
108
+ tmp.unlink()
109
+ except FileNotFoundError:
110
+ pass
111
+
112
+
113
+ def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
114
+ """Atomically replace text, preserving permissions and existing line endings."""
115
+ with _atomic_text_writer(path, encoding=encoding) as handle:
116
+ handle.write(match_line_endings(path, text))
117
+
118
+
119
+ def atomic_write_json(path: Path, value: object) -> None:
120
+ """Stream pretty JSON through the same durable replacement as text writes."""
121
+ newline = match_line_endings(path, "\n")
122
+ with _atomic_text_writer(path, newline=newline) as handle:
123
+ json.dump(value, handle, ensure_ascii=False, indent=2)
124
+ handle.write("\n")
125
+
126
+
127
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
128
+ """Binary counterpart of :func:`atomic_write_text`."""
129
+ path = Path(path)
130
+ ensure_writable(path)
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ tmp = path.with_name(f".{path.name}.whykit-tmp-{secrets.token_hex(6)}")
133
+ previous_mode = None
134
+ try:
135
+ try:
136
+ previous_mode = path.stat().st_mode & 0o7777
137
+ except FileNotFoundError:
138
+ pass
139
+ with tmp.open("xb") as handle:
140
+ handle.write(data)
141
+ handle.flush()
142
+ os.fsync(handle.fileno())
143
+ except BaseException:
144
+ try:
145
+ tmp.unlink()
146
+ except FileNotFoundError:
147
+ pass
148
+ raise
149
+ try:
150
+ if previous_mode is not None:
151
+ os.chmod(tmp, previous_mode)
152
+ os.replace(tmp, path)
153
+ _sync_directory(path.parent)
154
+ finally:
155
+ try:
156
+ tmp.unlink()
157
+ except FileNotFoundError:
158
+ pass
159
+
160
+
161
+ def safe_vault_dir(root: Path, relative: str | Path) -> Path:
162
+ """Return a vault-relative directory, creating missing parents without following symlinks."""
163
+ root = Path(root).resolve(strict=True)
164
+ relative = Path(relative)
165
+ if relative.is_absolute() or ".." in relative.parts:
166
+ raise ValueError(f"unsafe vault path: {relative}")
167
+ current = root
168
+ for part in relative.parts:
169
+ current = current / part
170
+ if current.is_symlink():
171
+ raise RuntimeError(f"symlinked vault directory refused: {current}")
172
+ if not current.exists():
173
+ try:
174
+ current.mkdir(mode=0o755)
175
+ _sync_directory(current.parent)
176
+ except FileExistsError:
177
+ # Another writer may have created this directory after our
178
+ # existence check. Revalidate below; in particular, do not
179
+ # accept a symlink created in the same race.
180
+ pass
181
+ if current.is_symlink():
182
+ raise RuntimeError(f"symlinked vault directory refused: {current}")
183
+ if not current.is_dir():
184
+ raise RuntimeError(f"expected directory: {current}")
185
+ resolved = current.resolve(strict=True)
186
+ try:
187
+ resolved.relative_to(root)
188
+ except ValueError as exc:
189
+ raise RuntimeError(f"path escapes vault: {relative}") from exc
190
+ return current
191
+
192
+
193
+ def safe_vault_target(root: Path, relative: str | Path, *, create_parents: bool = True) -> Path:
194
+ """Resolve a vault-relative write path without following parent symlinks."""
195
+ root = Path(root).resolve(strict=True)
196
+ relative = Path(relative)
197
+ if relative.is_absolute() or ".." in relative.parts:
198
+ raise ValueError(f"unsafe vault path: {relative}")
199
+
200
+ parts = relative.parts
201
+ if not parts:
202
+ raise ValueError("unsafe vault path: empty")
203
+
204
+ if len(parts) > 1:
205
+ if create_parents:
206
+ safe_vault_dir(root, Path(*parts[:-1]))
207
+ else:
208
+ parent = root.joinpath(*parts[:-1])
209
+ if parent.is_symlink():
210
+ raise RuntimeError(f"symlinked vault directory refused: {parent}")
211
+ parent.resolve(strict=True).relative_to(root)
212
+
213
+ target = root / relative
214
+ if target.is_symlink():
215
+ raise RuntimeError(f"refusing to mutate symlink: {target}")
216
+ parent = target.parent.resolve(strict=True)
217
+ try:
218
+ parent.relative_to(root)
219
+ except ValueError as exc:
220
+ raise RuntimeError(f"path escapes vault: {relative}") from exc
221
+ return target
222
+
223
+
224
+ def safe_export_target(root: Path, relative: str | Path) -> Path:
225
+ """Exports must not replace Git state or the inode that coordinates readers."""
226
+ parts = tuple(part.split(":", 1)[0].rstrip(" .").casefold() for part in Path(relative).parts)
227
+ if parts and (parts[0] == ".git" or parts[:2] in {
228
+ (".whykit", "mutation.lock"), (".whykit", "transactions"), (".whykit", "cache"),
229
+ }):
230
+ raise ValueError("export cannot replace Git or WhyKit coordination state")
231
+ return safe_vault_target(root, relative)
232
+
233
+
234
+ def _acquire_exclusive(handle, *, non_blocking: bool) -> None:
235
+ if os.name == "nt": # pragma: no cover - exercised on Windows runners
236
+ import msvcrt
237
+
238
+ handle.seek(0)
239
+ mode = msvcrt.LK_NBLCK if non_blocking else msvcrt.LK_LOCK
240
+ msvcrt.locking(handle.fileno(), mode, 1)
241
+ return
242
+ import fcntl
243
+
244
+ flags = fcntl.LOCK_EX
245
+ if non_blocking:
246
+ flags |= fcntl.LOCK_NB
247
+ fcntl.flock(handle.fileno(), flags)
248
+
249
+
250
+ def _release_exclusive(handle) -> None:
251
+ if os.name == "nt": # pragma: no cover
252
+ import msvcrt
253
+
254
+ handle.seek(0)
255
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
256
+ return
257
+ import fcntl
258
+
259
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
260
+
261
+
262
+ @dataclass
263
+ class _HeldLock:
264
+ mode: str
265
+ owner: tuple[int, int, int | None]
266
+ active: bool = True
267
+
268
+
269
+ def _lock_owner() -> tuple[int, int, int | None]:
270
+ try:
271
+ task = asyncio.current_task()
272
+ except RuntimeError:
273
+ task = None
274
+ return os.getpid(), threading.get_ident(), id(task) if task is not None else None
275
+
276
+
277
+ _HELD_LOCKS: contextvars.ContextVar[dict[Path, _HeldLock] | None] = contextvars.ContextVar("whykit_vault_locks", default=None)
278
+
279
+
280
+ def _owned_lock(held: dict[Path, _HeldLock], root: Path) -> _HeldLock | None:
281
+ lock = held.get(root)
282
+ return lock if lock is not None and lock.active and lock.owner == _lock_owner() else None
283
+
284
+
285
+ def _acquire_shared(handle) -> None:
286
+ if os.name == "nt": # pragma: no cover - Windows CI
287
+ import msvcrt
288
+ handle.seek(0)
289
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBRLCK, 1)
290
+ else:
291
+ import fcntl
292
+ fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
293
+
294
+
295
+ def _pending_transactions(root: Path) -> bool:
296
+ base = root / ".whykit" / "transactions"
297
+ if base.is_symlink():
298
+ raise OSError(errno.EBUSY, "symlinked transaction state refused")
299
+ if not base.exists():
300
+ return False
301
+ for tx in base.iterdir():
302
+ if tx.is_symlink():
303
+ raise OSError(errno.EBUSY, "symlinked transaction state refused")
304
+ if (tx / "READY").exists() and not (tx / "COMMITTED").exists():
305
+ return True
306
+ return False
307
+
308
+
309
+ @contextmanager
310
+ def vault_read_lock(root: Path, *, timeout: float = 15.0) -> Iterator[None]:
311
+ """Hold a shared OS lock without writing to the vault; pending recovery fails closed.
312
+
313
+ A pristine vault has no lock file. Detect its creation before returning a
314
+ result, so its first writer cannot race the reader. External editors that
315
+ ignore the advisory lock are outside this transaction guarantee.
316
+ """
317
+ root = Path(root).resolve(strict=True)
318
+ held = _HELD_LOCKS.get() or {}
319
+ if _owned_lock(held, root) is not None:
320
+ yield
321
+ return
322
+ state = root / ".whykit"
323
+ lock_path = state / "mutation.lock"
324
+ if state.is_symlink() or lock_path.is_symlink():
325
+ raise OSError(errno.EBUSY, "symlinked mutation state refused")
326
+ try:
327
+ handle = lock_path.open("rb")
328
+ except FileNotFoundError:
329
+ handle = None
330
+ token = None
331
+ acquired = False
332
+ ownership = _HeldLock("read", _lock_owner())
333
+ try:
334
+ if handle is not None:
335
+ deadline = time.monotonic() + timeout
336
+ while True:
337
+ try:
338
+ _acquire_shared(handle)
339
+ acquired = True
340
+ break
341
+ except OSError as exc:
342
+ if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
343
+ raise
344
+ if time.monotonic() >= deadline:
345
+ raise OSError(errno.EBUSY, "vault write in progress; retry the read") from None
346
+ time.sleep(0.01)
347
+ if _pending_transactions(root):
348
+ raise OSError(errno.EBUSY, "vault recovery required; run whykit recover before reading")
349
+ token = _HELD_LOCKS.set({**held, root: ownership})
350
+ yield
351
+ if handle is None and (lock_path.exists() or _pending_transactions(root)):
352
+ raise OSError(errno.EBUSY, "vault changed during its first governed write; retry the read")
353
+ finally:
354
+ ownership.active = False
355
+ if token is not None:
356
+ _HELD_LOCKS.reset(token)
357
+ if handle is not None:
358
+ try:
359
+ if acquired:
360
+ _release_exclusive(handle)
361
+ finally:
362
+ handle.close()
363
+
364
+
365
+ def consistent_read(handler):
366
+ """Protect a complete root-first report, including its nested config/source reads."""
367
+ @functools.wraps(handler)
368
+ def guarded(root, *args, **kwargs):
369
+ with vault_read_lock(root):
370
+ return handler(root, *args, **kwargs)
371
+ return guarded
372
+
373
+
374
+ @contextmanager
375
+ def vault_mutation_lock(root: Path, *, timeout: float = 15.0) -> Iterator[None]:
376
+ """Serialize ID allocation and multi-file ledger mutations for one vault.
377
+
378
+ Uses an OS advisory exclusive lock on ``.whykit/mutation.lock``. The kernel
379
+ releases the lock when the process dies, so crash/SIGKILL cannot leave a
380
+ permanent directory lock that blocks all future mutations.
381
+ """
382
+ root = Path(root).resolve(strict=True)
383
+ held = _HELD_LOCKS.get() or {}
384
+ current = _owned_lock(held, root)
385
+ if current is not None:
386
+ if current.mode != "write":
387
+ raise RuntimeError("cannot upgrade a vault read lock to a write lock")
388
+ yield
389
+ return
390
+ lock_path = safe_vault_target(root, ".whykit/mutation.lock")
391
+ handle = lock_path.open("a+b")
392
+ deadline = time.monotonic() + timeout
393
+ token = None
394
+ ownership = _HeldLock("write", _lock_owner())
395
+ try:
396
+ while True:
397
+ try:
398
+ _acquire_exclusive(handle, non_blocking=True)
399
+ break
400
+ except (BlockingIOError, OSError):
401
+ if time.monotonic() >= deadline:
402
+ raise TimeoutError(f"vault is already being modified: {root}") from None
403
+ time.sleep(0.05)
404
+ token = _HELD_LOCKS.set({**held, root: ownership})
405
+ # Crash recovery must run under the lock so two agents never finish the
406
+ # same READY journal concurrently.
407
+ recover_pending_transactions(root)
408
+ handle.seek(0)
409
+ handle.truncate()
410
+ handle.write(
411
+ json.dumps({"pid": os.getpid(), "started_at": time.time()}, ensure_ascii=False).encode("utf-8")
412
+ )
413
+ handle.flush()
414
+ yield
415
+ finally:
416
+ ownership.active = False
417
+ if token is not None:
418
+ _HELD_LOCKS.reset(token)
419
+ try:
420
+ _release_exclusive(handle)
421
+ except OSError:
422
+ pass
423
+ handle.close()
424
+
425
+
426
+ def _is_transaction_state(relative: str | Path) -> bool:
427
+ # Reserve filesystem aliases on every host so moved journals stay safe.
428
+ parts = Path(relative).parts
429
+ return bool(parts) and parts[0].split(":", 1)[0].rstrip(" .").casefold() == ".whykit"
430
+
431
+
432
+ def stage_transaction(root: Path, updates: Mapping[Path, str]) -> Path:
433
+ """Stage a multi-file write batch under ``.whykit/transactions/<id>/``."""
434
+ root = Path(root).resolve(strict=True)
435
+ if not updates:
436
+ raise ValueError("transaction requires at least one update")
437
+ for target in updates:
438
+ ensure_writable(Path(target))
439
+ txid = secrets.token_hex(12)
440
+ tx = safe_vault_dir(root, Path(".whykit") / "transactions" / txid)
441
+ manifest: list[dict[str, str]] = []
442
+ for index, (target, content) in enumerate(updates.items()):
443
+ if Path(target).is_symlink():
444
+ raise RuntimeError(f"refusing to mutate symlink: {target}")
445
+ resolved_target = Path(target).resolve()
446
+ try:
447
+ relative = resolved_target.relative_to(root).as_posix()
448
+ except ValueError as exc:
449
+ raise RuntimeError(f"transaction target escapes vault: {target}") from exc
450
+ if _is_transaction_state(relative):
451
+ raise ValueError(f"transaction cannot replace its own state: {target}")
452
+ staged_name = f"{index}.new"
453
+ staged = tx / staged_name
454
+ content = match_line_endings(resolved_target, content)
455
+ atomic_write_text(staged, content)
456
+ manifest.append({
457
+ "target": relative,
458
+ "staged": staged_name,
459
+ "sha256": hashlib.sha256(content.encode("utf-8", "surrogateescape")).hexdigest(),
460
+ })
461
+ # ASCII JSON: paths decoded under a C locale carry surrogates, which UTF-8
462
+ # cannot encode; json escapes round-trip them exactly.
463
+ atomic_write_text(tx / "manifest.json", json.dumps(manifest, sort_keys=True, ensure_ascii=True))
464
+ atomic_write_text(tx / "READY", "1\n")
465
+ return tx
466
+
467
+
468
+ def commit_transaction(root: Path, tx: Path) -> None:
469
+ """Replay a READY transaction; discard staged bytes only after COMMITTED."""
470
+ root = Path(root).resolve(strict=True)
471
+ tx = Path(tx)
472
+ base = safe_vault_dir(root, ".whykit/transactions")
473
+ if tx.parent.resolve() != base or tx.is_symlink():
474
+ raise RuntimeError(f"unsafe transaction directory: {tx}")
475
+ ready = tx / "READY"
476
+ committed = tx / "COMMITTED"
477
+ if not ready.exists():
478
+ raise RuntimeError(f"transaction is not READY: {tx}")
479
+ if any(path.is_symlink() for path in (ready, committed, tx / "manifest.json")):
480
+ raise RuntimeError(f"symlinked transaction metadata refused: {tx}")
481
+ manifest = json.loads((tx / "manifest.json").read_text(encoding="utf-8"))
482
+ if not isinstance(manifest, list) or not manifest:
483
+ raise RuntimeError(f"malformed transaction manifest: {tx}")
484
+ entries: list[tuple[Path, Path, str]] = []
485
+ sources: set[str] = set()
486
+ targets: set[Path] = set()
487
+ for item in manifest:
488
+ if (
489
+ not isinstance(item, dict)
490
+ or not isinstance(item.get("target"), str)
491
+ or not isinstance(item.get("staged"), str)
492
+ or not re.fullmatch(r"[0-9]+\.new", item["staged"])
493
+ or not isinstance(item.get("sha256"), str)
494
+ or not re.fullmatch(r"[0-9a-f]{64}", item["sha256"])
495
+ ):
496
+ raise RuntimeError(f"malformed transaction entry: {tx}")
497
+ source = tx / item["staged"]
498
+ if _is_transaction_state(item["target"]):
499
+ raise RuntimeError(f"transaction cannot replace its own state: {item['target']}")
500
+ target = safe_vault_target(root, item["target"])
501
+ if source.is_symlink() or item["staged"] in sources or target in targets:
502
+ raise RuntimeError(f"unsafe or duplicate transaction entry: {tx}")
503
+ sources.add(item["staged"])
504
+ targets.add(target)
505
+ entries.append((source, target, item["sha256"]))
506
+ if not committed.exists():
507
+ for source, target, expected in entries:
508
+ try:
509
+ data = source.read_bytes()
510
+ except FileNotFoundError:
511
+ # Older journals deleted each source immediately after writing.
512
+ if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == expected:
513
+ continue
514
+ raise RuntimeError(f"cannot recover missing staged content: {source}; restore it from a backup") from None
515
+ if hashlib.sha256(data).hexdigest() != expected:
516
+ raise RuntimeError(f"staged content digest mismatch: {source}")
517
+ atomic_write_bytes(target, data)
518
+ atomic_write_text(committed, "1\n")
519
+ _discard_transaction(tx)
520
+
521
+
522
+ def _discard_transaction(tx: Path) -> None:
523
+ # Rename before unlinking: a crash during deletion must never replay targets.
524
+ garbage = tx.with_name("gc-" + tx.name)
525
+ os.replace(tx, garbage)
526
+ _sync_directory(garbage.parent)
527
+ shutil.rmtree(garbage)
528
+ _sync_directory(garbage.parent)
529
+
530
+
531
+ def recover_pending_transactions(root: Path) -> list[Path]:
532
+ """Finish READY transactions that never reached COMMITTED (crash recovery)."""
533
+ root = Path(root).resolve(strict=True)
534
+ base = root / ".whykit" / "transactions"
535
+ if base.is_symlink():
536
+ raise RuntimeError(f"symlinked transaction directory refused: {base}")
537
+ if not base.is_dir():
538
+ return []
539
+ base = safe_vault_dir(root, ".whykit/transactions")
540
+ recovered: list[Path] = []
541
+ for tx in sorted(base.iterdir()):
542
+ if tx.is_symlink():
543
+ raise RuntimeError(f"symlinked transaction directory refused: {tx}")
544
+ if not tx.is_dir():
545
+ continue
546
+ if re.fullmatch(r"gc-[0-9a-f]{24}", tx.name):
547
+ shutil.rmtree(tx)
548
+ _sync_directory(base)
549
+ elif (tx / "READY").exists():
550
+ pending = not (tx / "COMMITTED").exists()
551
+ commit_transaction(root, tx)
552
+ if pending:
553
+ recovered.append(tx)
554
+ elif re.fullmatch(r"[0-9a-f]{24}", tx.name) and time.time() - tx.stat().st_mtime >= 86400:
555
+ # Pre-READY journals never touched targets; retain young ones for inspection.
556
+ _discard_transaction(tx)
557
+ return recovered
558
+
559
+
560
+ def apply_transaction(root: Path, updates: Mapping[Path, str]) -> None:
561
+ """Stage then commit a multi-file update under the caller's mutation lock."""
562
+ tx = stage_transaction(root, updates)
563
+ commit_transaction(root, tx)