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
@@ -0,0 +1,43 @@
1
+ ---
2
+ title: Source ingestion batch XXX
3
+ aliases: []
4
+ type: research
5
+ status: template
6
+ owner: TODO
7
+ created: YYYY-MM-DD
8
+ last_updated: YYYY-MM-DD
9
+ source_of_truth: false
10
+ sensitivity: public
11
+ source_ids: []
12
+ tags: []
13
+ ---
14
+
15
+ # Source ingestion batch XXX
16
+
17
+ ## Purpose
18
+
19
+ Record how one batch of uploaded material was assessed and normalized.
20
+
21
+ ## Results
22
+
23
+ | Uploaded file | SHA-256 | Assessment | Evidence ID | Destination in repository |
24
+ |---|---|---|---|---|
25
+ | | | useful / duplicate / empty / unsupported / missing attachment | | |
26
+
27
+ ## Normalization performed
28
+
29
+ - [ ] Platform-specific markup removed.
30
+ - [ ] YAML front matter added.
31
+ - [ ] Aliases, tags, source IDs and links to related notes added.
32
+ - [ ] Facts, hypotheses, recommendations and decisions separated.
33
+ - [ ] Missing assets recorded.
34
+ - [ ] Skipped, empty and duplicate files recorded.
35
+ - [ ] Verification questions and review requirements added.
36
+
37
+ ## Missing assets
38
+
39
+ - None recorded.
40
+
41
+ ## Review required
42
+
43
+ -
@@ -0,0 +1,39 @@
1
+ # Versioned repository-local policy. WhyKit has no hidden user-global config.
2
+ format_version = 1
3
+
4
+ [defaults]
5
+ owner = "TODO"
6
+ sensitivity = "internal"
7
+ decision_review_days = 90
8
+ status_due_days = 30
9
+ require_hub_links = false
10
+
11
+ [profiles.local]
12
+ strict = false
13
+ orphans = true
14
+ secrets = true
15
+ require_git = false
16
+ require_clean_tree = false
17
+ require_configured = false
18
+ require_hub_links = false
19
+ history = "optional"
20
+
21
+ [profiles.ci]
22
+ strict = true
23
+ orphans = true
24
+ secrets = true
25
+ require_git = false
26
+ require_clean_tree = false
27
+ require_configured = false
28
+ require_hub_links = false
29
+ history = "optional"
30
+
31
+ [profiles.release]
32
+ strict = true
33
+ orphans = true
34
+ secrets = true
35
+ require_git = true
36
+ require_clean_tree = true
37
+ require_configured = true
38
+ require_hub_links = false
39
+ history = "required"
whykit/trace.py ADDED
@@ -0,0 +1,272 @@
1
+ """Decision-to-evidence traceability.
2
+
3
+ `whykit impact` answers "what breaks if I change this?" for one record. This
4
+ module answers the ledger-wide question in the other direction: for every
5
+ decision, which evidence does it rest on, and is that evidence still usable?
6
+
7
+ A decision can cite evidence directly (an ``E-NNN`` in its text or
8
+ ``source_ids``) or inherit it from a non-decision document it links to, such as
9
+ a research note. Both are reported; direct citations win when the same ID
10
+ appears twice.
11
+ Evidence is unusable when it is retired, missing from the register, or older
12
+ than the vault's ``[evidence_access_age_days]`` policy for its source type.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .io import consistent_read
17
+
18
+ import argparse
19
+ import datetime as dt
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ from .config import ConfigError, load_config
25
+ from .contract import emit_error
26
+ from .graph import build_graph
27
+ from .lint import DECISION_ID_RE, EVIDENCE_ID_RE, _parse_date, evidence_register, find_vault_root, is_vault_root, path_cache, strip_markdown_suffix
28
+ from .vault_index import VaultIndex
29
+ from .console import emit_machine
30
+
31
+ GAP_KINDS = ("no_evidence", "missing_evidence", "retired_evidence", "stale_evidence")
32
+
33
+
34
+ def _evidence_state(
35
+ evidence_id: str,
36
+ active: dict[str, dict[str, str]],
37
+ retired: dict[str, dict[str, str]],
38
+ access_age_days: dict[str, int],
39
+ default_max_age: int | None,
40
+ today: dt.date,
41
+ ) -> dict:
42
+ if evidence_id in active:
43
+ row = active[evidence_id]
44
+ max_age = access_age_days.get(row.get("type", ""), default_max_age)
45
+ accessed = _parse_date(row.get("accessed", ""))
46
+ age = (today - accessed).days if accessed is not None else None
47
+ # Same rule as the `evidence.access_stale` lint: older than the window,
48
+ # never in the future, and an unparseable date is reported by lint.
49
+ stale = max_age is not None and age is not None and age > max_age
50
+ return {
51
+ "state": "active",
52
+ "source": row.get("source", ""),
53
+ "type": row.get("type", ""),
54
+ "accessed": row.get("accessed", "") or None,
55
+ "age_days": age,
56
+ "max_age_days": max_age,
57
+ "stale": stale,
58
+ "replaced_by": None,
59
+ }
60
+ if evidence_id in retired:
61
+ row = retired[evidence_id]
62
+ replacement = row.get("replaced_by", "").strip()
63
+ return {
64
+ "state": "retired",
65
+ "source": row.get("source", ""),
66
+ "type": "",
67
+ "accessed": None,
68
+ "age_days": None,
69
+ "max_age_days": None,
70
+ "stale": False,
71
+ "replaced_by": replacement if EVIDENCE_ID_RE.fullmatch(replacement) else None,
72
+ }
73
+ return {
74
+ "state": "missing",
75
+ "source": "",
76
+ "type": "",
77
+ "accessed": None,
78
+ "age_days": None,
79
+ "max_age_days": None,
80
+ "stale": False,
81
+ "replaced_by": None,
82
+ }
83
+
84
+
85
+ @consistent_read
86
+ @path_cache()
87
+ def build_trace(
88
+ root: Path,
89
+ *,
90
+ today: dt.date | None = None,
91
+ decision: str | None = None,
92
+ default_max_age: int | None = None,
93
+ access_age_days: dict[str, int] | None = None,
94
+ vault: VaultIndex | None = None,
95
+ ) -> dict:
96
+ """Return a traceability report for every decision (or one, by ``D-NNN``)."""
97
+ today = today or dt.date.today()
98
+ root = root.resolve()
99
+ vault_index = vault or VaultIndex.load(root)
100
+ if access_age_days is None:
101
+ try:
102
+ access_age_days = dict(load_config(root)[0]["evidence_access_age_days"])
103
+ except ConfigError:
104
+ access_age_days = {}
105
+ active, retired, _ = evidence_register(root)
106
+ graph = build_graph(root, vault=vault_index)
107
+
108
+ cites: dict[str, set[str]] = {}
109
+ links: dict[str, set[str]] = {}
110
+ for edge in graph["edges"]:
111
+ if edge["type"] == "evidence":
112
+ cites.setdefault(edge["from"], set()).add(edge["to"].split(":", 1)[1])
113
+ elif edge["type"] == "wikilink":
114
+ links.setdefault(edge["from"], set()).add(edge["to"])
115
+
116
+ superseded_by: dict[str, list[str]] = {}
117
+ decision_notes = []
118
+ for note in vault_index.notes:
119
+ own = str(note.front.get("decision_id") or "").strip()
120
+ if not DECISION_ID_RE.fullmatch(own):
121
+ continue
122
+ decision_notes.append((own, note))
123
+ replaced = str(note.front.get("supersedes") or "").strip()
124
+ if DECISION_ID_RE.fullmatch(replaced):
125
+ superseded_by.setdefault(replaced, []).append(own)
126
+
127
+ decision_nodes = {strip_markdown_suffix(vault_index.relative(note.path)) for _, note in decision_notes}
128
+ states: dict[str, dict] = {}
129
+
130
+ def state_of(evidence_id: str) -> dict:
131
+ if evidence_id not in states:
132
+ states[evidence_id] = _evidence_state(evidence_id, active, retired, access_age_days, default_max_age, today)
133
+ return states[evidence_id]
134
+
135
+ records: list[dict] = []
136
+ for own, note in sorted(decision_notes, key=lambda item: (item[0], vault_index.relative(item[1].path))):
137
+ if decision and own != decision:
138
+ continue
139
+ node = strip_markdown_suffix(vault_index.relative(note.path))
140
+ evidence: dict[str, dict] = {}
141
+ for evidence_id in sorted(cites.get(node, ())):
142
+ evidence[evidence_id] = {"id": evidence_id, "via": None, **state_of(evidence_id)}
143
+ for linked in sorted(links.get(node, ())):
144
+ # Another decision's evidence is that decision's case, not this
145
+ # one's; inheriting it would let a successor hide behind the very
146
+ # record it superseded.
147
+ if linked == node or linked in decision_nodes:
148
+ continue
149
+ for evidence_id in sorted(cites.get(linked, ())):
150
+ if evidence_id not in evidence:
151
+ evidence[evidence_id] = {"id": evidence_id, "via": linked, **state_of(evidence_id)}
152
+ items = [evidence[key] for key in sorted(evidence)]
153
+ gaps: list[str] = []
154
+ if not items:
155
+ gaps.append("no_evidence")
156
+ if any(item["state"] == "missing" for item in items):
157
+ gaps.append("missing_evidence")
158
+ if any(item["state"] == "retired" for item in items):
159
+ gaps.append("retired_evidence")
160
+ if any(item["stale"] for item in items):
161
+ gaps.append("stale_evidence")
162
+ status = str(note.front.get("status") or "")
163
+ successors = sorted(superseded_by.get(own, []))
164
+ records.append({
165
+ "decision_id": own,
166
+ "path": vault_index.relative(note.path),
167
+ "title": str(note.front.get("title") or note.path.stem),
168
+ "status": status,
169
+ "owner": str(note.front.get("owner") or ""),
170
+ "sensitivity": str(note.front.get("sensitivity") or "internal"),
171
+ "superseded_by": successors,
172
+ # Only an approved decision nobody has superseded is still binding;
173
+ # historical records may legitimately cite since-retired evidence.
174
+ "live": status == "approved" and not successors,
175
+ "evidence": items,
176
+ "gaps": gaps,
177
+ })
178
+
179
+ live = [record for record in records if record["live"]]
180
+ gap_counts = {kind: sum(1 for record in live if kind in record["gaps"]) for kind in GAP_KINDS}
181
+ return {
182
+ "contract_version": 1,
183
+ "as_of": today.isoformat(),
184
+ "decision": decision,
185
+ "decisions": records,
186
+ "summary": {
187
+ "decisions": len(records),
188
+ "live": len(live),
189
+ "live_with_gaps": sum(1 for record in live if record["gaps"]),
190
+ "gaps": gap_counts,
191
+ },
192
+ }
193
+
194
+
195
+ def _describe(item: dict) -> str:
196
+ text = f"{item['id']} {item['state']}"
197
+ if item["state"] == "retired" and item.get("replaced_by"):
198
+ text += f" -> {item['replaced_by']}"
199
+ if item["stale"]:
200
+ text += f" (stale: accessed {item['accessed']}, {item['age_days']}d > {item['max_age_days']}d)"
201
+ if item.get("via"):
202
+ text += f" via {item['via']}"
203
+ return text
204
+
205
+
206
+ def main(argv: list[str] | None = None) -> int:
207
+ parser = argparse.ArgumentParser(
208
+ prog="whykit trace",
209
+ description="Trace every decision to the evidence it rests on and flag missing, retired or stale sources.",
210
+ )
211
+ parser.add_argument("--root", help="vault root (default: nearest vault)")
212
+ parser.add_argument("--decision", help="trace only this D-NNN")
213
+ parser.add_argument("--today", help="evaluate evidence age as of this ISO date")
214
+ parser.add_argument(
215
+ "--max-age-days", type=int,
216
+ help="treat evidence as stale after N days when [evidence_access_age_days] has no entry for its type",
217
+ )
218
+ parser.add_argument("--gaps-only", action="store_true", help="list only live decisions with gaps")
219
+ parser.add_argument("--strict", action="store_true", help="exit 1 when any live decision has a gap")
220
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
221
+ args = parser.parse_args(argv)
222
+
223
+ if args.decision and not DECISION_ID_RE.fullmatch(args.decision):
224
+ return emit_error("invalid_argument", "--decision must be a D-NNN identifier", json_mode=args.json)
225
+ if args.max_age_days is not None and args.max_age_days < 0:
226
+ return emit_error("invalid_argument", "--max-age-days must be >= 0", json_mode=args.json)
227
+ today = None
228
+ if args.today:
229
+ try:
230
+ today = dt.date.fromisoformat(args.today)
231
+ except ValueError:
232
+ return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
233
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
234
+ if root is None or not is_vault_root(root):
235
+ return emit_error("vault_not_found", "no WhyKit vault found", json_mode=args.json)
236
+ try:
237
+ config, _ = load_config(root)
238
+ except ConfigError as exc:
239
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
240
+ report = build_trace(
241
+ root,
242
+ today=today,
243
+ decision=args.decision,
244
+ default_max_age=args.max_age_days,
245
+ access_age_days=dict(config["evidence_access_age_days"]),
246
+ )
247
+ found = bool(report["decisions"])
248
+ if args.gaps_only:
249
+ report["decisions"] = [record for record in report["decisions"] if record["live"] and record["gaps"]]
250
+ if args.json:
251
+ emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
252
+ else:
253
+ print(f"Decision traceability — {report['as_of']}")
254
+ for record in report["decisions"]:
255
+ state = "live" if record["live"] else (
256
+ f"superseded by {', '.join(record['superseded_by'])}" if record["superseded_by"] else record["status"] or "no status"
257
+ )
258
+ gaps = f" [{', '.join(gap.replace('_', ' ') for gap in record['gaps'])}]" if record["gaps"] else ""
259
+ print(f" {record['decision_id']} {record['path']} ({state}){gaps}")
260
+ for item in record["evidence"]:
261
+ print(f" {_describe(item)}")
262
+ summary = report["summary"]
263
+ print(
264
+ f"\n{summary['decisions']} decision(s), {summary['live']} live, "
265
+ f"{summary['live_with_gaps']} live with gaps"
266
+ )
267
+ if args.decision and not found:
268
+ print(f"{args.decision}: not found", file=sys.stderr)
269
+ return 1
270
+ if args.strict and report["summary"]["live_with_gaps"]:
271
+ return 1
272
+ return 0
whykit/vault_index.py ADDED
@@ -0,0 +1,190 @@
1
+ """Request-scoped vault model: one parse, many consumers."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import contextvars
6
+ import os
7
+ import stat
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable, Iterator
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from .io import vault_read_lock
15
+ from .lint import Note, _normalise, _build_index, _link_key, _real, _resolve, _within, collect_markdown, load_note, path_cache, rel, strip_markdown_suffix
16
+
17
+
18
+ class NoteCache:
19
+ """Parsed notes kept across requests while their files are unchanged.
20
+
21
+ A long-lived reader (the MCP server) would otherwise read and parse every
22
+ note on every call. Each entry is keyed by the file's path and checked
23
+ against its current ``stat`` signature (modification and change time,
24
+ size, inode), link metadata and content when change time is untrusted,
25
+ so an edit, an atomic replace, a rename
26
+ or a deletion is always picked up by the next call.
27
+
28
+ A file whose timestamps are within :attr:`racy_ns` of the clock is never
29
+ cached: a second write in the same timestamp tick, with the same size,
30
+ would otherwise be invisible (the "racy clean" problem Git also guards
31
+ against). Only parsing is reused. Link resolution, findings and every
32
+ other derived view are still computed per request, because they depend on
33
+ files outside the note set (attachments, symlinks, configuration).
34
+
35
+ Notes are shared between requests, so callers must treat them as read-only,
36
+ as every WhyKit consumer already does.
37
+ """
38
+
39
+ racy_ns = 2_000_000_000
40
+
41
+ def __init__(self) -> None:
42
+ self._entries: dict[str, tuple[tuple[int, ...], Note]] = {}
43
+ self._lock = threading.Lock()
44
+ self.hits = 0
45
+ self.misses = 0
46
+
47
+ def load_all(self, paths: list[Path]) -> list[Note]:
48
+ now = time.time_ns()
49
+ fresh: dict[str, tuple[tuple[int, ...], Note]] = {}
50
+ notes: list[Note] = []
51
+ with self._lock:
52
+ entries = self._entries
53
+ for path in paths:
54
+ key = os.fspath(path)
55
+ try:
56
+ link = os.lstat(path)
57
+ info = os.stat(path) if stat.S_ISLNK(link.st_mode) else link
58
+ except OSError:
59
+ notes.append(load_note(path)) # raises like an uncached load
60
+ continue
61
+ signature = (info.st_mtime_ns, info.st_ctime_ns, info.st_size, info.st_ino, info.st_dev,
62
+ link.st_mtime_ns, link.st_ctime_ns, link.st_size, link.st_ino, link.st_dev)
63
+ cached = entries.get(key)
64
+ same = cached is not None and cached[0] == signature
65
+ from .parse_cache import CHANGE_TIME_TRUSTED
66
+ if same and cached is not None and not CHANGE_TIME_TRUSTED:
67
+ try:
68
+ text = _normalise(path.read_text(encoding="utf-8"))
69
+ same = text == cached[1].text
70
+ except UnicodeError:
71
+ same = False
72
+ if same and cached is not None:
73
+ self.hits += 1
74
+ note = cached[1]
75
+ else:
76
+ self.misses += 1
77
+ note = load_note(path)
78
+ notes.append(note)
79
+ if now - max(info.st_mtime_ns, info.st_ctime_ns) >= self.racy_ns:
80
+ fresh[key] = cached if same and cached is not None else (signature, note)
81
+ # Only the files of this load survive: a deleted note is dropped.
82
+ with self._lock:
83
+ self._entries = fresh
84
+ return notes
85
+
86
+
87
+ _NOTE_CACHE: contextvars.ContextVar[NoteCache | None] = contextvars.ContextVar("whykit_note_cache", default=None)
88
+
89
+
90
+ @contextlib.contextmanager
91
+ def reuse_notes(cache: NoteCache) -> Iterator[None]:
92
+ """Let every :meth:`VaultIndex.load` in this context reuse *cache*."""
93
+ token = _NOTE_CACHE.set(cache)
94
+ try:
95
+ yield
96
+ finally:
97
+ _NOTE_CACHE.reset(token)
98
+
99
+
100
+ @dataclass
101
+ class VaultIndex:
102
+ """Immutable in-memory view of a vault after one Markdown parse pass."""
103
+
104
+ root: Path
105
+ notes: list[Note]
106
+ by_path: dict[Path, Note] = field(repr=False)
107
+ link_index: dict[str, set[Path]] = field(repr=False)
108
+ # Link resolution and relative paths cost several ``stat``/``realpath``
109
+ # calls each. A vault repeats the same targets thousands of times, so both
110
+ # are memoised for the lifetime of this request-scoped index.
111
+ _resolved: dict[str, tuple[Path | None, bool]] = field(default_factory=dict, repr=False, compare=False)
112
+ _relative: dict[Path, str] = field(default_factory=dict, repr=False, compare=False)
113
+ # Views derived from this parse (e.g. the wikilink adjacency used by impact
114
+ # analysis), so a multi-record command does not rebuild them per record.
115
+ # A subset() starts with its own empty dict: derived views never cross views.
116
+ derived: dict = field(default_factory=dict, repr=False, compare=False)
117
+ # A confined index resolves links only to its own notes: a file on disk
118
+ # that is not one of them is treated as if it did not exist. See subset().
119
+ confined: bool = field(default=False, repr=False, compare=False)
120
+
121
+ @classmethod
122
+ @path_cache()
123
+ def load(cls, root: Path) -> VaultIndex:
124
+ with vault_read_lock(root):
125
+ root = Path(root).resolve()
126
+ paths = collect_markdown(root, [])
127
+ cache = _NOTE_CACHE.get()
128
+ if cache is not None:
129
+ notes = cache.load_all(paths)
130
+ else:
131
+ from .parse_cache import current
132
+
133
+ disk = current(root)
134
+ notes = disk.load_all(paths) if disk is not None else [load_note(path) for path in paths]
135
+ return cls(
136
+ root=root,
137
+ notes=notes,
138
+ by_path={_real(note.path): note for note in notes},
139
+ link_index=_build_index(notes),
140
+ )
141
+
142
+ def subset(self, keep: Callable[[Note], bool]) -> VaultIndex:
143
+ """Return a confined index over the notes for which *keep* is true.
144
+
145
+ Every consumer that takes a ``vault=`` argument then behaves as if the
146
+ dropped notes did not exist: they are not link targets, not decision ID
147
+ holders and not graph nodes, so a stem shared with a dropped note
148
+ resolves to the remaining one instead of being reported as ambiguous.
149
+ """
150
+ notes = [note for note in self.notes if keep(note)]
151
+ return VaultIndex(
152
+ root=self.root,
153
+ notes=notes,
154
+ by_path={_real(note.path): note for note in notes},
155
+ link_index=_build_index(notes),
156
+ _relative=self._relative,
157
+ confined=True,
158
+ )
159
+
160
+ def note_for(self, path: Path) -> Note | None:
161
+ return self.by_path.get(_real(Path(path)))
162
+
163
+ def relative(self, path: Path) -> str:
164
+ cached = self._relative.get(path)
165
+ if cached is None:
166
+ cached = self._relative[path] = rel(self.root, path)
167
+ return cached
168
+
169
+ def resolve_link(self, target: str) -> tuple[Path | None, bool]:
170
+ """Resolve a wikilink target exactly like the linter, once per target."""
171
+ cached = self._resolved.get(target)
172
+ if cached is None:
173
+ cached = self._resolved[target] = (
174
+ self._resolve_confined(target) if self.confined else _resolve(self.root, target, self.link_index)
175
+ )
176
+ return cached
177
+
178
+ def _resolve_confined(self, target: str) -> tuple[Path | None, bool]:
179
+ path, ambiguous = _resolve(self.root, target, self.link_index)
180
+ if path is None or self.note_for(path) is not None:
181
+ return path, ambiguous
182
+ # `_resolve` accepted a file that exists on disk but is not one of this
183
+ # index's notes. Ignore it and fall back to the stem/alias lookup, which
184
+ # only ever sees this index's notes.
185
+ normalized = target.strip().replace("\\", "/").lstrip("/")
186
+ stem = _link_key(strip_markdown_suffix(normalized.rstrip("/").split("/")[-1]))
187
+ hits = {item for item in self.link_index.get(stem, set()) if _within(self.root, item)}
188
+ if len(hits) == 1:
189
+ return next(iter(hits)), False
190
+ return None, len(hits) > 1