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/graph.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Export the vault knowledge graph for agents, CI and visual tooling.
|
|
2
|
+
|
|
3
|
+
The graph is intentionally typed. Wikilinks are only one relationship:
|
|
4
|
+
documents can also cite evidence and decisions can supersede one another.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .io import consistent_read
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .contract import emit_error, vault_not_found
|
|
16
|
+
from .lint import (
|
|
17
|
+
path_cache,
|
|
18
|
+
strip_markdown_suffix,
|
|
19
|
+
DECISION_ID_RE,
|
|
20
|
+
evidence_register,
|
|
21
|
+
find_vault_root,
|
|
22
|
+
is_vault_root,
|
|
23
|
+
rel,
|
|
24
|
+
)
|
|
25
|
+
from .vault_index import VaultIndex
|
|
26
|
+
from .console import emit_json, emit_machine
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def document_node(note, node_id: str) -> dict:
|
|
30
|
+
"""The graph node for one note (``kind: document``)."""
|
|
31
|
+
return {
|
|
32
|
+
"id": node_id,
|
|
33
|
+
"kind": "document",
|
|
34
|
+
"title": str(note.front.get("title") or note.path.stem),
|
|
35
|
+
"type": str(note.front.get("type") or ""),
|
|
36
|
+
"status": str(note.front.get("status") or ""),
|
|
37
|
+
"owner": str(note.front.get("owner") or ""),
|
|
38
|
+
"sensitivity": str(note.front.get("sensitivity") or ""),
|
|
39
|
+
"decision_id": str(note.front.get("decision_id") or "") or None,
|
|
40
|
+
"source_of_truth": note.front.get("source_of_truth") is True,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wikilink_resolutions(note, vault_index: VaultIndex) -> Iterator[tuple[str, Path | None, bool]]:
|
|
45
|
+
"""Yield ``(target, resolved, ambiguous)`` for each wikilink outside code."""
|
|
46
|
+
for target, _line, _embed in note.wikilink_hits:
|
|
47
|
+
if not target or target.startswith(("http://", "https://")):
|
|
48
|
+
continue
|
|
49
|
+
resolved, ambiguous = vault_index.resolve_link(target)
|
|
50
|
+
yield target, resolved, ambiguous
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@consistent_read
|
|
54
|
+
@path_cache()
|
|
55
|
+
def build_graph(
|
|
56
|
+
root: Path,
|
|
57
|
+
*,
|
|
58
|
+
canonical_only: bool = False,
|
|
59
|
+
vault: VaultIndex | None = None,
|
|
60
|
+
) -> dict:
|
|
61
|
+
root = root.resolve()
|
|
62
|
+
vault_index = vault or VaultIndex.load(root)
|
|
63
|
+
notes = vault_index.notes
|
|
64
|
+
selected = [
|
|
65
|
+
note for note in notes
|
|
66
|
+
if not canonical_only or (note.front.get("source_of_truth") is True and note.front.get("status") == "approved")
|
|
67
|
+
]
|
|
68
|
+
nodes: list[dict] = []
|
|
69
|
+
edges: list[dict] = []
|
|
70
|
+
unresolved: list[dict] = []
|
|
71
|
+
seen_edges: set[tuple[str, str, str]] = set()
|
|
72
|
+
path_by_decision: dict[str, str] = {}
|
|
73
|
+
duplicate_decisions: set[str] = set()
|
|
74
|
+
|
|
75
|
+
def node_id(path: Path) -> str:
|
|
76
|
+
return strip_markdown_suffix(vault_index.relative(path))
|
|
77
|
+
|
|
78
|
+
for note in notes:
|
|
79
|
+
value = str(note.front.get("decision_id") or "").strip()
|
|
80
|
+
if DECISION_ID_RE.fullmatch(value):
|
|
81
|
+
if value in path_by_decision:
|
|
82
|
+
duplicate_decisions.add(value)
|
|
83
|
+
path_by_decision[value] = node_id(note.path)
|
|
84
|
+
selected_ids = {node_id(note.path) for note in selected}
|
|
85
|
+
|
|
86
|
+
def add_edge(source: str, destination: str, edge_type: str) -> None:
|
|
87
|
+
key = (source, destination, edge_type)
|
|
88
|
+
if key not in seen_edges:
|
|
89
|
+
seen_edges.add(key)
|
|
90
|
+
edges.append({"from": source, "to": destination, "type": edge_type})
|
|
91
|
+
|
|
92
|
+
for note in selected:
|
|
93
|
+
source = node_id(note.path)
|
|
94
|
+
nodes.append(document_node(note, source))
|
|
95
|
+
for target, resolved, ambiguous in wikilink_resolutions(note, vault_index):
|
|
96
|
+
if resolved is None or ambiguous:
|
|
97
|
+
unresolved.append({
|
|
98
|
+
"from": source,
|
|
99
|
+
"target": target,
|
|
100
|
+
"type": "wikilink",
|
|
101
|
+
"reason": "ambiguous" if ambiguous else "missing",
|
|
102
|
+
})
|
|
103
|
+
continue
|
|
104
|
+
destination = node_id(resolved)
|
|
105
|
+
if canonical_only and destination not in selected_ids:
|
|
106
|
+
continue
|
|
107
|
+
add_edge(source, destination, "wikilink")
|
|
108
|
+
|
|
109
|
+
if source != "00-context/evidence-register":
|
|
110
|
+
for evidence_id in note.cited_evidence:
|
|
111
|
+
add_edge(source, f"evidence:{evidence_id}", "evidence")
|
|
112
|
+
|
|
113
|
+
supersedes = str(note.front.get("supersedes") or "").strip()
|
|
114
|
+
if DECISION_ID_RE.fullmatch(supersedes):
|
|
115
|
+
destination = path_by_decision.get(supersedes)
|
|
116
|
+
if supersedes in duplicate_decisions:
|
|
117
|
+
unresolved.append({"from": source, "target": supersedes, "type": "supersedes", "reason": "ambiguous"})
|
|
118
|
+
elif destination is None:
|
|
119
|
+
unresolved.append({"from": source, "target": supersedes, "type": "supersedes", "reason": "missing"})
|
|
120
|
+
elif not canonical_only or destination in selected_ids:
|
|
121
|
+
# A canonical-only export must not point at a node it left out.
|
|
122
|
+
add_edge(source, destination, "supersedes")
|
|
123
|
+
|
|
124
|
+
active, retired, _ = evidence_register(root)
|
|
125
|
+
referenced_evidence = sorted({edge[1].split(":", 1)[1] for edge in seen_edges if edge[2] == "evidence"})
|
|
126
|
+
for evidence_id in referenced_evidence:
|
|
127
|
+
row = active.get(evidence_id) or retired.get(evidence_id)
|
|
128
|
+
nodes.append({
|
|
129
|
+
"id": f"evidence:{evidence_id}",
|
|
130
|
+
"kind": "evidence",
|
|
131
|
+
"evidence_id": evidence_id,
|
|
132
|
+
"title": (row or {}).get("source") or evidence_id,
|
|
133
|
+
"status": "active" if evidence_id in active else "retired" if evidence_id in retired else "missing",
|
|
134
|
+
"type": (row or {}).get("type") or "",
|
|
135
|
+
"owner": "",
|
|
136
|
+
"sensitivity": "",
|
|
137
|
+
"source_of_truth": False,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
nodes.sort(key=lambda n: n["id"])
|
|
141
|
+
edges.sort(key=lambda e: (e["from"], e["to"], e["type"]))
|
|
142
|
+
unresolved.sort(key=lambda e: (e["from"], e["target"], e.get("type", "")))
|
|
143
|
+
edges_by_type: dict[str, int] = {}
|
|
144
|
+
for edge in edges:
|
|
145
|
+
edges_by_type[edge["type"]] = edges_by_type.get(edge["type"], 0) + 1
|
|
146
|
+
return {
|
|
147
|
+
"contract_version": 1,
|
|
148
|
+
"nodes": nodes,
|
|
149
|
+
"edges": edges,
|
|
150
|
+
"unresolved": unresolved,
|
|
151
|
+
"stats": {
|
|
152
|
+
"nodes": len(nodes),
|
|
153
|
+
"documents": sum(1 for node in nodes if node["kind"] == "document"),
|
|
154
|
+
"evidence": sum(1 for node in nodes if node["kind"] == "evidence"),
|
|
155
|
+
"edges": len(edges),
|
|
156
|
+
"edges_by_type": dict(sorted(edges_by_type.items())),
|
|
157
|
+
"unresolved": len(unresolved),
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Characters that end a line for some reader of the output: Python's
|
|
163
|
+
# ``splitlines`` and most editors, not only Graphviz.
|
|
164
|
+
_LINE_BREAKS = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]")
|
|
165
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _dot_escape(value: str) -> str:
|
|
169
|
+
"""Quote-safe text for a DOT string, kept on one physical line.
|
|
170
|
+
|
|
171
|
+
A line break becomes Graphviz's ``\\n`` label escape, so a multi-line
|
|
172
|
+
title still renders on two lines without splitting the statement; any
|
|
173
|
+
other control character becomes a space.
|
|
174
|
+
"""
|
|
175
|
+
text = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
176
|
+
return _CONTROL.sub(" ", _LINE_BREAKS.sub("\\\\n", text))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def as_obsidian(graph: dict) -> dict:
|
|
180
|
+
"""Export a Graph-Analysis-friendly JSON view of the vault.
|
|
181
|
+
|
|
182
|
+
Obsidian's own ``.obsidian/graph.json`` stores *view settings*, not nodes.
|
|
183
|
+
This payload is a portable nodes/edges document that plugins and scripts can
|
|
184
|
+
ingest; write it beside the vault (for example ``.whykit/graph.json``).
|
|
185
|
+
"""
|
|
186
|
+
return {
|
|
187
|
+
"format": "whykit.obsidian-graph/v1",
|
|
188
|
+
"contract_version": 1,
|
|
189
|
+
"directed": True,
|
|
190
|
+
"nodes": [
|
|
191
|
+
{
|
|
192
|
+
"id": node["id"],
|
|
193
|
+
"label": node.get("title") or node["id"],
|
|
194
|
+
"kind": node.get("kind") or "document",
|
|
195
|
+
"path": None if node.get("kind") == "evidence" else f"{node['id']}.md",
|
|
196
|
+
}
|
|
197
|
+
for node in graph["nodes"]
|
|
198
|
+
],
|
|
199
|
+
"edges": [
|
|
200
|
+
{
|
|
201
|
+
"from": edge["from"],
|
|
202
|
+
"to": edge["to"],
|
|
203
|
+
"label": edge.get("type") or "wikilink",
|
|
204
|
+
}
|
|
205
|
+
for edge in graph["edges"]
|
|
206
|
+
],
|
|
207
|
+
"stats": graph.get("stats") or {},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def as_dot(graph: dict) -> str:
|
|
212
|
+
lines = ["digraph whykit {", " rankdir=LR;"]
|
|
213
|
+
for node in graph["nodes"]:
|
|
214
|
+
shape = "box" if node.get("kind") == "document" else "ellipse"
|
|
215
|
+
lines.append(f' "{_dot_escape(node["id"])}" [shape={shape}, label="{_dot_escape(node["title"])}"];')
|
|
216
|
+
for edge in graph["edges"]:
|
|
217
|
+
label = edge.get("type", "wikilink")
|
|
218
|
+
lines.append(
|
|
219
|
+
f' "{_dot_escape(edge["from"])}" -> "{_dot_escape(edge["to"])}" '
|
|
220
|
+
f'[label="{_dot_escape(label)}"];'
|
|
221
|
+
)
|
|
222
|
+
lines.append("}")
|
|
223
|
+
return "\n".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _mermaid_label(value: str) -> str:
|
|
227
|
+
# Mermaid has no backslash escapes inside quoted labels; it uses HTML-style
|
|
228
|
+
# entity codes. Newlines would end the statement, so they become spaces.
|
|
229
|
+
text = " ".join(_CONTROL.sub(" ", str(value)).split())
|
|
230
|
+
for raw, code in (("#", "#35;"), ('"', "#quot;"), ("&", "#amp;"), ("<", "#lt;"), (">", "#gt;")):
|
|
231
|
+
text = text.replace(raw, code)
|
|
232
|
+
return text
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def as_mermaid(graph: dict) -> str:
|
|
236
|
+
"""Render the graph as a Mermaid flowchart (GitHub and Obsidian render it inline).
|
|
237
|
+
|
|
238
|
+
Node IDs are generated (``n0``, ``n1``…) because vault paths contain
|
|
239
|
+
characters Mermaid treats as syntax; the path is kept in the label.
|
|
240
|
+
"""
|
|
241
|
+
ids = {node["id"]: f"n{index}" for index, node in enumerate(graph["nodes"])}
|
|
242
|
+
lines = ["flowchart LR"]
|
|
243
|
+
for node in graph["nodes"]:
|
|
244
|
+
label = _mermaid_label(node.get("title") or node["id"])
|
|
245
|
+
if node.get("kind") == "evidence":
|
|
246
|
+
lines.append(f' {ids[node["id"]]}(["{label}"])')
|
|
247
|
+
else:
|
|
248
|
+
lines.append(f' {ids[node["id"]]}["{label}<br/><small>{_mermaid_label(node["id"])}</small>"]')
|
|
249
|
+
arrows = {"wikilink": "-->", "evidence": "-.->", "supersedes": "==>"}
|
|
250
|
+
for edge in graph["edges"]:
|
|
251
|
+
source, target = ids.get(edge["from"]), ids.get(edge["to"])
|
|
252
|
+
if source is None or target is None:
|
|
253
|
+
continue
|
|
254
|
+
edge_type = edge.get("type") or "wikilink"
|
|
255
|
+
lines.append(f" {source} {arrows.get(edge_type, '-->')}|{edge_type}| {target}")
|
|
256
|
+
classes = {
|
|
257
|
+
"evidence": [ids[n["id"]] for n in graph["nodes"] if n.get("kind") == "evidence" and n.get("status") == "active"],
|
|
258
|
+
"unusable": [ids[n["id"]] for n in graph["nodes"] if n.get("kind") == "evidence" and n.get("status") != "active"],
|
|
259
|
+
"decision": [ids[n["id"]] for n in graph["nodes"] if n.get("kind") == "document" and n.get("decision_id")],
|
|
260
|
+
}
|
|
261
|
+
styles = {
|
|
262
|
+
"evidence": "stroke-dasharray: 4 2",
|
|
263
|
+
"unusable": "stroke-dasharray: 4 2,stroke:#b42318,color:#b42318",
|
|
264
|
+
"decision": "stroke-width:2px",
|
|
265
|
+
}
|
|
266
|
+
for name, members in classes.items():
|
|
267
|
+
if members:
|
|
268
|
+
lines.append(f" classDef {name} {styles[name]}")
|
|
269
|
+
lines.append(f" class {','.join(members)} {name}")
|
|
270
|
+
return "\n".join(lines)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main(argv: list[str] | None = None) -> int:
|
|
274
|
+
parser = argparse.ArgumentParser(prog="whykit graph", description="Export typed WhyKit relations as JSON, Graphviz DOT or Mermaid.")
|
|
275
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
276
|
+
parser.add_argument("--format", choices=("json", "dot", "mermaid", "obsidian"), default="json")
|
|
277
|
+
parser.add_argument("--canonical-only", action="store_true", help="include only approved source-of-truth documents (plus evidence they cite)")
|
|
278
|
+
parser.add_argument("--output", help="write to this path (vault-relative or absolute); default: stdout")
|
|
279
|
+
args = parser.parse_args(argv)
|
|
280
|
+
requested_root = Path(args.root).expanduser().absolute() if args.root else find_vault_root()
|
|
281
|
+
root = requested_root.resolve() if requested_root is not None else None
|
|
282
|
+
if root is None or not is_vault_root(root):
|
|
283
|
+
return vault_not_found(args.root, json_mode=args.format == "json")
|
|
284
|
+
graph = build_graph(root, canonical_only=args.canonical_only)
|
|
285
|
+
payload = as_obsidian(graph) if args.format == "obsidian" else graph
|
|
286
|
+
rendered = None
|
|
287
|
+
if args.format == "dot":
|
|
288
|
+
rendered = as_dot(graph)
|
|
289
|
+
elif args.format == "mermaid":
|
|
290
|
+
rendered = as_mermaid(graph)
|
|
291
|
+
if args.output:
|
|
292
|
+
from .io import atomic_write_json, atomic_write_text, safe_export_target
|
|
293
|
+
target = Path(args.output).expanduser()
|
|
294
|
+
try:
|
|
295
|
+
if target.is_absolute():
|
|
296
|
+
try:
|
|
297
|
+
relative = target.relative_to(root)
|
|
298
|
+
except ValueError:
|
|
299
|
+
relative = target.relative_to(requested_root)
|
|
300
|
+
else:
|
|
301
|
+
relative = target
|
|
302
|
+
target = safe_export_target(root, relative)
|
|
303
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
304
|
+
return emit_error(
|
|
305
|
+
"unsafe_path",
|
|
306
|
+
f"--output must be a safe path inside the vault: {exc}",
|
|
307
|
+
json_mode=args.format == "json",
|
|
308
|
+
)
|
|
309
|
+
if rendered is None:
|
|
310
|
+
atomic_write_json(target, payload)
|
|
311
|
+
else:
|
|
312
|
+
atomic_write_text(target, rendered + "\n")
|
|
313
|
+
print(f"wrote {rel(root, target)}")
|
|
314
|
+
elif rendered is None:
|
|
315
|
+
emit_json(payload)
|
|
316
|
+
else:
|
|
317
|
+
emit_machine(rendered)
|
|
318
|
+
return 0
|