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
whykit/explorer_index.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Canonical Explorer index export — one Python parser for the UI contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .io import consistent_read, vault_read_lock
|
|
5
|
+
|
|
6
|
+
from .tables import review_table_header
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections import defaultdict, deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .config import ConfigError, load_config
|
|
18
|
+
from .contract import CONTRACT_VERSION, emit_error, vault_not_found
|
|
19
|
+
from .lint import (
|
|
20
|
+
evidence_view,
|
|
21
|
+
_parse_evidence_register_text,
|
|
22
|
+
path_cache,
|
|
23
|
+
strip_markdown_suffix,
|
|
24
|
+
_split_table_row,
|
|
25
|
+
decision_log_rows,
|
|
26
|
+
find_vault_root,
|
|
27
|
+
is_vault_root,
|
|
28
|
+
lint,
|
|
29
|
+
Note,
|
|
30
|
+
rel,
|
|
31
|
+
)
|
|
32
|
+
from .vault_index import VaultIndex
|
|
33
|
+
from .console import emit_machine
|
|
34
|
+
from .sensitivity import note_sensitivity_level, classified_evidence, evidence_register_notes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _marker_pattern(markers: set[str]) -> re.Pattern | None:
|
|
38
|
+
"""Share prefixes so matching a path does not scan every vault path."""
|
|
39
|
+
if not markers:
|
|
40
|
+
return None
|
|
41
|
+
nodes: list[tuple[str, list[int]]] = []
|
|
42
|
+
pending = [(0, sorted(markers), 0)]
|
|
43
|
+
next_id = 1
|
|
44
|
+
while pending:
|
|
45
|
+
at, words, depth = pending.pop()
|
|
46
|
+
while len(nodes) <= at:
|
|
47
|
+
nodes.append(("", []))
|
|
48
|
+
if depth >= 32:
|
|
49
|
+
nodes[at] = ("(?:" + "|".join(re.escape(word) for word in sorted(words, key=len, reverse=True)) + ")", [])
|
|
50
|
+
continue
|
|
51
|
+
prefix = os.path.commonprefix((words[0], words[-1]))
|
|
52
|
+
groups: dict[str, list[str]] = defaultdict(list)
|
|
53
|
+
for word in words:
|
|
54
|
+
suffix = word[len(prefix):]
|
|
55
|
+
if suffix:
|
|
56
|
+
groups[suffix[0]].append(suffix)
|
|
57
|
+
children = []
|
|
58
|
+
for group in groups.values():
|
|
59
|
+
child = next_id
|
|
60
|
+
next_id += 1
|
|
61
|
+
children.append(child)
|
|
62
|
+
pending.append((child, group, depth + 1))
|
|
63
|
+
if prefix in words and children:
|
|
64
|
+
children.append(-1)
|
|
65
|
+
nodes[at] = (re.escape(prefix), children)
|
|
66
|
+
parts = {-1: ""}
|
|
67
|
+
for at in range(len(nodes) - 1, -1, -1):
|
|
68
|
+
prefix, children = nodes[at]
|
|
69
|
+
parts[at] = prefix + ("(?:" + "|".join(parts[child] for child in children) + ")" if children else "")
|
|
70
|
+
return re.compile(r"(?<!\w)(?:" + parts[0] + r")(?!\w)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _public_payload(payload: dict, index: VaultIndex, *, reasons: dict[str, set[str]] | None = None) -> dict:
|
|
74
|
+
"""Withhold non-public notes and their dependent references as whole notes.
|
|
75
|
+
|
|
76
|
+
Redacting fragments would invent a different decision or review history.
|
|
77
|
+
This closure also inspects code examples: unlike graph citations, an export
|
|
78
|
+
must not reveal a hidden identifier just because it occurs in a code block.
|
|
79
|
+
"""
|
|
80
|
+
docs = {doc["id"]: doc for doc in payload["docs"]}
|
|
81
|
+
paths = {note.path.resolve(): strip_markdown_suffix(index.relative(note.path)) for note in index.notes}
|
|
82
|
+
withheld = {paths[note.path.resolve()] for note in index.notes if note_sensitivity_level(note) != 0}
|
|
83
|
+
def withhold(key: str, reason: str) -> None:
|
|
84
|
+
withheld.add(key)
|
|
85
|
+
if reasons is not None:
|
|
86
|
+
reasons.setdefault(key, set()).add(reason)
|
|
87
|
+
|
|
88
|
+
for key in withheld:
|
|
89
|
+
if reasons is not None:
|
|
90
|
+
reasons.setdefault(key, set()).add("non_public_sensitivity")
|
|
91
|
+
markers: dict[str, set[str]] = defaultdict(set)
|
|
92
|
+
titles: dict[str, set[str]] = defaultdict(set)
|
|
93
|
+
for key, doc in docs.items():
|
|
94
|
+
for value in (key, key + ".md", doc["decisionId"]):
|
|
95
|
+
if value:
|
|
96
|
+
markers[str(value)].add(key)
|
|
97
|
+
for value in (doc["title"], *doc["aliases"]):
|
|
98
|
+
if value:
|
|
99
|
+
titles[str(value)].add(key)
|
|
100
|
+
dependents: dict[str, set[str]] = defaultdict(set)
|
|
101
|
+
registers = evidence_register_notes(index)
|
|
102
|
+
register_ids = {paths[note.path.resolve()] for note in registers}
|
|
103
|
+
register_id = paths[registers[0].path.resolve()] if len(registers) == 1 else "00-context/evidence-register"
|
|
104
|
+
evidence_ids = {row["id"] for row in payload["evidence"]}
|
|
105
|
+
for row in payload["evidence"]:
|
|
106
|
+
key = "evidence:" + row["id"]
|
|
107
|
+
markers[row["id"]].add(key)
|
|
108
|
+
if row["sensitivity"] != "public":
|
|
109
|
+
withheld.add(key)
|
|
110
|
+
pattern = _marker_pattern(set(markers))
|
|
111
|
+
title_pattern = _marker_pattern(set(titles))
|
|
112
|
+
_, _, _, per_row = classified_evidence(index)
|
|
113
|
+
if per_row:
|
|
114
|
+
for key in register_ids:
|
|
115
|
+
withhold(key, "per_row_evidence_classification")
|
|
116
|
+
evidence_pattern = re.compile(r"(?<!\w)E-[0-9]{3,}(?!\w)")
|
|
117
|
+
for note in index.notes:
|
|
118
|
+
key = paths[note.path.resolve()]
|
|
119
|
+
if key in withheld:
|
|
120
|
+
continue
|
|
121
|
+
raw = Note(note.path, note.text)
|
|
122
|
+
raw.masked = note.text
|
|
123
|
+
dependencies: set[str] = set()
|
|
124
|
+
for target, _, _ in raw.wikilink_hits:
|
|
125
|
+
if target.startswith(("https://", "http://")):
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
resolved, ambiguous = index.resolve_link(target)
|
|
129
|
+
target_id = paths.get(resolved.resolve()) if resolved is not None else None
|
|
130
|
+
except (OSError, ValueError, RuntimeError):
|
|
131
|
+
target_id, ambiguous = None, False
|
|
132
|
+
if ambiguous or target_id is None:
|
|
133
|
+
withhold(key, "unresolved_or_ambiguous_wikilink")
|
|
134
|
+
else:
|
|
135
|
+
dependencies.add(target_id)
|
|
136
|
+
for _, target, _ in raw.markdown_link_hits:
|
|
137
|
+
candidate = index.root / target.lstrip("/") if target.startswith("/") else note.path.parent / target
|
|
138
|
+
try:
|
|
139
|
+
target_id = paths.get(candidate.resolve())
|
|
140
|
+
except (OSError, ValueError, RuntimeError):
|
|
141
|
+
target_id = None
|
|
142
|
+
if target_id is None:
|
|
143
|
+
withhold(key, "unclassified_attachment_or_missing_markdown_link")
|
|
144
|
+
else:
|
|
145
|
+
dependencies.add(target_id)
|
|
146
|
+
text = "\n".join(value if isinstance(value, str) else "\n".join(map(str, value)) if isinstance(value, list) else str(value) for value in docs[key].values())
|
|
147
|
+
if pattern is not None:
|
|
148
|
+
for match in pattern.finditer(text):
|
|
149
|
+
dependencies.update(markers[match.group()])
|
|
150
|
+
if title_pattern is not None:
|
|
151
|
+
content = "\n".join((docs[key]["title"], *docs[key]["aliases"], docs[key]["body"]))
|
|
152
|
+
for match in title_pattern.finditer(content):
|
|
153
|
+
dependencies.update(titles[match.group()])
|
|
154
|
+
if key != register_id and evidence_ids.intersection(evidence_pattern.findall(note.text)):
|
|
155
|
+
if not per_row:
|
|
156
|
+
if register_id not in docs:
|
|
157
|
+
withhold(key, "missing_evidence_register")
|
|
158
|
+
dependencies.add(register_id)
|
|
159
|
+
for target in dependencies - {key}:
|
|
160
|
+
dependents[target].add(key)
|
|
161
|
+
if key != register_id and set(evidence_pattern.findall(note.text)) - evidence_ids:
|
|
162
|
+
withhold(key, "unclassified_evidence_reference")
|
|
163
|
+
# Rows participate in the same closure as notes, including replacement IDs.
|
|
164
|
+
for row in payload["evidence"]:
|
|
165
|
+
key = "evidence:" + row["id"]
|
|
166
|
+
if set(evidence_pattern.findall("\n".join(str(value) for value in row.values()))) - evidence_ids:
|
|
167
|
+
withheld.add(key)
|
|
168
|
+
if not per_row:
|
|
169
|
+
if register_id not in docs:
|
|
170
|
+
withheld.add(key)
|
|
171
|
+
dependents[register_id].add(key)
|
|
172
|
+
if pattern is not None:
|
|
173
|
+
for match in pattern.finditer("\n".join(str(value) for value in row.values())):
|
|
174
|
+
for target in markers[match.group()] - {key}:
|
|
175
|
+
dependents[target].add(key)
|
|
176
|
+
if title_pattern is not None:
|
|
177
|
+
for match in title_pattern.finditer("\n".join(str(value) for value in row.values())):
|
|
178
|
+
for target in titles[match.group()] - {key}:
|
|
179
|
+
dependents[target].add(key)
|
|
180
|
+
pending = deque(withheld)
|
|
181
|
+
while pending:
|
|
182
|
+
while pending:
|
|
183
|
+
hidden = pending.popleft()
|
|
184
|
+
for dependent in dependents[hidden]:
|
|
185
|
+
if dependent not in withheld:
|
|
186
|
+
if dependent in docs:
|
|
187
|
+
withhold(dependent, "withheld_dependency:" + hidden)
|
|
188
|
+
else:
|
|
189
|
+
withheld.add(dependent)
|
|
190
|
+
pending.append(dependent)
|
|
191
|
+
# A longer visible marker may have masked a hidden prefix/substring.
|
|
192
|
+
hidden_paths = _marker_pattern({marker for marker, targets in markers.items() if targets & withheld})
|
|
193
|
+
hidden_titles = _marker_pattern({marker for marker, targets in titles.items() if targets & withheld})
|
|
194
|
+
for key, item in [(key, doc) for key, doc in docs.items()] + [("evidence:" + row["id"], row) for row in payload["evidence"]]:
|
|
195
|
+
if key in withheld:
|
|
196
|
+
continue
|
|
197
|
+
text = "\n".join(str(value) for value in item.values())
|
|
198
|
+
content = "\n".join((item["title"], *item["aliases"], item["body"])) if key in docs else text
|
|
199
|
+
if ((hidden_paths is not None and hidden_paths.search(text))
|
|
200
|
+
or (hidden_titles is not None and hidden_titles.search(content))):
|
|
201
|
+
if key in docs:
|
|
202
|
+
withhold(key, "withheld_reference")
|
|
203
|
+
else:
|
|
204
|
+
withheld.add(key)
|
|
205
|
+
pending.append(key)
|
|
206
|
+
visible = set(docs) - withheld
|
|
207
|
+
hidden_markers = {marker for group in (markers, titles) for marker, targets in group.items() if targets & withheld}
|
|
208
|
+
hidden_pattern = _marker_pattern(hidden_markers)
|
|
209
|
+
findings = [item for item in payload["lint"]["findings"]
|
|
210
|
+
if strip_markdown_suffix(item["path"]) in visible
|
|
211
|
+
and (hidden_pattern is None or not hidden_pattern.search(item["path"] + "\n" + item["message"]))]
|
|
212
|
+
public_index = index.subset(lambda note: paths[note.path.resolve()] in visible)
|
|
213
|
+
evidence = [row for row in payload["evidence"] if "evidence:" + row["id"] not in withheld]
|
|
214
|
+
return {
|
|
215
|
+
**payload,
|
|
216
|
+
"exportMode": "public",
|
|
217
|
+
"vaultName": _vault_name(index.root, public_index, fallback="WhyKit public vault") if "README" in visible else "WhyKit public vault",
|
|
218
|
+
"docs": [{**doc, "sensitivity": "public"} for doc in payload["docs"] if doc["id"] in visible],
|
|
219
|
+
"evidence": evidence,
|
|
220
|
+
"decisions": payload["decisions"] if "06-decisions/decision-log" in visible else [],
|
|
221
|
+
"reviews": payload["reviews"] if "00-context/review-log" in visible else [],
|
|
222
|
+
"lint": {"files": len(visible), "errors": sum(item["level"] == "error" for item in findings),
|
|
223
|
+
"warnings": sum(item["level"] == "warning" for item in findings), "findings": findings},
|
|
224
|
+
"policy": {**payload["policy"], "evidenceAccessAgeDays": {
|
|
225
|
+
kind: age for kind, age in payload["policy"]["evidenceAccessAgeDays"].items()
|
|
226
|
+
if kind in {row["type"] for row in evidence}
|
|
227
|
+
}},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@consistent_read
|
|
232
|
+
@path_cache()
|
|
233
|
+
def build_publication_preview(root: Path, *, today: dt.date | None = None) -> dict:
|
|
234
|
+
"""Private diagnostics only; never included in an Explorer publication artifact."""
|
|
235
|
+
root = root.resolve()
|
|
236
|
+
config_path = root / "whykit.toml"
|
|
237
|
+
if config_path.is_symlink():
|
|
238
|
+
raise RuntimeError("unsafe publication input")
|
|
239
|
+
config_bytes = config_path.read_bytes() if config_path.exists() else None
|
|
240
|
+
payload, index = _build_explorer_index(root, today=today)
|
|
241
|
+
reasons: dict[str, set[str]] = {}
|
|
242
|
+
public = _public_payload(payload, index, reasons=reasons)
|
|
243
|
+
files = {note.path for note in index.notes}
|
|
244
|
+
if (root / "whykit.toml").exists():
|
|
245
|
+
files.add(root / "whykit.toml")
|
|
246
|
+
digest = hashlib.sha256()
|
|
247
|
+
for path in sorted(files):
|
|
248
|
+
if path.is_symlink() or not path.resolve().is_relative_to(index.root):
|
|
249
|
+
raise RuntimeError("unsafe publication input")
|
|
250
|
+
data = path.read_bytes()
|
|
251
|
+
note = index.note_for(path)
|
|
252
|
+
if note is not None and data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n") != note.text:
|
|
253
|
+
raise RuntimeError("publication inputs changed during capture; retry")
|
|
254
|
+
digest.update(index.relative(path).encode("utf-8") + b"\0")
|
|
255
|
+
digest.update(hashlib.sha256(data).digest())
|
|
256
|
+
if (config_path.read_bytes() if config_path.exists() else None) != config_bytes:
|
|
257
|
+
raise RuntimeError("publication inputs changed during capture; retry")
|
|
258
|
+
return {"contract_version": 1, "format": "whykit.publication-preview/v1", "private": True,
|
|
259
|
+
"source_manifest_sha256": digest.hexdigest(), "publication_ready": payload["lint"]["errors"] == 0,
|
|
260
|
+
"total_docs": len(payload["docs"]), "public_docs": len(public["docs"]),
|
|
261
|
+
"withheld": [{"path": key + ".md", "reasons": sorted(values)} for key, values in sorted(reasons.items())]}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _summary(body: str) -> str:
|
|
265
|
+
lines = body.split("\n")
|
|
266
|
+
seen_h1 = False
|
|
267
|
+
buf: list[str] = []
|
|
268
|
+
for raw in lines:
|
|
269
|
+
line = raw.strip()
|
|
270
|
+
if not seen_h1 and line.startswith("# "):
|
|
271
|
+
seen_h1 = True
|
|
272
|
+
continue
|
|
273
|
+
if not seen_h1 or not line:
|
|
274
|
+
if buf:
|
|
275
|
+
break
|
|
276
|
+
continue
|
|
277
|
+
if re.match(r"^(#|\||>|-|\*|\d+\.)", line):
|
|
278
|
+
if buf:
|
|
279
|
+
break
|
|
280
|
+
continue
|
|
281
|
+
buf.append(line)
|
|
282
|
+
if len(" ".join(buf)) > 220:
|
|
283
|
+
break
|
|
284
|
+
return " ".join(buf)[:240]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _wiki_target(cell: str) -> str:
|
|
288
|
+
match = re.search(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]*)?\]\]", str(cell))
|
|
289
|
+
return match.group(1).strip() if match else ""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _as_list(value: object) -> list[str]:
|
|
293
|
+
if value is None or value == "":
|
|
294
|
+
return []
|
|
295
|
+
if isinstance(value, list):
|
|
296
|
+
return [str(item) for item in value]
|
|
297
|
+
return [str(value)]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
_H1_RE = re.compile(r"^#[ \t]+(.+?)[ \t#]*$", re.MULTILINE)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _vault_name(root: Path, index: VaultIndex, *, fallback: str | None = None) -> str:
|
|
304
|
+
"""Name the vault after its README, never after the file name itself.
|
|
305
|
+
|
|
306
|
+
A README without a front-matter title (the one `whykit init` writes) or
|
|
307
|
+
with the placeholder title "README" would otherwise label the Explorer
|
|
308
|
+
"README". Fall back to its first heading, then to the directory name.
|
|
309
|
+
"""
|
|
310
|
+
note = index.note_for(root / "README.md")
|
|
311
|
+
if note is not None:
|
|
312
|
+
title = str(note.front.get("title") or "").strip()
|
|
313
|
+
if title and title.casefold() != "readme":
|
|
314
|
+
return title
|
|
315
|
+
heading = _H1_RE.search(note.body)
|
|
316
|
+
if heading and heading.group(1).strip().casefold() != "readme":
|
|
317
|
+
return heading.group(1).strip()
|
|
318
|
+
return fallback if fallback is not None else root.name or "WhyKit vault"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _policy(root: Path) -> dict:
|
|
322
|
+
"""The parts of ``whykit.toml`` the Explorer needs to judge freshness.
|
|
323
|
+
|
|
324
|
+
A malformed config is a lint error reported elsewhere; the Explorer then
|
|
325
|
+
shows no thresholds rather than guessing at them.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
config, _ = load_config(root)
|
|
329
|
+
except ConfigError:
|
|
330
|
+
return {"evidenceAccessAgeDays": {}, "decisionReviewDays": None, "statusDueDays": None}
|
|
331
|
+
defaults = config.get("defaults", {})
|
|
332
|
+
return {
|
|
333
|
+
"evidenceAccessAgeDays": {str(k): int(v) for k, v in sorted(config.get("evidence_access_age_days", {}).items())},
|
|
334
|
+
"decisionReviewDays": defaults.get("decision_review_days"),
|
|
335
|
+
"statusDueDays": defaults.get("status_due_days"),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@consistent_read
|
|
340
|
+
@path_cache()
|
|
341
|
+
def build_explorer_index(root: Path, *, today: dt.date | None = None, private: bool = False) -> dict:
|
|
342
|
+
"""Build the Explorer vault.json payload from the canonical Python parser."""
|
|
343
|
+
payload, index = _build_explorer_index(root, today=today)
|
|
344
|
+
return payload if private else _public_payload(payload, index)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@consistent_read
|
|
348
|
+
@path_cache()
|
|
349
|
+
def _build_explorer_index(root: Path, *, today: dt.date | None = None) -> tuple[dict, VaultIndex]:
|
|
350
|
+
root = root.resolve()
|
|
351
|
+
index = VaultIndex.load(root)
|
|
352
|
+
registers = evidence_register_notes(index)
|
|
353
|
+
register_paths = {note.path for note in registers}
|
|
354
|
+
docs: list[dict] = []
|
|
355
|
+
for note in index.notes:
|
|
356
|
+
doc_id = strip_markdown_suffix(rel(root, note.path))
|
|
357
|
+
# Every E-NNN the note cites, in front matter or body, outside code:
|
|
358
|
+
# `Note.cited_evidence` is the one citation rule `whykit graph`,
|
|
359
|
+
# `impact` and `trace` use. The register itself lists IDs rather than
|
|
360
|
+
# citing them.
|
|
361
|
+
citations = [] if note.path in register_paths else list(note.cited_evidence)
|
|
362
|
+
top = doc_id.split("/", 1)[0] if "/" in doc_id else "root"
|
|
363
|
+
body = note.body
|
|
364
|
+
docs.append({
|
|
365
|
+
"id": doc_id,
|
|
366
|
+
"title": str(note.front.get("title") or note.path.stem),
|
|
367
|
+
"aliases": _as_list(note.front.get("aliases")),
|
|
368
|
+
"type": str(note.front.get("type") or "guide"),
|
|
369
|
+
"status": str(note.front.get("status") or "draft"),
|
|
370
|
+
"owner": str(note.front.get("owner") or ""),
|
|
371
|
+
"created": str(note.front.get("created") or ""),
|
|
372
|
+
"lastUpdated": str(note.front.get("last_updated") or ""),
|
|
373
|
+
"reviewBy": str(note.front.get("review_by") or ""),
|
|
374
|
+
"sourceOfTruth": note.front.get("source_of_truth") is True,
|
|
375
|
+
"sensitivity": str(note.front.get("sensitivity") or "internal"),
|
|
376
|
+
"sourceIds": _as_list(note.front.get("source_ids")),
|
|
377
|
+
"citations": citations,
|
|
378
|
+
"tags": _as_list(note.front.get("tags")),
|
|
379
|
+
"workstream": str(note.front.get("workstream") or top),
|
|
380
|
+
"decisionId": str(note.front.get("decision_id") or "") or None,
|
|
381
|
+
"supersedes": str(note.front.get("supersedes") or "") or None,
|
|
382
|
+
"summary": _summary(body),
|
|
383
|
+
"body": body.lstrip("\n"),
|
|
384
|
+
})
|
|
385
|
+
docs.sort(key=lambda item: item["id"])
|
|
386
|
+
|
|
387
|
+
active, retired, _, _ = classified_evidence(index)
|
|
388
|
+
evidence = []
|
|
389
|
+
for eid, row in sorted(active.items()):
|
|
390
|
+
evidence.append({
|
|
391
|
+
"id": eid,
|
|
392
|
+
"state": "active",
|
|
393
|
+
"source": str(row.get("source") or ""),
|
|
394
|
+
"type": str(row.get("type") or ""),
|
|
395
|
+
"date": str(row.get("date") or ""),
|
|
396
|
+
"accessed": str(row.get("accessed") or ""),
|
|
397
|
+
"location": str(row.get("location") or ""),
|
|
398
|
+
"claims": str(row.get("claims") or ""),
|
|
399
|
+
"sensitivity": row["sensitivity"],
|
|
400
|
+
})
|
|
401
|
+
for eid, row in sorted(retired.items()):
|
|
402
|
+
replaced = str(row.get("replaced_by") or "")
|
|
403
|
+
evidence.append({
|
|
404
|
+
"id": eid,
|
|
405
|
+
"state": "retired",
|
|
406
|
+
"source": str(row.get("source") or ""),
|
|
407
|
+
"type": "",
|
|
408
|
+
"date": "",
|
|
409
|
+
"accessed": "",
|
|
410
|
+
"location": "",
|
|
411
|
+
"claims": "",
|
|
412
|
+
"retiredOn": str(row.get("retired_on") or ""),
|
|
413
|
+
"why": str(row.get("why") or ""),
|
|
414
|
+
"replacedBy": None if replaced in {"", "—"} else replaced,
|
|
415
|
+
"sensitivity": row["sensitivity"],
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
decisions = []
|
|
419
|
+
decision_log = index.note_for(root / "06-decisions/decision-log.md")
|
|
420
|
+
for row in decision_log_rows(root, text=decision_log.text if decision_log else ""):
|
|
421
|
+
decisions.append({
|
|
422
|
+
"id": str(row.get("id") or ""),
|
|
423
|
+
"title": str(row.get("title") or ""),
|
|
424
|
+
"date": str(row.get("date") or ""),
|
|
425
|
+
"owner": str(row.get("owner") or ""),
|
|
426
|
+
"status": str(row.get("status") or ""),
|
|
427
|
+
"recordId": str(row.get("record") or ""),
|
|
428
|
+
})
|
|
429
|
+
# Attach supersedes / supersededBy from decision documents.
|
|
430
|
+
by_decision = {doc["decisionId"]: doc for doc in docs if doc.get("decisionId")}
|
|
431
|
+
for row in decisions:
|
|
432
|
+
doc = by_decision.get(row["id"])
|
|
433
|
+
if doc:
|
|
434
|
+
row["supersedes"] = doc.get("supersedes")
|
|
435
|
+
if not row.get("recordId"):
|
|
436
|
+
row["recordId"] = doc["id"]
|
|
437
|
+
# The first row that supersedes an ID wins, as the old per-row scan did.
|
|
438
|
+
first_successor: dict[str, dict] = {}
|
|
439
|
+
for other in decisions:
|
|
440
|
+
predecessor = other.get("supersedes")
|
|
441
|
+
if predecessor is not None:
|
|
442
|
+
first_successor.setdefault(predecessor, other)
|
|
443
|
+
for row in decisions:
|
|
444
|
+
newer = first_successor.get(row["id"])
|
|
445
|
+
if newer:
|
|
446
|
+
row["supersededBy"] = newer["id"]
|
|
447
|
+
|
|
448
|
+
review_path = root / "00-context" / "review-log.md"
|
|
449
|
+
reviews = []
|
|
450
|
+
review_log = index.note_for(review_path)
|
|
451
|
+
if review_log is not None:
|
|
452
|
+
lines = review_log.text.splitlines()
|
|
453
|
+
at = review_table_header(lines)
|
|
454
|
+
if at is not None:
|
|
455
|
+
for line in lines[at + 2 :]:
|
|
456
|
+
if not line.lstrip().startswith("|"):
|
|
457
|
+
break
|
|
458
|
+
cells = _split_table_row(line)
|
|
459
|
+
if len(cells) < 7:
|
|
460
|
+
continue
|
|
461
|
+
date, target = cells[0], cells[1]
|
|
462
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) or not target:
|
|
463
|
+
continue
|
|
464
|
+
reviews.append({
|
|
465
|
+
"date": date,
|
|
466
|
+
"target": target,
|
|
467
|
+
"targetId": _wiki_target(target),
|
|
468
|
+
"reviewer": cells[2],
|
|
469
|
+
"outcome": cells[3],
|
|
470
|
+
"previousReview": cells[4],
|
|
471
|
+
"nextReview": cells[5],
|
|
472
|
+
"note": cells[6],
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
register = registers[0] if len(registers) == 1 else None
|
|
476
|
+
with evidence_view(root, _parse_evidence_register_text(register.text if register else "")):
|
|
477
|
+
lint_files, findings = lint(root, orphans=True, secrets=True, today=today, vault=index)
|
|
478
|
+
lint_payload = {
|
|
479
|
+
"files": len(lint_files),
|
|
480
|
+
"errors": sum(1 for item in findings if item.level == "error"),
|
|
481
|
+
"warnings": sum(1 for item in findings if item.level == "warning"),
|
|
482
|
+
"findings": [
|
|
483
|
+
{
|
|
484
|
+
"path": item.path,
|
|
485
|
+
"line": item.line,
|
|
486
|
+
"level": item.level,
|
|
487
|
+
"code": item.code,
|
|
488
|
+
"message": item.message,
|
|
489
|
+
}
|
|
490
|
+
for item in findings
|
|
491
|
+
],
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
vault_name = _vault_name(root, index)
|
|
495
|
+
payload = {
|
|
496
|
+
"contract_version": CONTRACT_VERSION,
|
|
497
|
+
"generatedAt": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
498
|
+
"vaultName": vault_name,
|
|
499
|
+
"docs": docs,
|
|
500
|
+
"evidence": evidence,
|
|
501
|
+
"decisions": decisions,
|
|
502
|
+
"reviews": reviews,
|
|
503
|
+
"lint": lint_payload,
|
|
504
|
+
"policy": _policy(root),
|
|
505
|
+
"exportMode": "private",
|
|
506
|
+
}
|
|
507
|
+
return payload, index
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def main(argv: list[str] | None = None) -> int:
|
|
511
|
+
parser = argparse.ArgumentParser(
|
|
512
|
+
prog="whykit explorer-index",
|
|
513
|
+
description="Export the Explorer vault index from the canonical Python parser.",
|
|
514
|
+
)
|
|
515
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
516
|
+
parser.add_argument("--json", action="store_true", default=True, help="emit JSON (default)")
|
|
517
|
+
parser.add_argument("--today", help="evaluate lint review dates as of this ISO date")
|
|
518
|
+
parser.add_argument("--publication-preview", action="store_true", help="private withheld-path diagnostics; never publish this report")
|
|
519
|
+
parser.add_argument("--private", action="store_true", help="include non-public content; never publish this index")
|
|
520
|
+
args = parser.parse_args(argv)
|
|
521
|
+
root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
|
|
522
|
+
if root is None or not is_vault_root(root):
|
|
523
|
+
return vault_not_found(args.root, json_mode=True)
|
|
524
|
+
today = None
|
|
525
|
+
if args.today:
|
|
526
|
+
try:
|
|
527
|
+
today = dt.date.fromisoformat(args.today)
|
|
528
|
+
except ValueError:
|
|
529
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=True)
|
|
530
|
+
if args.publication_preview:
|
|
531
|
+
if args.private:
|
|
532
|
+
return emit_error("invalid_argument", "--publication-preview and --private are mutually exclusive", json_mode=True)
|
|
533
|
+
emit_machine(json.dumps(build_publication_preview(root, today=today), ensure_ascii=True, indent=2))
|
|
534
|
+
return 0
|
|
535
|
+
with vault_read_lock(root):
|
|
536
|
+
payload, index = _build_explorer_index(root, today=today)
|
|
537
|
+
if payload["lint"]["errors"] > 0:
|
|
538
|
+
details = "\n".join(
|
|
539
|
+
f"- {item['path']}: {item['message']}"
|
|
540
|
+
for item in payload["lint"]["findings"]
|
|
541
|
+
if item["level"] == "error"
|
|
542
|
+
)
|
|
543
|
+
summary = f"Vault has {payload['lint']['errors']} lint error(s); Explorer index not generated." if args.private else "Vault has lint errors; public Explorer index not generated."
|
|
544
|
+
return emit_error(
|
|
545
|
+
"vault_invalid",
|
|
546
|
+
summary + ("\n" + details if args.private and details else ""),
|
|
547
|
+
json_hint="run `whykit lint` to see every finding",
|
|
548
|
+
json_mode=True,
|
|
549
|
+
)
|
|
550
|
+
# JSON escapes preserve Unicode content without requiring a UTF-8 console.
|
|
551
|
+
if not args.private:
|
|
552
|
+
payload = _public_payload(payload, index)
|
|
553
|
+
emit_machine(json.dumps(payload, ensure_ascii=True, indent=2))
|
|
554
|
+
return 0
|