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/lsp.py ADDED
@@ -0,0 +1,1241 @@
1
+ """A read-only Language Server for WhyKit vaults (``whykit lsp``).
2
+
3
+ Speaks JSON-RPC 2.0 over stdio with the subset of LSP 3.17 an editor needs to
4
+ show lint findings and navigate a vault:
5
+
6
+ * ``textDocument/publishDiagnostics`` from the same linter as ``whykit lint``,
7
+ run on open, change, save and watched-file events, debounced per vault;
8
+ * completion for ``[[wikilinks]]`` (paths, aliases, decision IDs) and for
9
+ ``E-NNN`` / ``D-NNN`` identifiers;
10
+ * hover and go-to-definition for wikilinks and identifiers;
11
+ * document links for wikilinks and local Markdown links.
12
+
13
+ The server never writes a file. Open documents are linted from the editor's
14
+ buffer, so unsaved edits are checked as typed; everything else is read from
15
+ disk. The evidence register and ``whykit.toml`` are always read from disk by
16
+ the linter, so their findings follow the saved file.
17
+
18
+ Standard library only, like the rest of the core package.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import contextlib
24
+ import datetime as dt
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ import threading
30
+ import traceback
31
+ import unicodedata
32
+ from collections.abc import Callable
33
+ from dataclasses import replace
34
+ from pathlib import Path, PurePosixPath, PureWindowsPath
35
+ from typing import IO, Any
36
+ from urllib.parse import quote, quote_from_bytes, unquote, unquote_to_bytes, urlsplit
37
+
38
+ from . import __version__
39
+ from .lint import (
40
+ CONTENT_SKIP_DIRS,
41
+ DECISION_FILE_RE,
42
+ MARKDOWN_LINK_RE,
43
+ URL_SCHEME_RE,
44
+ WIKILINK_RE,
45
+ Finding,
46
+ Note,
47
+ _build_index,
48
+ _decision_own_id,
49
+ _mask_code,
50
+ _parse_evidence_register_text,
51
+ _real,
52
+ _within,
53
+ collect_markdown,
54
+ evidence_register,
55
+ is_vault_root,
56
+ find_vault_root,
57
+ lint,
58
+ load_note,
59
+ path_cache,
60
+ )
61
+ from .vault_index import NoteCache, VaultIndex
62
+
63
+ JsonObject = dict[str, Any]
64
+ Send = Callable[[JsonObject], None]
65
+
66
+ # JSON-RPC and LSP error codes.
67
+ PARSE_ERROR = -32700
68
+ INVALID_REQUEST = -32600
69
+ METHOD_NOT_FOUND = -32601
70
+ INVALID_PARAMS = -32602
71
+ INTERNAL_ERROR = -32603
72
+ SERVER_NOT_INITIALIZED = -32002
73
+
74
+ MAX_MESSAGE_BYTES = 64 * 1024 * 1024
75
+ COMPLETION_LIMIT = 200
76
+ ENCODINGS = ("utf-16", "utf-8", "utf-32")
77
+ REGISTER = PurePosixPath("00-context/evidence-register.md")
78
+
79
+ ID_RE = re.compile(r"\b[ED]-[0-9]{3,}\b")
80
+ ID_PREFIX_RE = re.compile(r"(?<![\w-])([ED]-[0-9]*)$")
81
+ HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*#*\s*$")
82
+ MESSAGE_LINK_RE = re.compile(r"\[\[(.+?)\]\]")
83
+ MESSAGE_PAREN_RE = re.compile(r"\(([^()]+)\)$")
84
+
85
+ # Findings that depend on files other than the one they are reported on. A
86
+ # lint of only the edited files cannot produce them, so while typing they are
87
+ # carried over from the last whole-vault lint. Keep in step with the checks
88
+ # `lint()` runs only without `paths` (and with decision.duplicate, which needs
89
+ # the other holder of the ID); tests/test_lsp.py holds the two together.
90
+ CROSS_FILE_CODES = frozenset({
91
+ "agents.absent", "agents.unconfigured", "config.invalid", "decision.duplicate", "hub.unlinked_workstream",
92
+ "note.orphan", "path.case_collision", "review_log.date", "review_log.next_review",
93
+ "review_log.outcome", "review_log.reviewer", "review_log.row", "review_log.table",
94
+ "review_log.target", "secret.detected", "secret.scan_skipped_large_file", "secret.scan_unreadable",
95
+ })
96
+
97
+ # LSP enums.
98
+ SEVERITY = {"error": 1, "warning": 2, "info": 3, "information": 3, "hint": 4}
99
+ KIND_FILE, KIND_REFERENCE, KIND_CONSTANT = 17, 18, 21
100
+ TAG_DEPRECATED = 1
101
+
102
+
103
+ class ProtocolError(ValueError):
104
+ """The byte stream is not a valid base-protocol frame."""
105
+
106
+
107
+ class _RequestError(Exception):
108
+ def __init__(self, code: int, message: str) -> None:
109
+ super().__init__(message)
110
+ self.code = code
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # Base protocol: Content-Length framed JSON over a byte stream.
115
+
116
+
117
+ def read_message(stream: IO[bytes]) -> JsonObject | None:
118
+ """Read one framed message; ``None`` at end of input.
119
+
120
+ Raises :class:`ProtocolError` for a header block without a usable
121
+ ``Content-Length`` and :class:`json.JSONDecodeError` for a body that is not
122
+ JSON (the frame has been consumed, so the caller can answer and go on).
123
+ """
124
+ length: int | None = None
125
+ while True:
126
+ line = stream.readline()
127
+ if not line:
128
+ return None
129
+ if line in (b"\r\n", b"\n"):
130
+ if length is None:
131
+ raise ProtocolError("message header has no Content-Length")
132
+ break
133
+ name, _, value = line.decode("ascii", "replace").partition(":")
134
+ if name.strip().lower() == "content-length":
135
+ try:
136
+ length = int(value.strip())
137
+ except ValueError as exc:
138
+ raise ProtocolError(f"bad Content-Length: {value.strip()!r}") from exc
139
+ if length < 0:
140
+ raise ProtocolError("negative Content-Length")
141
+ assert length is not None
142
+ if length > MAX_MESSAGE_BYTES:
143
+ remaining = length
144
+ while remaining > 0:
145
+ chunk = stream.read(min(remaining, 1 << 20))
146
+ if not chunk:
147
+ return None
148
+ remaining -= len(chunk)
149
+ raise ProtocolError(f"message of {length} bytes is over the {MAX_MESSAGE_BYTES}-byte limit")
150
+ body = stream.read(length)
151
+ if len(body) < length:
152
+ return None
153
+ message = json.loads(body.decode("utf-8"))
154
+ if not isinstance(message, dict):
155
+ raise json.JSONDecodeError("a message must be a JSON object", body.decode("utf-8", "replace"), 0)
156
+ return message
157
+
158
+
159
+ def write_message(stream: IO[bytes], message: JsonObject) -> None:
160
+ body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
161
+ stream.write(b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body)
162
+ stream.flush()
163
+
164
+
165
+ # --------------------------------------------------------------------------
166
+ # URIs and positions.
167
+
168
+
169
+ def uri_to_path(uri: str, *, windows: bool | None = None) -> str:
170
+ """The filesystem path of a ``file:`` URI.
171
+
172
+ Accepts both drive spellings editors send (``file:///c%3A/...`` from VS
173
+ Code, ``file:///C:/...`` elsewhere) and UNC shares (``file://host/share``).
174
+ """
175
+ parts = urlsplit(uri)
176
+ if parts.scheme.lower() != "file":
177
+ raise ValueError(f"not a file URI: {uri}")
178
+ windows = os.name == "nt" if windows is None else windows
179
+ if windows:
180
+ path = unquote(parts.path)
181
+ if parts.netloc and parts.netloc.lower() != "localhost":
182
+ return str(PureWindowsPath(f"//{parts.netloc}{path}"))
183
+ if re.match(r"^/[A-Za-z]:", path):
184
+ path = path[1:]
185
+ return str(PureWindowsPath(path))
186
+ return os.fsdecode(unquote_to_bytes(parts.path))
187
+
188
+
189
+ def path_to_uri(path: str | os.PathLike[str], *, windows: bool | None = None) -> str:
190
+ """A ``file:`` URI for an absolute path, percent-encoding everything but ``/``."""
191
+ windows = os.name == "nt" if windows is None else windows
192
+ if windows:
193
+ pure = PureWindowsPath(path)
194
+ drive = pure.drive
195
+ rest = pure.as_posix()[len(drive):]
196
+ if drive.startswith(("\\\\", "//")):
197
+ host, _, share = drive.replace("\\", "/").lstrip("/").partition("/")
198
+ return f"file://{host}/{quote(share)}{quote(rest)}"
199
+ return f"file:///{drive}{quote(rest)}"
200
+ text = os.fspath(PurePosixPath(os.fspath(path)))
201
+ return "file://" + quote_from_bytes(os.fsencode(text), safe="/")
202
+
203
+
204
+ def _units(char: str, encoding: str) -> int:
205
+ if encoding == "utf-16":
206
+ return 2 if ord(char) > 0xFFFF else 1
207
+ if encoding == "utf-8":
208
+ return len(char.encode("utf-8", "surrogatepass"))
209
+ return 1
210
+
211
+
212
+ def to_character(line: str, index: int, encoding: str) -> int:
213
+ """LSP ``character`` of string index *index* in *line*."""
214
+ index = max(0, min(index, len(line)))
215
+ if encoding == "utf-32":
216
+ return index
217
+ return sum(_units(char, encoding) for char in line[:index])
218
+
219
+
220
+ def from_character(line: str, character: int, encoding: str) -> int:
221
+ """String index of LSP ``character`` in *line*, clamped to the line."""
222
+ if encoding == "utf-32":
223
+ return max(0, min(character, len(line)))
224
+ units = 0
225
+ for index, char in enumerate(line):
226
+ if units >= character:
227
+ return index
228
+ units += _units(char, encoding)
229
+ return len(line)
230
+
231
+
232
+ def _split_lines(text: str) -> list[str]:
233
+ return [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
234
+
235
+
236
+ def _offset_at(text: str, position: JsonObject, encoding: str) -> int:
237
+ line_no = int(position.get("line", 0))
238
+ start = 0
239
+ for _ in range(max(0, line_no)):
240
+ found = text.find("\n", start)
241
+ if found < 0:
242
+ return len(text)
243
+ start = found + 1
244
+ end = text.find("\n", start)
245
+ end = len(text) if end < 0 else end
246
+ line = text[start:end].removesuffix("\r")
247
+ return start + from_character(line, int(position.get("character", 0)), encoding)
248
+
249
+
250
+ def apply_change(text: str, change: JsonObject, encoding: str) -> str:
251
+ """Apply one ``contentChanges`` entry (full or ranged) to *text*."""
252
+ new_text = str(change.get("text", ""))
253
+ span = change.get("range")
254
+ if not isinstance(span, dict):
255
+ return new_text
256
+ start = _offset_at(text, span.get("start", {}), encoding)
257
+ end = _offset_at(text, span.get("end", {}), encoding)
258
+ if end < start:
259
+ start, end = end, start
260
+ return text[:start] + new_text + text[end:]
261
+
262
+
263
+ def _key(path: Path) -> str:
264
+ """Identity of a file across spellings: real path, case-folded where the OS is, NFC."""
265
+ return unicodedata.normalize("NFC", os.path.normcase(os.fspath(_real(path))))
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # Server state.
270
+
271
+
272
+ class _Document:
273
+ __slots__ = ("uri", "path", "text", "version", "vault", "_masked")
274
+
275
+ def __init__(self, uri: str, path: Path | None, text: str, version: int | None, vault: _Vault | None) -> None:
276
+ self.uri = uri
277
+ self.path = path
278
+ self.text = text
279
+ self.version = version
280
+ self.vault = vault
281
+ self._masked: tuple[str, list[str], list[str]] | None = None
282
+
283
+ def lines(self) -> tuple[list[str], list[str]]:
284
+ """Raw and code-masked lines of the current text."""
285
+ if self._masked is None or self._masked[0] is not self.text:
286
+ normalized = self.text.replace("\r\n", "\n")
287
+ self._masked = (self.text, _split_lines(normalized), _split_lines(_mask_code(normalized)))
288
+ return self._masked[1], self._masked[2]
289
+
290
+
291
+ class _Vault:
292
+ def __init__(self, root: Path) -> None:
293
+ self.root = root
294
+ self.notes = NoteCache()
295
+ # The last scan of the disk: parsed notes, their real paths and keys.
296
+ self.disk: list[Note] | None = None
297
+ self.disk_by_path: dict[Path, Note] = {}
298
+ self.disk_keys: dict[str, int] = {}
299
+ # Disk plus open buffers, as of the last lint; navigation reads it.
300
+ self.index: VaultIndex | None = None
301
+ self.decisions: dict[str, Note] | None = None
302
+ # Findings of the last whole-vault lint, by file key.
303
+ self.full_findings: dict[str, list[Finding]] | None = None
304
+ self.published: set[str] = set()
305
+ self.full_pending = False
306
+ self.changed: set[str] = set()
307
+ self.timer: threading.Timer | None = None
308
+ self.lint_lock = threading.Lock()
309
+
310
+
311
+ class LanguageServer:
312
+ """Protocol handler. Feed it decoded messages; it calls *send* with replies.
313
+
314
+ *debounce* is in seconds. With ``0`` the lint runs inline, before
315
+ :meth:`handle` returns, which keeps tests deterministic.
316
+ """
317
+
318
+ def __init__(
319
+ self,
320
+ send: Send,
321
+ *,
322
+ root: Path | None = None,
323
+ debounce: float = 0.3,
324
+ today: dt.date | None = None,
325
+ ) -> None:
326
+ self._send = send
327
+ self.root = Path(root).resolve() if root is not None else None
328
+ self.debounce = max(0.0, debounce)
329
+ self.today = today
330
+ self.encoding = "utf-16"
331
+ self.running = True
332
+ self.exit_code: int | None = None
333
+ self.lint_runs = 0
334
+ self._initialized = False
335
+ self._shutdown = False
336
+ self._documents: dict[str, _Document] = {}
337
+ self._vaults: dict[Path, _Vault] = {}
338
+ self._vault_dirs: dict[str, _Vault | None] = {}
339
+ self._workspace: list[_Vault] = []
340
+ self._lock = threading.RLock()
341
+ self._watch_registration = False
342
+
343
+ # ---------------------------------------------------------------- plumbing
344
+
345
+ def send(self, message: JsonObject) -> None:
346
+ self._send({"jsonrpc": "2.0", **message})
347
+
348
+ def notify(self, method: str, params: JsonObject) -> None:
349
+ self.send({"method": method, "params": params})
350
+
351
+ def log(self, text: str, level: int = 3) -> None:
352
+ self.notify("window/logMessage", {"type": level, "message": text})
353
+
354
+ def handle(self, message: JsonObject) -> None:
355
+ method = message.get("method")
356
+ ident = message.get("id")
357
+ is_request = "id" in message
358
+ if not isinstance(method, str):
359
+ if is_request and ("result" in message or "error" in message):
360
+ return # a reply to one of our requests (capability registration)
361
+ self.send({"id": ident if is_request else None, "error": {"code": INVALID_REQUEST, "message": "missing method"}})
362
+ return
363
+ params = message.get("params", {})
364
+ try:
365
+ result = self._dispatch(method, params if params is not None else {}, is_request)
366
+ except _RequestError as exc:
367
+ if is_request:
368
+ self.send({"id": ident, "error": {"code": exc.code, "message": str(exc)}})
369
+ return
370
+ except Exception as exc: # noqa: BLE001 - one bad message must not end the session
371
+ if os.environ.get("WHYKIT_DEBUG"):
372
+ traceback.print_exc(file=sys.stderr)
373
+ text = f"whykit lsp: {method} failed: {type(exc).__name__}: {exc}"
374
+ if is_request:
375
+ self.send({"id": ident, "error": {"code": INTERNAL_ERROR, "message": text}})
376
+ else:
377
+ self.log(text, 1)
378
+ return
379
+ if is_request:
380
+ self.send({"id": ident, "result": result})
381
+
382
+ def _dispatch(self, method: str, params: Any, is_request: bool) -> Any:
383
+ if method == "exit":
384
+ self._stop()
385
+ return None
386
+ if method == "initialize":
387
+ return self._initialize(params)
388
+ if not self._initialized:
389
+ if is_request:
390
+ raise _RequestError(SERVER_NOT_INITIALIZED, "server not initialized")
391
+ return None
392
+ if self._shutdown and is_request:
393
+ raise _RequestError(INVALID_REQUEST, "server is shutting down")
394
+ handler = _HANDLERS.get(method)
395
+ if handler is None:
396
+ if is_request and not method.startswith("$/"):
397
+ raise _RequestError(METHOD_NOT_FOUND, f"method not supported: {method}")
398
+ return None
399
+ if not isinstance(params, dict):
400
+ raise _RequestError(INVALID_PARAMS, "params must be an object")
401
+ if not is_request:
402
+ return handler(self, params)
403
+ with path_cache(fresh=True):
404
+ return handler(self, params)
405
+
406
+ def _stop(self) -> None:
407
+ with self._lock:
408
+ for vault in self._vaults.values():
409
+ if vault.timer is not None:
410
+ vault.timer.cancel()
411
+ self.running = False
412
+ self.exit_code = 0 if self._shutdown else 1
413
+
414
+ # --------------------------------------------------------------- lifecycle
415
+
416
+ def _initialize(self, params: Any) -> JsonObject:
417
+ if not isinstance(params, dict):
418
+ raise _RequestError(INVALID_PARAMS, "params must be an object")
419
+ capabilities = params.get("capabilities") or {}
420
+ offered = ((capabilities.get("general") or {}).get("positionEncodings")) or []
421
+ self.encoding = next((enc for enc in offered if enc in ENCODINGS), "utf-16")
422
+ watched = ((capabilities.get("workspace") or {}).get("didChangeWatchedFiles")) or {}
423
+ self._watch_registration = bool(watched.get("dynamicRegistration"))
424
+ folders: list[str] = []
425
+ for folder in params.get("workspaceFolders") or []:
426
+ if isinstance(folder, dict) and isinstance(folder.get("uri"), str):
427
+ folders.append(folder["uri"])
428
+ if isinstance(params.get("rootUri"), str):
429
+ folders.append(params["rootUri"])
430
+ elif isinstance(params.get("rootPath"), str):
431
+ folders.append(path_to_uri(params["rootPath"]))
432
+ with self._lock:
433
+ if self.root is not None:
434
+ self._workspace = [self._vault_at(self.root)]
435
+ else:
436
+ for uri in folders:
437
+ path = self._path(uri)
438
+ vault = self._vault_containing(path) if path is not None else None
439
+ if vault is not None and vault not in self._workspace:
440
+ self._workspace.append(vault)
441
+ self._initialized = True
442
+ return {
443
+ "capabilities": {
444
+ "positionEncoding": self.encoding,
445
+ "textDocumentSync": {"openClose": True, "change": 2, "save": {"includeText": False}},
446
+ "completionProvider": {"triggerCharacters": ["[", "-"], "resolveProvider": False},
447
+ "hoverProvider": True,
448
+ "definitionProvider": True,
449
+ "documentLinkProvider": {"resolveProvider": False},
450
+ },
451
+ "serverInfo": {"name": "whykit", "version": __version__},
452
+ }
453
+
454
+ def _initialized_notification(self, params: JsonObject) -> None:
455
+ if self._watch_registration:
456
+ self.send({"id": "whykit-watch", "method": "client/registerCapability", "params": {"registrations": [{
457
+ "id": "whykit-watch",
458
+ "method": "workspace/didChangeWatchedFiles",
459
+ "registerOptions": {"watchers": [{"globPattern": "**/*.md"}, {"globPattern": "**/whykit.toml"}]},
460
+ }]}})
461
+ for vault in list(self._workspace):
462
+ self._schedule(vault, full=True)
463
+
464
+ def _shutdown_request(self, params: JsonObject) -> None:
465
+ self._shutdown = True
466
+ with self._lock:
467
+ for vault in self._vaults.values():
468
+ if vault.timer is not None:
469
+ vault.timer.cancel()
470
+
471
+ # ------------------------------------------------------------------ vaults
472
+
473
+ def _path(self, uri: str) -> Path | None:
474
+ try:
475
+ return Path(uri_to_path(uri))
476
+ except ValueError:
477
+ return None
478
+
479
+ def _vault_at(self, root: Path) -> _Vault:
480
+ root = root.resolve()
481
+ vault = self._vaults.get(root)
482
+ if vault is None:
483
+ vault = self._vaults[root] = _Vault(root)
484
+ return vault
485
+
486
+ def _vault_containing(self, path: Path) -> _Vault | None:
487
+ if self.root is not None:
488
+ vault = self._vault_at(self.root)
489
+ return vault if _within(vault.root, path) else None
490
+ start = path if path.is_dir() else path.parent
491
+ key = os.fspath(start)
492
+ if key not in self._vault_dirs:
493
+ found = find_vault_root(start) if start.exists() else None
494
+ self._vault_dirs[key] = self._vault_at(found) if found is not None else None
495
+ discovered = self._vault_dirs[key]
496
+ return discovered if discovered is not None and _within(discovered.root, path) else None
497
+
498
+ def _document(self, params: JsonObject) -> _Document | None:
499
+ """The open document named in *params*, or a read-only view of the file on disk."""
500
+ ident = params.get("textDocument")
501
+ if not isinstance(ident, dict) or not isinstance(ident.get("uri"), str):
502
+ raise _RequestError(INVALID_PARAMS, "textDocument.uri is required")
503
+ uri = ident["uri"]
504
+ with self._lock:
505
+ doc = self._documents.get(uri)
506
+ if doc is not None:
507
+ return None if doc.vault is not None and doc.path is not None and not _within(doc.vault.root, doc.path) else doc
508
+ path = self._path(uri)
509
+ if path is None or not path.is_file():
510
+ return None
511
+ vault = self._vault_containing(path)
512
+ if vault is None:
513
+ return None
514
+ try:
515
+ text = path.read_text(encoding="utf-8")
516
+ except (OSError, UnicodeDecodeError):
517
+ return None
518
+ return _Document(uri, path, text, None, vault)
519
+
520
+ def _position(self, params: JsonObject) -> tuple[int, int]:
521
+ position = params.get("position")
522
+ if not isinstance(position, dict):
523
+ raise _RequestError(INVALID_PARAMS, "position is required")
524
+ line, character = position.get("line"), position.get("character")
525
+ if not isinstance(line, int) or not isinstance(character, int) or line < 0 or character < 0:
526
+ raise _RequestError(INVALID_PARAMS, "position.line and position.character must be non-negative integers")
527
+ return line, character
528
+
529
+ # ------------------------------------------------------- text sync events
530
+
531
+ def _did_open(self, params: JsonObject) -> None:
532
+ item = params.get("textDocument")
533
+ if not isinstance(item, dict) or not isinstance(item.get("uri"), str):
534
+ return
535
+ uri = item["uri"]
536
+ path = self._path(uri)
537
+ with self._lock:
538
+ vault = self._vault_containing(path) if path is not None else None
539
+ self._documents[uri] = _Document(uri, path, str(item.get("text", "")), item.get("version"), vault)
540
+ if vault is not None:
541
+ self._schedule(vault, changed=uri)
542
+
543
+ def _did_change(self, params: JsonObject) -> None:
544
+ ident = params.get("textDocument") or {}
545
+ with self._lock:
546
+ doc = self._documents.get(str(ident.get("uri"))) if isinstance(ident, dict) else None
547
+ if doc is None:
548
+ return
549
+ text = doc.text
550
+ for change in params.get("contentChanges") or []:
551
+ if isinstance(change, dict):
552
+ text = apply_change(text, change, self.encoding)
553
+ doc.text = text
554
+ doc.version = ident.get("version", doc.version)
555
+ vault = doc.vault
556
+ if vault is not None:
557
+ self._schedule(vault, changed=doc.uri)
558
+
559
+ def _did_save(self, params: JsonObject) -> None:
560
+ ident = params.get("textDocument") or {}
561
+ with self._lock:
562
+ doc = self._documents.get(str(ident.get("uri"))) if isinstance(ident, dict) else None
563
+ vault = doc.vault if doc is not None else None
564
+ if vault is not None:
565
+ self._schedule(vault, full=True)
566
+
567
+ def _did_close(self, params: JsonObject) -> None:
568
+ ident = params.get("textDocument") or {}
569
+ with self._lock:
570
+ doc = self._documents.pop(str(ident.get("uri")), None) if isinstance(ident, dict) else None
571
+ if doc is None or doc.vault is None or doc.path is None:
572
+ return
573
+ try:
574
+ unchanged = doc.path.read_text(encoding="utf-8").replace("\r\n", "\n") == doc.text.replace("\r\n", "\n")
575
+ except (OSError, UnicodeDecodeError):
576
+ unchanged = False
577
+ # A closed buffer that matched the file changes nothing; one that did
578
+ # not was being linted in place of the file, so recheck the vault.
579
+ if not unchanged:
580
+ self._schedule(doc.vault, full=True)
581
+
582
+ def _did_change_watched(self, params: JsonObject) -> None:
583
+ touched: list[_Vault] = []
584
+ with self._lock:
585
+ for change in params.get("changes") or []:
586
+ if not isinstance(change, dict) or not isinstance(change.get("uri"), str):
587
+ continue
588
+ path = self._path(change["uri"])
589
+ if path is None:
590
+ continue
591
+ for vault in self._vaults.values():
592
+ if _within(vault.root, path) and vault not in touched:
593
+ touched.append(vault)
594
+ for vault in touched:
595
+ self._schedule(vault, full=True)
596
+
597
+ # ---------------------------------------------------------------- linting
598
+
599
+ def _schedule(self, vault: _Vault, *, full: bool = False, changed: str | None = None) -> None:
600
+ """Queue a lint of *vault*: the whole vault, or only the edited buffers.
601
+
602
+ Typing re-lints only the edited files, which takes a fraction of a
603
+ second at any vault size; opening the workspace, saving, and files
604
+ changing on disk re-lint the whole vault.
605
+ """
606
+ if self._shutdown:
607
+ return
608
+ with self._lock:
609
+ if full or vault.full_findings is None:
610
+ vault.full_pending = True
611
+ if changed is not None:
612
+ vault.changed.add(changed)
613
+ if self.debounce > 0:
614
+ if vault.timer is not None:
615
+ vault.timer.cancel()
616
+ timer = threading.Timer(self.debounce, self._lint_quietly, (vault,))
617
+ timer.daemon = True
618
+ vault.timer = timer
619
+ timer.start()
620
+ return
621
+ self._lint(vault)
622
+
623
+ def _lint_quietly(self, vault: _Vault) -> None:
624
+ try:
625
+ self._lint(vault)
626
+ except Exception as exc: # noqa: BLE001 - a background failure is reported, not raised
627
+ if os.environ.get("WHYKIT_DEBUG"):
628
+ traceback.print_exc(file=sys.stderr)
629
+ self.log(f"whykit lsp: lint failed: {type(exc).__name__}: {exc}", 1)
630
+
631
+ def _overlays(self, vault: _Vault) -> list[tuple[Path, str]]:
632
+ return [(path, text) for path, text, _, _ in self._snapshot(vault)]
633
+
634
+ def _snapshot(self, vault: _Vault) -> list[tuple[Path, str, str, Any]]:
635
+ """Path, text, URI and version of every open Markdown buffer in *vault*, taken atomically."""
636
+ with self._lock:
637
+ return [
638
+ (doc.path, doc.text, doc.uri, doc.version) for doc in self._documents.values()
639
+ if doc.vault is vault and doc.path is not None and doc.path.suffix.lower() == ".md"
640
+ ]
641
+
642
+ def _build_index(self, vault: _Vault, overlays: list[tuple[Path, str]], *, rescan: bool) -> VaultIndex:
643
+ """Disk notes (reusing unchanged parses) with open buffers laid over them.
644
+
645
+ Call inside ``path_cache()``. Without *rescan* the last disk scan is
646
+ reused, so a keystroke does not stat every file of a large vault.
647
+ """
648
+ root = vault.root
649
+ if rescan or vault.disk is None:
650
+ disk = vault.notes.load_all(collect_markdown(root, []))
651
+ vault.disk = disk
652
+ vault.disk_by_path = {_real(note.path): note for note in disk}
653
+ vault.disk_keys = {_key(note.path): number for number, note in enumerate(disk)}
654
+ notes = list(vault.disk)
655
+ by_path = dict(vault.disk_by_path)
656
+ depth = len(root.parts)
657
+ for path, text in overlays:
658
+ number = vault.disk_keys.get(_key(path))
659
+ if number is not None:
660
+ note = load_note(notes[number].path, text=text)
661
+ notes[number] = note
662
+ else:
663
+ real = _real(path)
664
+ if not _within(root, real) or any(part in CONTENT_SKIP_DIRS for part in real.parts[depth:]):
665
+ continue
666
+ note = load_note(real, text=text)
667
+ notes.append(note)
668
+ by_path[_real(note.path)] = note
669
+ return VaultIndex(root=root, notes=notes, by_path=by_path, link_index=_build_index(notes))
670
+
671
+ def _lint(self, vault: _Vault) -> None:
672
+ with vault.lint_lock:
673
+ with self._lock:
674
+ full = vault.full_pending or vault.full_findings is None
675
+ changed = [self._documents[uri] for uri in sorted(vault.changed) if uri in self._documents]
676
+ vault.full_pending = False
677
+ vault.changed = set()
678
+ snapshot = self._snapshot(vault)
679
+ overlays = [(path, text) for path, text, _, _ in snapshot]
680
+ versions = {uri: version for _, _, uri, version in snapshot}
681
+ with path_cache(fresh=True):
682
+ index = self._build_index(vault, overlays, rescan=full)
683
+ paths: list[str] = []
684
+ if not full:
685
+ for doc in changed:
686
+ if doc.path is None or vault.disk_keys.get(_key(doc.path)) is None:
687
+ full = True # a new, unsaved file: only a whole-vault lint sees it
688
+ break
689
+ paths.append(index.relative(doc.path))
690
+ if not full and not paths:
691
+ return
692
+ _, findings = lint(vault.root, paths or None, vault=index, today=self.today)
693
+ with self._lock:
694
+ vault.index = index
695
+ vault.decisions = None
696
+ self.lint_runs += 1
697
+ if full:
698
+ self._publish_full(vault, index, findings, versions)
699
+ else:
700
+ self._publish_scoped(vault, index, changed, findings, versions)
701
+
702
+ def _by_file(self, vault: _Vault, findings: list[Finding]) -> dict[str, tuple[Path, list[Finding]]]:
703
+ grouped: dict[str, tuple[Path, list[Finding]]] = {}
704
+ for finding in findings:
705
+ path = Path(finding.path)
706
+ if not path.is_absolute():
707
+ path = vault.root / PurePosixPath(finding.path)
708
+ grouped.setdefault(_key(path), (path, []))[1].append(finding)
709
+ return grouped
710
+
711
+ def _send_diagnostics(self, uri: str, diagnostics: list[JsonObject], versions: dict[str, Any]) -> None:
712
+ params: JsonObject = {"uri": uri, "diagnostics": diagnostics}
713
+ if isinstance(versions.get(uri), int):
714
+ params["version"] = versions[uri]
715
+ self.notify("textDocument/publishDiagnostics", params)
716
+
717
+ def _publish_full(self, vault: _Vault, index: VaultIndex, findings: list[Finding], versions: dict[str, Any]) -> None:
718
+ with self._lock:
719
+ open_docs = {_key(doc.path): doc for doc in self._documents.values()
720
+ if doc.vault is vault and doc.path is not None}
721
+ grouped = self._by_file(vault, findings)
722
+ vault.full_findings = {key: items for key, (_, items) in grouped.items()}
723
+ outgoing: dict[str, list[JsonObject]] = {}
724
+ for key, (path, items) in grouped.items():
725
+ doc = open_docs.get(key)
726
+ uri = doc.uri if doc is not None else path_to_uri(path)
727
+ # The text that was linted: an open buffer as it was, or the file.
728
+ lines = self._disk_lines(index, path)
729
+ outgoing[uri] = [self._diagnostic(item, lines) for item in items]
730
+ for doc in open_docs.values():
731
+ outgoing.setdefault(doc.uri, [])
732
+ with self._lock:
733
+ stale = vault.published - set(outgoing)
734
+ vault.published = {uri for uri, diagnostics in outgoing.items() if diagnostics}
735
+ for uri in sorted(stale):
736
+ self._send_diagnostics(uri, [], versions)
737
+ for uri, diagnostics in outgoing.items():
738
+ self._send_diagnostics(uri, diagnostics, versions)
739
+
740
+ def _publish_scoped(self, vault: _Vault, index: VaultIndex, docs: list[_Document], findings: list[Finding],
741
+ versions: dict[str, Any]) -> None:
742
+ """Diagnostics for edited buffers: their own findings, fresh, plus the
743
+ cross-file findings of the last whole-vault lint (refreshed on save)."""
744
+ grouped = self._by_file(vault, findings)
745
+ previous = vault.full_findings or {}
746
+ for doc in docs:
747
+ assert doc.path is not None
748
+ key = _key(doc.path)
749
+ items = list(grouped.get(key, (doc.path, []))[1])
750
+ seen = {(item.line, item.code, item.message) for item in items}
751
+ for item in previous.get(key, []):
752
+ if item.code in CROSS_FILE_CODES and (item.line, item.code, item.message) not in seen:
753
+ items.append(item)
754
+ lines = self._disk_lines(index, doc.path)
755
+ diagnostics = [self._diagnostic(item, lines) for item in items]
756
+ with self._lock:
757
+ (vault.published.add if diagnostics else vault.published.discard)(doc.uri)
758
+ self._send_diagnostics(doc.uri, diagnostics, versions)
759
+
760
+ def _disk_lines(self, index: VaultIndex, path: Path) -> list[str]:
761
+ note = index.note_for(path)
762
+ if note is not None:
763
+ return _split_lines(note.text)
764
+ try:
765
+ with open(path, encoding="utf-8", errors="replace") as handle:
766
+ return _split_lines(handle.read(4 * 1024 * 1024))
767
+ except OSError:
768
+ return []
769
+
770
+ def _diagnostic(self, finding: Finding, lines: list[str]) -> JsonObject:
771
+ line_no = max(0, (finding.line or 1) - 1)
772
+ text = lines[line_no] if line_no < len(lines) else ""
773
+ start, end = _finding_span(text, finding)
774
+ return {
775
+ "range": self._range(line_no, text, start, end),
776
+ "severity": SEVERITY.get(finding.level, 3),
777
+ "code": finding.code,
778
+ "source": "whykit",
779
+ "message": finding.message,
780
+ }
781
+
782
+ def _range(self, line_no: int, text: str, start: int, end: int) -> JsonObject:
783
+ return {
784
+ "start": {"line": line_no, "character": to_character(text, start, self.encoding)},
785
+ "end": {"line": line_no, "character": to_character(text, end, self.encoding)},
786
+ }
787
+
788
+ # ------------------------------------------------------------- navigation
789
+
790
+ def _index(self, vault: _Vault) -> VaultIndex:
791
+ """The index of the last lint, or a fresh one when there was none yet."""
792
+ with self._lock:
793
+ index = vault.index
794
+ if index is None:
795
+ with path_cache():
796
+ index = self._build_index(vault, self._overlays(vault), rescan=False)
797
+ with self._lock:
798
+ vault.index = index
799
+ vault.decisions = None
800
+ return replace(index, _resolved={}, _relative={}, derived={})
801
+
802
+ def _decisions(self, vault: _Vault, index: VaultIndex) -> dict[str, Note]:
803
+ with self._lock:
804
+ if vault.decisions is not None and vault.index is index:
805
+ return vault.decisions
806
+ found: dict[str, Note] = {}
807
+ for note in index.notes:
808
+ if not _within(vault.root, note.path):
809
+ continue
810
+ if str(note.front.get("status", "")).strip() == "template":
811
+ continue
812
+ explicit = str(note.front.get("decision_id") or "").strip()
813
+ did = explicit if re.fullmatch(r"D-[0-9]{3,}", explicit) else None
814
+ if did is None and "06-decisions" in note.path.parts and DECISION_FILE_RE.match(note.path.name):
815
+ did = _decision_own_id(note)
816
+ if did is not None:
817
+ found.setdefault(did, note)
818
+ with self._lock:
819
+ if vault.index is index:
820
+ vault.decisions = found
821
+ return found
822
+
823
+ def _evidence(self, vault: _Vault) -> tuple[dict[str, dict[str, str]], dict[str, dict[str, str]]]:
824
+ register = vault.root / REGISTER
825
+ if not _within(vault.root, register):
826
+ return {}, {}
827
+ with self._lock:
828
+ open_text = next((doc.text for doc in self._documents.values()
829
+ if doc.path is not None and doc.vault is vault and _key(doc.path) == _key(register)), None)
830
+ if open_text is not None:
831
+ active, retired, _ = _parse_evidence_register_text(open_text.replace("\r\n", "\n"))
832
+ return active, retired
833
+ active, retired, _ = evidence_register(vault.root)
834
+ return active, retired
835
+
836
+ def _uri_for(self, path: Path) -> str:
837
+ key = _key(path)
838
+ with self._lock:
839
+ for doc in self._documents.values():
840
+ if doc.path is not None and _key(doc.path) == key:
841
+ return doc.uri
842
+ return path_to_uri(path)
843
+
844
+ def _location(self, path: Path, line: int = 0) -> JsonObject:
845
+ position = {"line": line, "character": 0}
846
+ return {"uri": self._uri_for(path), "range": {"start": position, "end": position}}
847
+
848
+ def _at(self, params: JsonObject) -> tuple[_Document, VaultIndex, int, str, str, int] | None:
849
+ """Document, index, line number, raw and masked line, string index under the cursor."""
850
+ doc = self._document(params)
851
+ line_no, character = self._position(params)
852
+ if doc is None or doc.vault is None:
853
+ return None
854
+ raw, masked = doc.lines()
855
+ if line_no >= len(raw):
856
+ return None
857
+ index = self._index(doc.vault)
858
+ return doc, index, line_no, raw[line_no], masked[line_no], from_character(raw[line_no], character, self.encoding)
859
+
860
+ def _token(self, masked: str, at: int) -> tuple[str, re.Match[str]] | None:
861
+ for match in WIKILINK_RE.finditer(masked):
862
+ if match.start() <= at <= match.end():
863
+ return "link", match
864
+ for match in ID_RE.finditer(masked):
865
+ if match.start() <= at <= match.end():
866
+ return "id", match
867
+ return None
868
+
869
+ def _resolve_link(self, index: VaultIndex, target: str) -> Path | None:
870
+ path, ambiguous = index.resolve_link(target.strip())
871
+ return None if ambiguous or path is not None and not _within(index.root, path) else path
872
+
873
+ def _hover(self, params: JsonObject) -> JsonObject | None:
874
+ found = self._at(params)
875
+ if found is None:
876
+ return None
877
+ doc, index, line_no, raw, masked, at = found
878
+ assert doc.vault is not None
879
+ token = self._token(masked, at)
880
+ if token is None:
881
+ return None
882
+ kind, match = token
883
+ if kind == "link":
884
+ path = self._resolve_link(index, match.group(1))
885
+ note = index.note_for(path) if path is not None else None
886
+ if note is None:
887
+ value = f"`[[{match.group(1).strip()}]]` does not resolve to a note in this vault"
888
+ else:
889
+ value = _note_card(index, note)
890
+ else:
891
+ value = self._id_card(doc.vault, index, match.group(0))
892
+ return {"contents": {"kind": "markdown", "value": value},
893
+ "range": self._range(line_no, raw, match.start(), match.end())}
894
+
895
+ def _id_card(self, vault: _Vault, index: VaultIndex, ident: str) -> str:
896
+ if ident.startswith("E-"):
897
+ active, retired = self._evidence(vault)
898
+ if ident in active:
899
+ row = active[ident]
900
+ lines = [f"**{ident}** · active evidence", "", f"**Source:** {row['source']}"]
901
+ for label, key in (("Type", "type"), ("Date", "date"), ("Accessed", "accessed"),
902
+ ("Location", "location"), ("Supports", "claims")):
903
+ if row.get(key):
904
+ lines.append(f"**{label}:** {row[key]}")
905
+ return " \n".join(lines)
906
+ if ident in retired:
907
+ row = retired[ident]
908
+ lines = [f"**{ident}** · retired evidence", "", f"**Source:** {row['source']}"]
909
+ for label, key in (("Retired", "retired_on"), ("Why", "why"), ("Replaced by", "replaced_by")):
910
+ if row.get(key):
911
+ lines.append(f"**{label}:** {row[key]}")
912
+ return " \n".join(lines)
913
+ return f"**{ident}** is not in the evidence register"
914
+ note = self._decisions(vault, index).get(ident)
915
+ if note is None:
916
+ return f"No decision record has the ID **{ident}**"
917
+ return _note_card(index, note)
918
+
919
+ def _definition(self, params: JsonObject) -> JsonObject | None:
920
+ found = self._at(params)
921
+ if found is None:
922
+ return None
923
+ doc, index, _, raw, masked, at = found
924
+ assert doc.vault is not None
925
+ token = self._token(masked, at)
926
+ if token is None:
927
+ return None
928
+ kind, match = token
929
+ if kind == "link":
930
+ target = match.group(1)
931
+ path = self._resolve_link(index, target)
932
+ if path is None:
933
+ attachment = self._attachment(doc, target)
934
+ return self._location(attachment) if attachment is not None else None
935
+ note = index.note_for(path)
936
+ heading = _link_heading(raw[match.start():match.end()])
937
+ line = _heading_line(note.text, heading) if note is not None and heading else 0
938
+ return self._location(path, line)
939
+ ident = match.group(0)
940
+ if ident.startswith("E-"):
941
+ active, retired = self._evidence(doc.vault)
942
+ row = active.get(ident) or retired.get(ident)
943
+ if row is None:
944
+ return None
945
+ return self._location(doc.vault.root / REGISTER, max(0, int(row.get("line", "1")) - 1))
946
+ note = self._decisions(doc.vault, index).get(ident)
947
+ return self._location(note.path) if note is not None else None
948
+
949
+ def _attachment(self, doc: _Document, target: str) -> Path | None:
950
+ assert doc.vault is not None
951
+ normalized = target.strip().replace("\\", "/")
952
+ if not normalized or normalized.lower().endswith(".md"):
953
+ return None
954
+ candidates = [doc.vault.root / normalized.lstrip("/")]
955
+ if doc.path is not None:
956
+ candidates.append(doc.path.parent / normalized)
957
+ for candidate in candidates:
958
+ if candidate.is_file() and _within(doc.vault.root, candidate):
959
+ return candidate
960
+ return None
961
+
962
+ def _document_links(self, params: JsonObject) -> list[JsonObject]:
963
+ doc = self._document(params)
964
+ if doc is None or doc.vault is None:
965
+ return []
966
+ index = self._index(doc.vault)
967
+ raw, masked = doc.lines()
968
+ links: list[JsonObject] = []
969
+ for line_no, (line, hidden) in enumerate(zip(raw, masked, strict=True)):
970
+ if "[" not in hidden:
971
+ continue
972
+ for match in WIKILINK_RE.finditer(hidden):
973
+ target = match.group(1)
974
+ if not target.strip() or URL_SCHEME_RE.match(target.strip()):
975
+ continue
976
+ path = self._resolve_link(index, target)
977
+ if path is None:
978
+ path = self._attachment(doc, target)
979
+ if path is not None:
980
+ links.append({"range": self._range(line_no, line, match.start(1), match.end(1)),
981
+ "target": self._uri_for(path)})
982
+ for match in MARKDOWN_LINK_RE.finditer(hidden):
983
+ target = match.group(2).strip()
984
+ if target.startswith("<") and ">" in target:
985
+ target = target[1:target.index(">")]
986
+ else:
987
+ target = target.split(None, 1)[0] if target else ""
988
+ if not target or target.startswith("#") or URL_SCHEME_RE.match(target):
989
+ continue
990
+ local = unquote(target.split("#", 1)[0].split("?", 1)[0])
991
+ if not local:
992
+ continue
993
+ base = doc.vault.root if local.startswith("/") else (doc.path.parent if doc.path else doc.vault.root)
994
+ candidate = base / local.lstrip("/")
995
+ if candidate.exists() and _within(doc.vault.root, candidate):
996
+ start = match.start(2) + match.group(2).index(target) if target in match.group(2) else match.start(2)
997
+ links.append({"range": self._range(line_no, line, start, start + len(target)),
998
+ "target": self._uri_for(candidate)})
999
+ return links
1000
+
1001
+ def _completion(self, params: JsonObject) -> JsonObject:
1002
+ empty = {"isIncomplete": False, "items": []}
1003
+ found = self._at(params)
1004
+ if found is None:
1005
+ return empty
1006
+ doc, index, line_no, raw, _, at = found
1007
+ assert doc.vault is not None
1008
+ before = raw[:at]
1009
+ opening = before.rfind("[[")
1010
+ if opening >= 0 and "]]" not in before[opening:]:
1011
+ typed = before[opening + 2:]
1012
+ if "|" in typed or "#" in typed:
1013
+ return empty
1014
+ return self._link_items(doc.vault, index, line_no, raw, opening + 2, at, typed)
1015
+ match = ID_PREFIX_RE.search(before)
1016
+ if match is None:
1017
+ return empty
1018
+ return self._id_items(doc.vault, index, line_no, raw, match.start(1), at, match.group(1))
1019
+
1020
+ def _link_items(self, vault: _Vault, index: VaultIndex, line_no: int, raw: str,
1021
+ start: int, end: int, typed: str) -> JsonObject:
1022
+ needle = typed.strip().casefold()
1023
+ closing = "" if raw[end:].startswith(("]]", "|", "#")) else "]]"
1024
+ edit_range = self._range(line_no, raw, start, end)
1025
+ candidates: list[JsonObject] = []
1026
+
1027
+ def unique(name: str, note: Note) -> bool:
1028
+ hits = index.link_index.get(unicodedata.normalize("NFC", name).casefold(), set())
1029
+ return hits == {note.path}
1030
+
1031
+ def shortest(note: Note) -> str:
1032
+ relative = index.relative(note.path).removesuffix(".md")
1033
+ return note.path.stem if unique(note.path.stem, note) else relative
1034
+
1035
+ def add(label: str, insert: str, kind: int, detail: str, filter_text: str, sort: str) -> None:
1036
+ if needle and needle not in filter_text.casefold():
1037
+ return
1038
+ candidates.append({
1039
+ "label": label, "kind": kind, "detail": detail, "filterText": filter_text,
1040
+ "sortText": sort, "textEdit": {"range": edit_range, "newText": insert + closing},
1041
+ })
1042
+
1043
+ decisions = self._decisions(vault, index)
1044
+ for did, note in sorted(decisions.items()):
1045
+ title = str(note.front.get("title") or note.path.stem)
1046
+ insert = did if unique(did, note) else shortest(note)
1047
+ add(did, insert, KIND_REFERENCE, f"{title} · {note.front.get('status') or 'no status'}", f"{did} {title}", f"0{did}")
1048
+ for note in index.notes:
1049
+ if not _within(vault.root, note.path):
1050
+ continue
1051
+ if str(note.front.get("status", "")).strip() == "template":
1052
+ continue
1053
+ relative = index.relative(note.path).removesuffix(".md")
1054
+ title = str(note.front.get("title") or note.path.stem)
1055
+ add(relative, shortest(note), KIND_FILE, title, relative, f"1{relative}")
1056
+ aliases = note.front.get("aliases")
1057
+ for alias in aliases if isinstance(aliases, list) else []:
1058
+ alias = str(alias).strip()
1059
+ if alias and alias not in decisions and unique(alias, note):
1060
+ add(alias, alias, KIND_REFERENCE, f"alias of {relative}", alias, f"2{alias}")
1061
+ candidates.sort(key=lambda item: item["sortText"])
1062
+ return {"isIncomplete": len(candidates) > COMPLETION_LIMIT, "items": candidates[:COMPLETION_LIMIT]}
1063
+
1064
+ def _id_items(self, vault: _Vault, index: VaultIndex, line_no: int, raw: str,
1065
+ start: int, end: int, typed: str) -> JsonObject:
1066
+ edit_range = self._range(line_no, raw, start, end)
1067
+ items: list[JsonObject] = []
1068
+ if typed.startswith("E"):
1069
+ active, retired = self._evidence(vault)
1070
+ for ident, row in sorted({**retired, **active}.items()):
1071
+ if not ident.startswith(typed):
1072
+ continue
1073
+ item: JsonObject = {
1074
+ "label": ident, "kind": KIND_CONSTANT, "detail": row.get("source", ""),
1075
+ "documentation": row.get("claims") or row.get("why") or "",
1076
+ "textEdit": {"range": edit_range, "newText": ident},
1077
+ }
1078
+ if ident not in active:
1079
+ item["tags"] = [TAG_DEPRECATED]
1080
+ item["detail"] = f"{row.get('source', '')} (retired)"
1081
+ items.append(item)
1082
+ else:
1083
+ for ident, note in sorted(self._decisions(vault, index).items()):
1084
+ if ident.startswith(typed):
1085
+ title = str(note.front.get("title") or note.path.stem)
1086
+ items.append({
1087
+ "label": ident, "kind": KIND_REFERENCE,
1088
+ "detail": f"{title} · {note.front.get('status') or 'no status'}",
1089
+ "textEdit": {"range": edit_range, "newText": ident},
1090
+ })
1091
+ return {"isIncomplete": len(items) > COMPLETION_LIMIT, "items": items[:COMPLETION_LIMIT]}
1092
+
1093
+
1094
+ _HANDLERS: dict[str, Callable[[LanguageServer, JsonObject], Any]] = {
1095
+ "initialized": LanguageServer._initialized_notification,
1096
+ "shutdown": LanguageServer._shutdown_request,
1097
+ "textDocument/didOpen": LanguageServer._did_open,
1098
+ "textDocument/didChange": LanguageServer._did_change,
1099
+ "textDocument/didSave": LanguageServer._did_save,
1100
+ "textDocument/didClose": LanguageServer._did_close,
1101
+ "workspace/didChangeWatchedFiles": LanguageServer._did_change_watched,
1102
+ "textDocument/hover": LanguageServer._hover,
1103
+ "textDocument/definition": LanguageServer._definition,
1104
+ "textDocument/completion": LanguageServer._completion,
1105
+ "textDocument/documentLink": LanguageServer._document_links,
1106
+ }
1107
+
1108
+
1109
+ # --------------------------------------------------------------------------
1110
+ # Helpers.
1111
+
1112
+
1113
+ def _finding_span(text: str, finding: Finding) -> tuple[int, int]:
1114
+ """Columns of the part of *text* a finding is about; the whole line otherwise."""
1115
+ link = MESSAGE_LINK_RE.search(finding.message)
1116
+ if link is not None:
1117
+ at = text.find("[[" + link.group(1))
1118
+ if at >= 0:
1119
+ close = text.find("]]", at)
1120
+ return at, (close + 2 if close >= 0 else at + len(link.group(1)) + 2)
1121
+ paren = MESSAGE_PAREN_RE.search(finding.message)
1122
+ if paren is not None and finding.code.startswith("markdown_link"):
1123
+ at = text.find(paren.group(1))
1124
+ if at >= 0:
1125
+ return at, at + len(paren.group(1))
1126
+ for ident in ID_RE.findall(finding.message):
1127
+ match = re.search(rf"\b{re.escape(ident)}\b", text)
1128
+ if match is not None:
1129
+ return match.start(), match.end()
1130
+ stripped = text.rstrip()
1131
+ start = len(stripped) - len(stripped.lstrip())
1132
+ return start, len(stripped)
1133
+
1134
+
1135
+ def _note_card(index: VaultIndex, note: Note) -> str:
1136
+ front = note.front
1137
+ title = str(front.get("title") or note.path.stem)
1138
+ lines = [f"**{title}**", "", f"`{index.relative(note.path)}`", ""]
1139
+ for label, key in (("Type", "type"), ("Status", "status"), ("Owner", "owner"),
1140
+ ("Review by", "review_by"), ("Decision", "decision_id"),
1141
+ ("Supersedes", "supersedes"), ("Superseded by", "superseded_by")):
1142
+ value = front.get(key)
1143
+ if value not in (None, "", []):
1144
+ lines.append(f"**{label}:** {', '.join(map(str, value)) if isinstance(value, list) else value}")
1145
+ sources = front.get("source_ids")
1146
+ if isinstance(sources, list) and sources:
1147
+ lines.append(f"**Evidence:** {', '.join(map(str, sources))}")
1148
+ return " \n".join(lines)
1149
+
1150
+
1151
+ def _link_heading(link_text: str) -> str | None:
1152
+ inner = link_text.strip("![]")
1153
+ inner = inner.split("|", 1)[0]
1154
+ if "#" not in inner:
1155
+ return None
1156
+ heading = inner.split("#", 1)[1].strip()
1157
+ return heading or None
1158
+
1159
+
1160
+ def _heading_line(text: str, heading: str) -> int:
1161
+ wanted = heading.casefold()
1162
+ for number, line in enumerate(text.split("\n")):
1163
+ match = HEADING_RE.match(line)
1164
+ if match is not None and match.group(1).casefold() == wanted:
1165
+ return number
1166
+ return 0
1167
+
1168
+
1169
+ # --------------------------------------------------------------------------
1170
+ # Entry points.
1171
+
1172
+
1173
+ def serve(
1174
+ stdin: IO[bytes],
1175
+ stdout: IO[bytes],
1176
+ *,
1177
+ root: Path | None = None,
1178
+ debounce: float = 0.3,
1179
+ today: dt.date | None = None,
1180
+ ) -> int:
1181
+ """Run the server until ``exit`` or end of input; return the exit code."""
1182
+ write_lock = threading.Lock()
1183
+
1184
+ def send(message: JsonObject) -> None:
1185
+ with write_lock:
1186
+ write_message(stdout, message)
1187
+
1188
+ server = LanguageServer(send, root=root, debounce=debounce, today=today)
1189
+ while server.running:
1190
+ try:
1191
+ message = read_message(stdin)
1192
+ except (ProtocolError, ValueError) as exc:
1193
+ # json.JSONDecodeError and UnicodeDecodeError are ValueErrors too.
1194
+ send({"jsonrpc": "2.0", "id": None, "error": {"code": PARSE_ERROR, "message": str(exc)}})
1195
+ continue
1196
+ if message is None:
1197
+ server._stop()
1198
+ return 0 if server.exit_code == 0 else 1
1199
+ server.handle(message)
1200
+ return server.exit_code if server.exit_code is not None else 1
1201
+
1202
+
1203
+ def build_parser() -> argparse.ArgumentParser:
1204
+ parser = argparse.ArgumentParser(
1205
+ prog="whykit lsp",
1206
+ description="Run the read-only WhyKit language server over stdio.",
1207
+ )
1208
+ parser.add_argument("--root", help="serve only this vault (default: the vault around each open file)")
1209
+ parser.add_argument("--debounce", type=int, default=300, help="milliseconds to wait after an edit before linting (default: %(default)s)")
1210
+ parser.add_argument("--today", help="evaluate review dates as of this YYYY-MM-DD date instead of today")
1211
+ parser.add_argument("--stdio", action="store_true", help="accepted for editor clients that pass it; stdio is the only transport")
1212
+ return parser
1213
+
1214
+
1215
+ def main(argv: list[str] | None = None) -> int:
1216
+ from .contract import emit_error, vault_not_found
1217
+
1218
+ args = build_parser().parse_args(argv)
1219
+ today = None
1220
+ if args.today:
1221
+ try:
1222
+ today = dt.date.fromisoformat(args.today)
1223
+ except ValueError:
1224
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=False)
1225
+ if args.debounce < 0:
1226
+ return emit_error("invalid_argument", "--debounce must be >= 0", json_mode=False)
1227
+ root = None
1228
+ if args.root:
1229
+ root = Path(args.root).expanduser().resolve()
1230
+ if not is_vault_root(root):
1231
+ return vault_not_found(args.root, json_mode=False)
1232
+ stdin, stdout = sys.stdin.buffer, sys.stdout.buffer
1233
+ # The protocol owns stdout: anything else printed there would corrupt a frame.
1234
+ with contextlib.redirect_stdout(sys.stderr):
1235
+ return serve(stdin, stdout, root=root, debounce=args.debounce / 1000, today=today)
1236
+
1237
+
1238
+ __all__ = [
1239
+ "LanguageServer", "ProtocolError", "apply_change", "from_character", "main", "path_to_uri",
1240
+ "read_message", "serve", "to_character", "uri_to_path", "write_message",
1241
+ ]