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/parse_cache.py ADDED
@@ -0,0 +1,752 @@
1
+ """Persistent parse cache: skip re-reading unchanged notes on the next run.
2
+
3
+ Every read command parses the whole vault. On a large vault most of that work
4
+ repeats from one run to the next, because most files did not change. This
5
+ module keeps, per note, what WhyKit derives from its text alone (front matter,
6
+ wikilink and Markdown link targets, fact callouts, credential-shaped matches)
7
+ in ``.whykit/cache/`` and reuses it while the file is unchanged. Front matter
8
+ lives in ``notes.v1``; each derived view has a file of its own, read only by a
9
+ command that needs it. Everything
10
+ that depends on more than one file -- link resolution, findings, the graph --
11
+ is still computed on every run.
12
+
13
+ Correctness rules, in order of precedence:
14
+
15
+ * An entry is reused only when the file's ``stat`` signature (modification
16
+ and change time in nanoseconds, size, inode) is exactly the one recorded
17
+ when the entry was parsed. Any difference means a re-parse. The change
18
+ time cannot be set by a user, so neither ``touch -d``/``os.utime`` nor a
19
+ copied-in cache file from another checkout can fake a match. Windows has
20
+ no such change time in ``os.stat`` (``st_ctime`` is the creation time
21
+ there), so on Windows every entry is verified by content.
22
+ * An entry whose file changed within two seconds of being parsed ("racy",
23
+ as Git calls it), or whose timestamps lie in the future, is reused only
24
+ after the file's SHA-256 matches the recorded one. This covers file
25
+ systems with coarse timestamps and clocks that moved.
26
+ * The whole cache is discarded when the WhyKit version, its source code, the
27
+ Python version, the Unicode database or ``whykit.toml`` changes.
28
+ * A cache that cannot be read or does not have the expected shape is ignored
29
+ with a warning and rebuilt. An unreadable or unwritable cache directory
30
+ just means no cache.
31
+ * Credential-shaped matches are stored per note under the same content rule,
32
+ so any change to a note (its ``sensitivity`` included) is scanned again;
33
+ files that are not notes are scanned on every run.
34
+ * Note bodies are never stored. A note's text is read again only if a
35
+ command needs it (full-text search, context packs), and then its SHA-256
36
+ must still match; if it does not, the note is parsed afresh.
37
+
38
+ Writes are atomic (temporary file plus ``os.replace``), take a lock of their
39
+ own and are skipped while another process holds it or while a WhyKit command
40
+ is modifying the vault, so concurrent runs never see a partial file.
41
+
42
+ ``--no-cache`` (``whykit --no-cache lint``) or ``WHYKIT_NO_CACHE=1`` turns
43
+ the cache off. Outputs are byte-identical either way.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import contextlib
48
+ import contextvars
49
+ import functools
50
+ import gc
51
+ import hashlib
52
+ import json
53
+ import os
54
+ import secrets
55
+ import stat as stat_mod
56
+ import sys
57
+ import time
58
+ import unicodedata
59
+ from collections.abc import Iterator
60
+ from pathlib import Path
61
+ from typing import Any
62
+
63
+ from . import __version__
64
+ from .config import CONFIG_FILE
65
+ from .console import one_line
66
+ from .lint import Note, _normalise, _shared, _shared_target, load_note
67
+
68
+ CACHE_DIR = Path(".whykit") / "cache"
69
+ # No extension the secret scan or the Markdown collector looks at.
70
+ CACHE_FILE = "notes.v1"
71
+ # File names keep the layout version; FORMAT (stored inside, and part of the
72
+ # fingerprint) is what invalidates entries.
73
+ VIEW_SUFFIX = ".v1"
74
+ LOCK_FILE = "write.lock"
75
+ # Bumped whenever what a parse produces changes shape or meaning (2: link and
76
+ # table parsing became linear, decision and evidence IDs ASCII-digit only, and
77
+ # files that are not valid UTF-8 parse with a finding instead of failing).
78
+ # The fingerprint also hashes the package source, so this is the explicit
79
+ # signal for installed copies whose version string did not move.
80
+ FORMAT = 2
81
+ ENV_DISABLE = "WHYKIT_NO_CACHE"
82
+ RACY_NS = 2_000_000_000
83
+ # Whether ``st_ctime`` is the inode change time, which every content write
84
+ # moves and no user can set. On Windows it is the creation time instead, so a
85
+ # same-size rewrite with the old mtime put back would keep the signature.
86
+ CHANGE_TIME_TRUSTED = os.name != "nt"
87
+ _SEP = os.sep
88
+
89
+ # Derived views of a note's text that the cache stores when a run computed
90
+ # them. Each is a ``functools.cached_property`` on ``Note``; a view a run did
91
+ # not need is simply absent and computed from the text when a later run does.
92
+ NOTE_FACTS: tuple[str, ...] = (
93
+ "cited_evidence",
94
+ "wikilink_hits",
95
+ "markdown_link_hits",
96
+ "fact_callouts",
97
+ "section_decision_id",
98
+ "secret_hits",
99
+ "decision_placeholders",
100
+ )
101
+
102
+
103
+ class _Corrupt(ValueError):
104
+ """A stored value does not have the shape its view returns."""
105
+
106
+
107
+ def _need(condition: bool) -> None:
108
+ if not condition:
109
+ raise _Corrupt("unexpected value in parse cache")
110
+
111
+
112
+ def _int(value: Any) -> int:
113
+ _need(type(value) is int)
114
+ return value
115
+
116
+
117
+ def _str(value: Any) -> str:
118
+ _need(type(value) is str)
119
+ return _shared_target(value)
120
+
121
+
122
+ def _bool(value: Any) -> bool:
123
+ _need(type(value) is bool)
124
+ return value
125
+
126
+
127
+ def _kind(value: Any) -> str:
128
+ _need(value in ("link", "image"))
129
+ return "image" if value == "image" else "link"
130
+
131
+
132
+ def _list(value: Any) -> list:
133
+ _need(type(value) is list)
134
+ return value
135
+
136
+
137
+ def _rows(value: Any, width: int) -> list:
138
+ _need(type(value) is list and all(type(row) is list and len(row) == width for row in value))
139
+ return value
140
+
141
+
142
+ # How each stored view turns back into what its ``Note`` property returns.
143
+ # Every conversion checks the shape, so a damaged entry is recomputed from the
144
+ # note's text instead of producing a wrong finding.
145
+ _THAW: dict[str, Any] = {
146
+ "cited_evidence": lambda value: tuple(_str(item) for item in _list(value)),
147
+ "wikilink_hits": lambda value: tuple(
148
+ (_str(target), _int(line), _bool(embed)) for target, line, embed in _rows(value, 3)
149
+ ),
150
+ "markdown_link_hits": lambda value: tuple(
151
+ (_kind(kind), _str(target), _int(line)) for kind, target, line in _rows(value, 3)
152
+ ),
153
+ "fact_callouts": lambda value: tuple(
154
+ (_int(line), tuple(_str(item) for item in _list(cited))) for line, cited in _rows(value, 2)
155
+ ),
156
+ "section_decision_id": lambda value: None if value is None else _str(value),
157
+ "secret_hits": lambda value: tuple((_int(line), _str(label)) for line, label in _rows(value, 2)),
158
+ "decision_placeholders": lambda value: tuple((_int(line), _str(section)) for line, section in _rows(value, 2)),
159
+ }
160
+
161
+
162
+ def _thaw_front(value: Any) -> Any:
163
+ """Check a stored front-matter value and share its short strings, in place.
164
+
165
+ Front matter is made of str, bool, None, lists and one level of mapping;
166
+ JSON round-trips all of them. The decoded containers become the note's
167
+ own, so nothing is copied.
168
+ """
169
+ kind = type(value)
170
+ if kind is str:
171
+ return _shared(value)
172
+ if kind is list:
173
+ for index, item in enumerate(value):
174
+ value[index] = _shared(item) if type(item) is str else _thaw_front(item)
175
+ return value
176
+ if kind is dict:
177
+ for key, item in value.items():
178
+ value[key] = _shared(item) if type(item) is str else _thaw_front(item)
179
+ return value
180
+ _need(value is None or kind is bool)
181
+ return value
182
+
183
+
184
+ def _signature(info: os.stat_result) -> tuple[int, int, int, int]:
185
+ return (info.st_mtime_ns, info.st_ctime_ns, info.st_size, info.st_ino)
186
+
187
+
188
+ def _settled(info: os.stat_result, now_ns: int) -> bool:
189
+ """Whether a file's timestamps are safely in the past at *now_ns*.
190
+
191
+ A write within the same timestamp tick, with the same size, would leave
192
+ the signature unchanged. Two seconds covers the coarsest common
193
+ timestamp resolution (FAT); a timestamp in the future means the clock
194
+ cannot be trusted, so such an entry is always verified by content.
195
+ """
196
+ if not CHANGE_TIME_TRUSTED:
197
+ return False # without a real change time, only the content proves a match
198
+ newest = max(info.st_mtime_ns, info.st_ctime_ns)
199
+ return now_ns - newest >= RACY_NS
200
+
201
+
202
+ def _decode(data: bytes) -> str:
203
+ # Same text as Path.read_text(encoding="utf-8"): strict UTF-8, universal
204
+ # newlines (load_note translates the line endings of supplied text).
205
+ return data.decode("utf-8")
206
+
207
+
208
+ _MISSING = object()
209
+
210
+
211
+ class CachedNote(Note):
212
+ """A note restored from the cache. Its text is read only when used.
213
+
214
+ The text must hash to the digest recorded with the parsed fields. If the
215
+ file changed since the run's ``stat`` (a concurrent edit), the note is
216
+ re-parsed from what is on disk now, as an uncached run reading the file at
217
+ this moment would have done, and it is not written back to the cache.
218
+
219
+ Each stored view is read from its own file the first time a command asks
220
+ for it, so a command that needs only the front matter never loads them.
221
+ """
222
+
223
+ def __init__(self, path: Path, digest: str, fields: list, cache: DiskCache, key: str) -> None: # noqa: D107
224
+ front, has_front, front_error, body_offset = fields
225
+ self.path = path
226
+ self._text: str | None = None
227
+ self.digest = digest
228
+ self.front = front
229
+ self.has_front = has_front
230
+ self.front_error = front_error
231
+ self.body_offset = body_offset
232
+ self.stale = False
233
+ self.cache = cache
234
+ self.key = key
235
+
236
+ @property # type: ignore[override]
237
+ def text(self) -> str:
238
+ if self._text is None:
239
+ data = self.path.read_bytes()
240
+ if hashlib.sha256(data).hexdigest() == self.digest:
241
+ self._text = _normalise(_decode(data))
242
+ else:
243
+ try:
244
+ fresh = load_note(self.path, text=_decode(data))
245
+ except UnicodeDecodeError:
246
+ fresh = load_note(self.path) # raises like an uncached load
247
+ self.stale = True
248
+ self.cache.stale.add(self.key)
249
+ for name in NOTE_FACTS + ("masked",):
250
+ self.__dict__.pop(name, None)
251
+ self.front = fresh.front
252
+ self.has_front = fresh.has_front
253
+ self.front_error = fresh.front_error
254
+ self.body_offset = fresh.body_offset
255
+ self._text = fresh.text
256
+ return self._text
257
+
258
+ @text.setter
259
+ def text(self, value: str) -> None:
260
+ self._text = value
261
+
262
+
263
+ class FreshNote(Note):
264
+ """A note parsed in this run; views it computes are handed to the cache.
265
+
266
+ The cache keeps the computed values rather than the note, so a command
267
+ that is done with its notes can free them before the cache is written.
268
+ """
269
+
270
+ cache: DiskCache
271
+ key: str
272
+ digest: str
273
+
274
+
275
+ def _cached_view(name: str) -> functools.cached_property:
276
+ compute = getattr(Note, name).func
277
+
278
+ def view(self: CachedNote) -> Any:
279
+ if not self.stale:
280
+ raw = self.cache.stored_view(name, self.key, self.digest)
281
+ if raw is not _MISSING:
282
+ try:
283
+ return _THAW[name](raw)
284
+ except (_Corrupt, TypeError, ValueError):
285
+ _warn(f"parse cache entry for {self.path.name} is damaged; recomputing it")
286
+ value = compute(self)
287
+ if not self.stale:
288
+ self.cache.computed(name, self.key, value)
289
+ return value
290
+
291
+ def fresh_view(self: FreshNote) -> Any:
292
+ value = compute(self)
293
+ self.cache.computed(name, self.key, value)
294
+ return value
295
+
296
+ prop = functools.cached_property(view)
297
+ prop.__set_name__(CachedNote, name)
298
+ setattr(CachedNote, name, prop)
299
+ fresh = functools.cached_property(fresh_view)
300
+ fresh.__set_name__(FreshNote, name)
301
+ setattr(FreshNote, name, fresh)
302
+ return prop
303
+
304
+
305
+ for _name in NOTE_FACTS:
306
+ _cached_view(_name)
307
+ del _name
308
+
309
+
310
+ class _Entry:
311
+ __slots__ = ("signature", "digest", "settled", "fields")
312
+
313
+ def __init__(self, signature: tuple[int, int, int, int], digest: str, settled: bool, fields: list) -> None:
314
+ self.signature = signature
315
+ self.digest = digest
316
+ self.settled = settled
317
+ self.fields = fields
318
+
319
+
320
+ def fingerprint(root: Path) -> str:
321
+ """Everything outside a note that its cached parse depends on."""
322
+ digest = hashlib.sha256()
323
+ digest.update(f"format={FORMAT}\nversion={__version__}\n".encode())
324
+ digest.update(f"python={sys.version_info[0]}.{sys.version_info[1]}\nunicode={unicodedata.unidata_version}\n".encode())
325
+ package = Path(__file__).resolve().parent
326
+ # The parser's own source: a development checkout changes behaviour without
327
+ # changing the version number. The template supplies placeholder prompts.
328
+ sources = sorted(package.glob("*.py")) + sorted(p for p in (package / "template").rglob("*") if p.is_file())
329
+ for path in sources:
330
+ try:
331
+ data = path.read_bytes()
332
+ except OSError:
333
+ data = b"<unreadable>"
334
+ digest.update(path.relative_to(package).as_posix().encode("utf-8", "surrogateescape"))
335
+ digest.update(hashlib.sha256(data).digest())
336
+ try:
337
+ digest.update(b"config=" + hashlib.sha256((root / CONFIG_FILE).read_bytes()).digest())
338
+ except OSError:
339
+ digest.update(b"config=<none>")
340
+ return digest.hexdigest()
341
+
342
+
343
+ def _warn(message: str) -> None:
344
+ try:
345
+ print(f"warning: {one_line(message)}", file=sys.stderr)
346
+ except (OSError, ValueError):
347
+ pass
348
+
349
+
350
+ def _no_symlinks(root: Path, relative: Path) -> bool:
351
+ current = root
352
+ for part in relative.parts:
353
+ current = current / part
354
+ try:
355
+ if stat_mod.S_ISLNK(os.lstat(current).st_mode):
356
+ return False
357
+ except FileNotFoundError:
358
+ return True
359
+ except OSError:
360
+ return False
361
+ return True
362
+
363
+
364
+ @contextlib.contextmanager
365
+ def _no_gc() -> Iterator[None]:
366
+ """Pause the cycle collector while building acyclic containers in bulk.
367
+
368
+ Decoding the cache creates hundreds of thousands of lists and dicts; each
369
+ allocation burst would otherwise trigger collections that walk all of them
370
+ again. Nothing created here holds a reference cycle.
371
+ """
372
+ enabled = gc.isenabled()
373
+ gc.disable()
374
+ try:
375
+ yield
376
+ finally:
377
+ if enabled:
378
+ gc.enable()
379
+
380
+
381
+ def _open_nofollow(path: Path, flags: int) -> int:
382
+ """``os.open`` that refuses to follow a symlink at *path* where supported."""
383
+ return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0), 0o644)
384
+
385
+
386
+ Record = tuple[tuple[int, int, int, int], str, bool]
387
+
388
+
389
+ def _view_tag(digest: str) -> str:
390
+ """The part of a note's digest a view row carries to say which content it is for.
391
+
392
+ 64 bits: the row is only ever compared with the digest of the same path's
393
+ current content, which ``notes.v1`` checked in full.
394
+ """
395
+ return digest[:16]
396
+
397
+
398
+ class DiskCache:
399
+ """The parse cache of one vault for the duration of one command.
400
+
401
+ ``notes.v1`` holds each note's ``stat`` signature, digest and front
402
+ matter. Each derived view has a file of its own (``wikilink_hits.v1`` and
403
+ so on), keyed by the same digest and read only when a command first asks
404
+ for that view. Any of them can be missing or stale without affecting the
405
+ others.
406
+
407
+ ``notes.v1`` is written as soon as the notes are loaded, when it changed;
408
+ the view files when the command ends, from the views it computed. The
409
+ cache never keeps the notes themselves alive.
410
+ """
411
+
412
+ def __init__(self, root: Path) -> None:
413
+ self.root = root
414
+ self.directory = root / CACHE_DIR
415
+ self.file = self.directory / CACHE_FILE
416
+ self.fingerprint = fingerprint(root)
417
+ self.entries: dict[str, _Entry] = {}
418
+ # Digest of each note the last load recorded, for the view files.
419
+ self.digests: dict[str, str] | None = None
420
+ self.stale: set[str] = set()
421
+ self.dirty = False
422
+ self.hits = 0
423
+ self.misses = 0
424
+ self._views: dict[str, dict[str, Any]] = {}
425
+ # Views whose file must be rewritten, with the values this run computed.
426
+ self._computed: dict[str, dict[str, Any]] = {}
427
+ self._broken_views: set[str] = set()
428
+ self._read()
429
+
430
+ # -- reading ---------------------------------------------------------
431
+
432
+ def _load_json(self, path: Path) -> Any:
433
+ """The decoded document at *path*, or ``None`` when there is none to trust."""
434
+ if not _no_symlinks(self.root, path.relative_to(self.root)):
435
+ return None
436
+ try:
437
+ info = os.stat(path)
438
+ except OSError:
439
+ return None
440
+ # Only trust a cache this user wrote.
441
+ if hasattr(os, "getuid") and info.st_uid != os.getuid():
442
+ return None
443
+ try:
444
+ with open(path, "rb") as handle:
445
+ raw = handle.read()
446
+ except OSError:
447
+ return None
448
+ with _no_gc():
449
+ data = json.loads(raw)
450
+ if not isinstance(data, dict) or data.get("format") != FORMAT:
451
+ raise ValueError("unknown format")
452
+ if data.get("fingerprint") != self.fingerprint:
453
+ return None # WhyKit, Python or whykit.toml changed
454
+ return data
455
+
456
+ def _read(self) -> None:
457
+ try:
458
+ data = self._load_json(self.file)
459
+ if data is None:
460
+ self.dirty = os.path.lexists(self.file)
461
+ return
462
+ entries: dict[str, _Entry] = {}
463
+ items = data["entries"]
464
+ if not isinstance(items, dict):
465
+ raise ValueError("entries is not an object")
466
+ for key, row in items.items():
467
+ mtime, ctime, size, ino, digest, settled, fields = row
468
+ if not (
469
+ all(type(value) is int for value in (mtime, ctime, size, ino))
470
+ and isinstance(digest, str) and len(digest) == 64
471
+ and type(settled) is bool
472
+ and isinstance(fields, list) and len(fields) == 4
473
+ and isinstance(fields[0], dict) and type(fields[1]) is bool
474
+ and (fields[2] is None or isinstance(fields[2], str)) and type(fields[3]) is int
475
+ ):
476
+ raise ValueError(f"malformed entry for {key!r}")
477
+ entries[key] = _Entry((mtime, ctime, size, ino), digest, settled, fields)
478
+ except (ValueError, TypeError, KeyError, RecursionError) as exc:
479
+ _warn(f"ignoring unreadable parse cache {self.file} ({exc}); it will be rebuilt")
480
+ self.dirty = True
481
+ return
482
+ self.entries = entries
483
+
484
+ def _view_file(self, name: str) -> Path:
485
+ return self.directory / f"{name}{VIEW_SUFFIX}"
486
+
487
+ def _decode_view(self, name: str) -> dict[str, Any]:
488
+ path = self._view_file(name)
489
+ try:
490
+ data = self._load_json(path)
491
+ if data is None:
492
+ if os.path.lexists(path):
493
+ self._broken_views.add(name) # from another WhyKit or config
494
+ return {}
495
+ rows = data["rows"]
496
+ if not isinstance(rows, dict):
497
+ raise ValueError("rows is not an object")
498
+ return rows
499
+ except (ValueError, TypeError, KeyError, RecursionError) as exc:
500
+ if name not in self._broken_views:
501
+ _warn(f"ignoring unreadable parse cache {path} ({exc}); it will be rebuilt")
502
+ self._broken_views.add(name)
503
+ return {}
504
+
505
+ def stored_view(self, name: str, key: str, digest: str) -> Any:
506
+ """The stored value of view *name* for the note at *key*, if it belongs to *digest*.
507
+
508
+ The row is handed over (removed): the note keeps the converted value,
509
+ and holding both would double the memory.
510
+ """
511
+ rows = self._views.get(name)
512
+ if rows is None:
513
+ rows = self._views[name] = self._decode_view(name)
514
+ row = rows.pop(key, None)
515
+ if type(row) is list and len(row) == 2 and row[0] == _view_tag(digest):
516
+ return row[1]
517
+ return _MISSING
518
+
519
+ def computed(self, name: str, key: str, value: Any) -> None:
520
+ """Record a view this run computed because the cache did not have it."""
521
+ self._computed.setdefault(name, {})[key] = value
522
+
523
+ def _key(self, path: Path) -> str:
524
+ """The note's vault-relative path, always with ``/`` separators.
525
+
526
+ One spelling on every platform, so a cache written on Windows is keyed
527
+ like every other record of the vault (``01-strategy/note.md``).
528
+ """
529
+ text = os.fspath(path)
530
+ prefix = os.fspath(self.root).rstrip(_SEP) + _SEP
531
+ key = text[len(prefix):] if text.startswith(prefix) else text
532
+ return key if _SEP == "/" else key.replace(_SEP, "/")
533
+
534
+ def load_all(self, paths: list[Path]) -> list[Note]:
535
+ """Parse *paths* like ``load_note`` would, reusing unchanged entries."""
536
+ if self.digests is not None:
537
+ # A second load in the same command: start from what is on disk now.
538
+ self.dirty = False
539
+ self._read()
540
+ notes: list[Note] = []
541
+ rows: dict[str, list] = {}
542
+ with _no_gc():
543
+ for path in paths:
544
+ key = self._key(path)
545
+ note, record = self._load(path, key)
546
+ notes.append(note)
547
+ if record is not None:
548
+ signature, digest, settled = record
549
+ rows[key] = [*signature, digest, settled, [note.front, note.has_front, note.front_error, note.body_offset]]
550
+ if set(self.entries) - set(rows):
551
+ self.dirty = True # a note was deleted, renamed or could not be recorded
552
+ if self.dirty:
553
+ self._write([(self.file, {"format": FORMAT, "whykit": __version__, "fingerprint": self.fingerprint, "entries": rows})])
554
+ # The notes own their front matter now; keep only what the view files need.
555
+ self.entries = {}
556
+ self.digests = {key: row[4] for key, row in rows.items()}
557
+ return notes
558
+
559
+ def _load(self, path: Path, key: str) -> tuple[Note, Record | None]:
560
+ try:
561
+ before = os.stat(path)
562
+ except OSError:
563
+ return load_note(path), None # raises like an uncached load
564
+ signature = _signature(before)
565
+ entry = self.entries.get(key)
566
+ now = time.time_ns()
567
+ if entry is not None and entry.signature == signature:
568
+ if entry.settled:
569
+ note = self._restore(path, key, entry, entry.digest)
570
+ if note is not None:
571
+ return note, (signature, entry.digest, True)
572
+ return self._parse(path, key, path.read_bytes(), before, now)
573
+ # Racy or future-dated when it was parsed: verify by content.
574
+ data = path.read_bytes()
575
+ digest = hashlib.sha256(data).hexdigest()
576
+ if digest == entry.digest and _signature(os.stat(path)) == signature:
577
+ note = self._restore(path, key, entry, digest)
578
+ if note is not None:
579
+ note.text = _normalise(_decode(data))
580
+ settled = _settled(before, now)
581
+ if settled:
582
+ self.dirty = True
583
+ return note, (signature, digest, settled)
584
+ return self._parse(path, key, data, before, now)
585
+ return self._parse(path, key, path.read_bytes(), before, now)
586
+
587
+ def _restore(self, path: Path, key: str, entry: _Entry, digest: str) -> CachedNote | None:
588
+ try:
589
+ front = _thaw_front(entry.fields[0])
590
+ except (_Corrupt, TypeError, ValueError, RecursionError):
591
+ _warn(f"parse cache entry for {path.name} is damaged; parsing the note again")
592
+ return None
593
+ self.hits += 1
594
+ return CachedNote(path, digest, [front, *entry.fields[1:]], self, key)
595
+
596
+ def _parse(self, path: Path, key: str, data: bytes, before: os.stat_result, now: int) -> tuple[Note, Record | None]:
597
+ self.misses += 1
598
+ self.dirty = True
599
+ try:
600
+ text = _decode(data)
601
+ except UnicodeDecodeError:
602
+ return load_note(path), None # raises exactly like an uncached load
603
+ note = load_note(path, text=text)
604
+ try:
605
+ after = os.stat(path)
606
+ except OSError:
607
+ return note, None
608
+ if _signature(after) != _signature(before):
609
+ return note, None # changed while being read: do not record
610
+ digest = hashlib.sha256(data).hexdigest()
611
+ note.__class__ = FreshNote
612
+ note.cache, note.key, note.digest = self, key, digest # type: ignore[attr-defined]
613
+ return note, (_signature(before), digest, _settled(before, now))
614
+
615
+ # -- writing ---------------------------------------------------------
616
+
617
+ def _view_payloads(self) -> Iterator[tuple[Path, dict]]:
618
+ if self.digests is None:
619
+ return
620
+ current = {key: _view_tag(digest) for key, digest in self.digests.items() if key not in self.stale}
621
+ for name in NOTE_FACTS:
622
+ computed = self._computed.get(name, {})
623
+ if not computed and name not in self._broken_views:
624
+ continue
625
+ # Start from what is on disk (decoded again: rows handed to notes
626
+ # are gone from the copy in memory), keep what still matches.
627
+ stored = {} if name in self._broken_views else self._decode_view(name)
628
+ rows: dict[str, list] = {}
629
+ for key, tag in current.items():
630
+ if key in computed:
631
+ rows[key] = [tag, computed[key]]
632
+ else:
633
+ row = stored.get(key)
634
+ if type(row) is list and len(row) == 2 and row[0] == tag:
635
+ rows[key] = row
636
+ yield self._view_file(name), {"format": FORMAT, "whykit": __version__, "fingerprint": self.fingerprint, "rows": rows}
637
+
638
+ def save(self) -> None:
639
+ """Write the view files this run added to or found damaged."""
640
+ self._write(list(self._view_payloads()))
641
+
642
+ def _write(self, payloads: list[tuple[Path, dict]]) -> None:
643
+ if not payloads:
644
+ return
645
+ try:
646
+ from .io import _acquire_exclusive, _release_exclusive, safe_vault_dir
647
+
648
+ if not _no_symlinks(self.root, CACHE_DIR):
649
+ return
650
+ directory = safe_vault_dir(self.root, CACHE_DIR)
651
+ ignore = directory / ".gitignore"
652
+ if not os.path.lexists(ignore):
653
+ # Older vaults do not ignore the cache; keep it out of Git anyway.
654
+ with contextlib.suppress(FileExistsError):
655
+ fd = _open_nofollow(ignore, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
656
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
657
+ out.write("# WhyKit parse cache: machine state, safe to delete.\n*\n")
658
+ blobs = [
659
+ (target, json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("ascii"))
660
+ for target, payload in payloads
661
+ ]
662
+ except (OSError, RuntimeError, ValueError):
663
+ return
664
+ with contextlib.ExitStack() as stack:
665
+ try:
666
+ handle = stack.enter_context(os.fdopen(_open_nofollow(directory / LOCK_FILE, os.O_RDWR | os.O_CREAT), "r+b"))
667
+ _acquire_exclusive(handle, non_blocking=True)
668
+ stack.callback(_release_exclusive, handle)
669
+ except OSError:
670
+ return # another run is writing the cache; its copy is as good
671
+ if self._vault_busy():
672
+ return
673
+ for target, blob in blobs:
674
+ tmp = directory / f".{target.name}.tmp-{secrets.token_hex(6)}"
675
+ try:
676
+ with open(tmp, "xb") as out:
677
+ out.write(blob)
678
+ out.flush()
679
+ os.fsync(out.fileno())
680
+ os.replace(tmp, target)
681
+ except OSError:
682
+ with contextlib.suppress(OSError):
683
+ tmp.unlink()
684
+ return
685
+
686
+ def _vault_busy(self) -> bool:
687
+ """True while another WhyKit command holds the vault's mutation lock."""
688
+ from .io import _acquire_exclusive, _release_exclusive
689
+
690
+ lock = self.root / ".whykit" / "mutation.lock"
691
+ try:
692
+ handle = os.fdopen(_open_nofollow(lock, os.O_RDWR), "r+b")
693
+ except OSError:
694
+ return False
695
+ with handle:
696
+ try:
697
+ _acquire_exclusive(handle, non_blocking=True)
698
+ except OSError:
699
+ return True
700
+ with contextlib.suppress(OSError):
701
+ _release_exclusive(handle)
702
+ return False
703
+
704
+
705
+ # -- scope -------------------------------------------------------------------
706
+
707
+ class _Scope:
708
+ def __init__(self) -> None:
709
+ self.caches: dict[str, DiskCache | None] = {}
710
+
711
+ def cache_for(self, root: Path) -> DiskCache | None:
712
+ key = os.fspath(root)
713
+ if key not in self.caches:
714
+ try:
715
+ self.caches[key] = DiskCache(root)
716
+ except OSError:
717
+ self.caches[key] = None
718
+ return self.caches[key]
719
+
720
+
721
+ _SCOPE: contextvars.ContextVar[_Scope | None] = contextvars.ContextVar("whykit_parse_cache", default=None)
722
+
723
+
724
+ def disabled_by_environment() -> bool:
725
+ return os.environ.get(ENV_DISABLE, "").strip().lower() not in ("", "0", "false", "no", "off")
726
+
727
+
728
+ def current(root: Path) -> DiskCache | None:
729
+ """The cache for *root* if a :func:`persistent` scope is active."""
730
+ scope = _SCOPE.get()
731
+ return scope.cache_for(root) if scope is not None else None
732
+
733
+
734
+ @contextlib.contextmanager
735
+ def persistent(enabled: bool = True) -> Iterator[None]:
736
+ """Use and update the on-disk parse cache for every vault loaded inside.
737
+
738
+ Front matter and signatures are written when the vault is loaded; views
739
+ computed along the way when the block finishes without an exception.
740
+ """
741
+ if not enabled or disabled_by_environment() or _SCOPE.get() is not None:
742
+ yield
743
+ return
744
+ scope = _Scope()
745
+ token = _SCOPE.set(scope)
746
+ try:
747
+ yield
748
+ finally:
749
+ _SCOPE.reset(token)
750
+ for cache in scope.caches.values():
751
+ if cache is not None:
752
+ cache.save()