whykit 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
|
@@ -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
|