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/sensitivity.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Shared fail-closed sensitivity classification for readers and exports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
from collections import Counter, defaultdict, deque
|
|
6
|
+
|
|
7
|
+
SENSITIVITY_LEVEL = {
|
|
8
|
+
"public": 0,
|
|
9
|
+
"internal": 1,
|
|
10
|
+
"confidential": 2,
|
|
11
|
+
"restricted": 3,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
UNREADABLE_LEVEL = 99
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sensitivity_level(value: object) -> int:
|
|
18
|
+
key = str(value or "internal").lower() or "internal"
|
|
19
|
+
return SENSITIVITY_LEVEL.get(key, 99)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def note_sensitivity_level(note: Any) -> int:
|
|
23
|
+
"""The level a note is filtered at, failing closed when its label is unreadable.
|
|
24
|
+
|
|
25
|
+
A note without front matter, or with valid front matter that omits the
|
|
26
|
+
key, is `internal` (the documented default). Front matter that does not
|
|
27
|
+
parse, or a label spelt with different case or spacing (`Sensitivity:`),
|
|
28
|
+
may hide a stricter label WhyKit cannot read, so such a note is treated as
|
|
29
|
+
above every ceiling.
|
|
30
|
+
"""
|
|
31
|
+
if note.front_error:
|
|
32
|
+
return UNREADABLE_LEVEL
|
|
33
|
+
front = note.front
|
|
34
|
+
value = front.get("sensitivity")
|
|
35
|
+
level = SENSITIVITY_LEVEL["internal"]
|
|
36
|
+
if "sensitivity" in front:
|
|
37
|
+
level = _sensitivity_level(value) if isinstance(value, str) and value else UNREADABLE_LEVEL
|
|
38
|
+
for key in front:
|
|
39
|
+
if key != "sensitivity" and str(key).strip().casefold() == "sensitivity":
|
|
40
|
+
level = UNREADABLE_LEVEL
|
|
41
|
+
return level
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def evidence_register_notes(index: Any) -> list[Any]:
|
|
45
|
+
"""Match captured register paths, including .MD and case aliases."""
|
|
46
|
+
return [note for note in index.notes
|
|
47
|
+
if index.relative(note.path).casefold() == "00-context/evidence-register.md"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def classified_evidence(index: Any) -> tuple[dict, dict, list, bool]:
|
|
51
|
+
"""Classify rows from the same captured bytes as their register's label."""
|
|
52
|
+
registers = evidence_register_notes(index)
|
|
53
|
+
if len(registers) != 1:
|
|
54
|
+
# Ambiguous registers must not expose any rows or raw candidate bodies.
|
|
55
|
+
return {}, {}, [], bool(registers)
|
|
56
|
+
return classify_evidence_register(registers[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def classify_evidence_register(note: Any) -> tuple[dict, dict, list, bool]:
|
|
60
|
+
"""Classify one captured register with duplicate and replacement-chain floors."""
|
|
61
|
+
from .lint import _parse_evidence_register_text
|
|
62
|
+
from .tables import split_table_row
|
|
63
|
+
|
|
64
|
+
floor = note_sensitivity_level(note)
|
|
65
|
+
names = {level: name for name, level in SENSITIVITY_LEVEL.items()}
|
|
66
|
+
active, retired, occurrences = _parse_evidence_register_text(note.text)
|
|
67
|
+
per_row = any("sensitivity" in row for row in (*active.values(), *retired.values()))
|
|
68
|
+
for line in note.text.splitlines():
|
|
69
|
+
if line.lstrip().startswith("|"):
|
|
70
|
+
cells = [cell.casefold() for cell in split_table_row(line)]
|
|
71
|
+
if cells and cells[0] == "id" and ("sensitivity" in cells or len(cells) not in {5, 7}):
|
|
72
|
+
per_row = True
|
|
73
|
+
duplicates = {key for key, count in Counter(key for key, _ in occurrences).items() if count > 1}
|
|
74
|
+
per_row = per_row or bool(duplicates)
|
|
75
|
+
for rows in (active, retired):
|
|
76
|
+
for key, row in rows.items():
|
|
77
|
+
level = max(floor, SENSITIVITY_LEVEL.get(row["sensitivity"], UNREADABLE_LEVEL)) if "sensitivity" in row else floor
|
|
78
|
+
if key in duplicates:
|
|
79
|
+
level = UNREADABLE_LEVEL
|
|
80
|
+
row["sensitivity"] = names.get(level, "unclassified")
|
|
81
|
+
rows = {**active, **retired}
|
|
82
|
+
dependents: dict[str, list[str]] = defaultdict(list)
|
|
83
|
+
for key, row in retired.items():
|
|
84
|
+
replacement = row.get("replaced_by")
|
|
85
|
+
if replacement in rows:
|
|
86
|
+
dependents[replacement].append(key)
|
|
87
|
+
pending = deque(rows)
|
|
88
|
+
while pending:
|
|
89
|
+
key = pending.popleft()
|
|
90
|
+
level = SENSITIVITY_LEVEL.get(rows[key]["sensitivity"], UNREADABLE_LEVEL)
|
|
91
|
+
for dependent in dependents[key]:
|
|
92
|
+
if level > SENSITIVITY_LEVEL.get(rows[dependent]["sensitivity"], UNREADABLE_LEVEL):
|
|
93
|
+
rows[dependent]["sensitivity"] = names.get(level, "unclassified")
|
|
94
|
+
pending.append(dependent)
|
|
95
|
+
return active, retired, occurrences, per_row
|
whykit/snapshot.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Deterministic vault snapshots and drift verification."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .io import consistent_read
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .contract import emit_error, vault_not_found
|
|
14
|
+
from .graph import build_graph
|
|
15
|
+
from .io import atomic_write_text, safe_export_target
|
|
16
|
+
from .lint import collect_markdown, find_vault_root, is_markdown_name, is_vault_root, load_note, rel, path_cache
|
|
17
|
+
from .status import build_status
|
|
18
|
+
from .vault_index import VaultIndex
|
|
19
|
+
from .console import emit_machine
|
|
20
|
+
|
|
21
|
+
SNAPSHOT_FORMAT_V1 = "whykit.snapshot/v1"
|
|
22
|
+
SNAPSHOT_FORMAT_V2 = "whykit.snapshot/v2"
|
|
23
|
+
# What `whykit snapshot` writes. verify-snapshot reads every format in
|
|
24
|
+
# SNAPSHOT_FORMATS and re-hashes the vault the way the baseline was hashed.
|
|
25
|
+
SNAPSHOT_FORMAT = SNAPSHOT_FORMAT_V2
|
|
26
|
+
SNAPSHOT_FORMATS = {"v1": SNAPSHOT_FORMAT_V1, "v2": SNAPSHOT_FORMAT_V2}
|
|
27
|
+
|
|
28
|
+
# v1 hashes raw bytes, so a checkout with `core.autocrlf=true` (Windows) or an
|
|
29
|
+
# editor that adds a byte-order mark reports every file as changed. v2 hashes
|
|
30
|
+
# the text after this normalization, and records it in the snapshot so a
|
|
31
|
+
# reader never has to guess how a digest was computed.
|
|
32
|
+
NORMALIZATION_V2 = {"utf8_bom": "strip", "line_endings": "crlf-to-lf"}
|
|
33
|
+
|
|
34
|
+
_BOM = b"\xef\xbb\xbf"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_content(data: bytes) -> bytes:
|
|
38
|
+
"""The bytes a v2 snapshot hashes: one leading UTF-8 BOM removed, CRLF as LF.
|
|
39
|
+
|
|
40
|
+
Lone CR and every other byte are kept, so any edit a reader could see is
|
|
41
|
+
still a change.
|
|
42
|
+
"""
|
|
43
|
+
if data.startswith(_BOM):
|
|
44
|
+
data = data[len(_BOM):]
|
|
45
|
+
return data.replace(b"\r\n", b"\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sha256(path: Path) -> str:
|
|
49
|
+
digest = hashlib.sha256()
|
|
50
|
+
with path.open("rb") as handle:
|
|
51
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
52
|
+
digest.update(chunk)
|
|
53
|
+
return digest.hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _digest(path: Path, snapshot_format: str) -> tuple[str, int]:
|
|
57
|
+
"""(sha256, byte count) of ``path`` as ``snapshot_format`` defines them."""
|
|
58
|
+
if snapshot_format == SNAPSHOT_FORMAT_V1:
|
|
59
|
+
return _sha256(path), path.stat().st_size
|
|
60
|
+
data = normalize_content(path.read_bytes())
|
|
61
|
+
return hashlib.sha256(data).hexdigest(), len(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _snapshot_files(root: Path, vault: VaultIndex | None = None) -> list[Path]:
|
|
65
|
+
files = {note.path for note in vault.notes} if vault is not None else set(collect_markdown(root, []))
|
|
66
|
+
config = root / "whykit.toml"
|
|
67
|
+
if config.is_file():
|
|
68
|
+
files.add(config)
|
|
69
|
+
relative = vault.relative if vault is not None else (lambda path: rel(root, path))
|
|
70
|
+
return sorted(files, key=relative)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _file_entry(
|
|
74
|
+
root: Path, path: Path, vault: VaultIndex | None = None, *, snapshot_format: str = SNAPSHOT_FORMAT,
|
|
75
|
+
) -> dict[str, Any]:
|
|
76
|
+
sha256, size = _digest(path, snapshot_format)
|
|
77
|
+
entry: dict[str, Any] = {
|
|
78
|
+
"path": vault.relative(path) if vault is not None else rel(root, path),
|
|
79
|
+
"sha256": sha256,
|
|
80
|
+
"bytes": size,
|
|
81
|
+
}
|
|
82
|
+
if is_markdown_name(path.name):
|
|
83
|
+
note = (vault.note_for(path) if vault is not None else None) or load_note(path)
|
|
84
|
+
entry.update({
|
|
85
|
+
"title": str(note.front.get("title") or path.stem),
|
|
86
|
+
"type": str(note.front.get("type") or ""),
|
|
87
|
+
"status": str(note.front.get("status") or ""),
|
|
88
|
+
"owner": str(note.front.get("owner") or ""),
|
|
89
|
+
"sensitivity": str(note.front.get("sensitivity") or ""),
|
|
90
|
+
"source_of_truth": note.front.get("source_of_truth") is True,
|
|
91
|
+
"decision_id": str(note.front.get("decision_id") or "") or None,
|
|
92
|
+
"review_by": str(note.front.get("review_by") or "") or None,
|
|
93
|
+
})
|
|
94
|
+
return entry
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _snapshot_id(entries: list[dict[str, Any]]) -> str:
|
|
98
|
+
digest = hashlib.sha256()
|
|
99
|
+
for item in entries:
|
|
100
|
+
digest.update(item["path"].encode("utf-8"))
|
|
101
|
+
digest.update(b"\0")
|
|
102
|
+
digest.update(item["sha256"].encode("ascii"))
|
|
103
|
+
digest.update(b"\n")
|
|
104
|
+
return digest.hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@consistent_read
|
|
108
|
+
@path_cache()
|
|
109
|
+
def build_snapshot(
|
|
110
|
+
root: Path, *, today: dt.date | None = None, snapshot_format: str = SNAPSHOT_FORMAT,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
if snapshot_format not in SNAPSHOT_FORMATS.values():
|
|
113
|
+
raise ValueError(f"unsupported snapshot format: {snapshot_format!r}")
|
|
114
|
+
today = today or dt.date.today()
|
|
115
|
+
root = root.resolve()
|
|
116
|
+
# One parse feeds the file table, status and graph; each used to re-read
|
|
117
|
+
# every note, which tripled the cost of a snapshot on a large vault.
|
|
118
|
+
vault = VaultIndex.load(root)
|
|
119
|
+
entries = [
|
|
120
|
+
_file_entry(root, path, vault, snapshot_format=snapshot_format)
|
|
121
|
+
for path in _snapshot_files(root, vault)
|
|
122
|
+
]
|
|
123
|
+
status = build_status(root, today=today, vault=vault)
|
|
124
|
+
graph = build_graph(root, vault=vault)
|
|
125
|
+
edges_by_type: dict[str, int] = {}
|
|
126
|
+
for edge in graph.get("edges", []):
|
|
127
|
+
edge_type = str(edge.get("type") or "wikilink")
|
|
128
|
+
edges_by_type[edge_type] = edges_by_type.get(edge_type, 0) + 1
|
|
129
|
+
payload: dict[str, Any] = {
|
|
130
|
+
"format": snapshot_format,
|
|
131
|
+
"contract_version": 1,
|
|
132
|
+
}
|
|
133
|
+
if snapshot_format != SNAPSHOT_FORMAT_V1:
|
|
134
|
+
payload["normalization"] = dict(NORMALIZATION_V2)
|
|
135
|
+
return payload | {
|
|
136
|
+
"snapshot_id": _snapshot_id(entries),
|
|
137
|
+
"as_of": today.isoformat(),
|
|
138
|
+
"files": entries,
|
|
139
|
+
"summary": {
|
|
140
|
+
"documents": status["documents"],
|
|
141
|
+
"canonical": status["canonical"],
|
|
142
|
+
"decisions": status["decisions"],
|
|
143
|
+
"evidence_active": status["evidence_active"],
|
|
144
|
+
"evidence_retired": status["evidence_retired"],
|
|
145
|
+
"errors": status["errors"],
|
|
146
|
+
"warnings": status["warnings"],
|
|
147
|
+
"review_due": len(status["review_queue"]),
|
|
148
|
+
"graph_nodes": len(graph.get("nodes", [])),
|
|
149
|
+
"graph_edges": len(graph.get("edges", [])),
|
|
150
|
+
"unresolved_links": len(graph.get("unresolved", [])),
|
|
151
|
+
"edges_by_type": dict(sorted(edges_by_type.items())),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@consistent_read
|
|
157
|
+
@path_cache()
|
|
158
|
+
def compare_snapshot(root: Path, baseline: dict[str, Any], *, today: dt.date | None = None) -> dict[str, Any]:
|
|
159
|
+
snapshot_format = baseline.get("format")
|
|
160
|
+
if snapshot_format not in SNAPSHOT_FORMATS.values():
|
|
161
|
+
supported = ", ".join(sorted(SNAPSHOT_FORMATS.values()))
|
|
162
|
+
raise ValueError(f"unsupported snapshot format: {snapshot_format!r} (this WhyKit reads {supported})")
|
|
163
|
+
if snapshot_format == SNAPSHOT_FORMAT_V2 and baseline.get("normalization") != NORMALIZATION_V2:
|
|
164
|
+
# Digests computed under another normalization cannot be compared;
|
|
165
|
+
# reporting every file as changed would be a false alarm.
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"unsupported snapshot normalization: {baseline.get('normalization')!r} "
|
|
168
|
+
f"(this WhyKit hashes {SNAPSHOT_FORMAT_V2} with {NORMALIZATION_V2})"
|
|
169
|
+
)
|
|
170
|
+
# Re-hash the vault the way the baseline was hashed: a v1 baseline keeps
|
|
171
|
+
# its exact byte semantics, so upgrading WhyKit never turns MATCH into DRIFT.
|
|
172
|
+
current = build_snapshot(root, today=today, snapshot_format=str(snapshot_format))
|
|
173
|
+
before = {item["path"]: item for item in baseline.get("files", []) if isinstance(item, dict) and item.get("path")}
|
|
174
|
+
after = {item["path"]: item for item in current["files"]}
|
|
175
|
+
added = sorted(set(after) - set(before))
|
|
176
|
+
removed = sorted(set(before) - set(after))
|
|
177
|
+
changed = sorted(path for path in set(before) & set(after) if before[path].get("sha256") != after[path].get("sha256"))
|
|
178
|
+
metadata_changed = sorted(
|
|
179
|
+
path for path in set(before) & set(after)
|
|
180
|
+
if before[path].get("sha256") == after[path].get("sha256") and before[path] != after[path]
|
|
181
|
+
)
|
|
182
|
+
content_matches = not (added or removed or changed or metadata_changed)
|
|
183
|
+
baseline_summary = baseline.get("summary", {}) if isinstance(baseline.get("summary"), dict) else {}
|
|
184
|
+
current_summary = current["summary"]
|
|
185
|
+
health_keys = ("errors", "warnings", "review_due", "unresolved_links")
|
|
186
|
+
health_delta = {
|
|
187
|
+
key: {
|
|
188
|
+
"before": baseline_summary.get(key),
|
|
189
|
+
"after": current_summary.get(key),
|
|
190
|
+
}
|
|
191
|
+
for key in health_keys
|
|
192
|
+
if baseline_summary.get(key) != current_summary.get(key)
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
"contract_version": 1,
|
|
196
|
+
"format": snapshot_format,
|
|
197
|
+
"baseline_snapshot_id": baseline.get("snapshot_id"),
|
|
198
|
+
"current_snapshot_id": current["snapshot_id"],
|
|
199
|
+
# `matches` remains content identity for backwards compatibility. A
|
|
200
|
+
# vault can become review-due as time advances without a byte changing.
|
|
201
|
+
"matches": content_matches,
|
|
202
|
+
"content_matches": content_matches,
|
|
203
|
+
"health_changed": bool(health_delta),
|
|
204
|
+
"health_delta": health_delta,
|
|
205
|
+
"added": added,
|
|
206
|
+
"removed": removed,
|
|
207
|
+
"changed": changed,
|
|
208
|
+
"metadata_changed": metadata_changed,
|
|
209
|
+
"baseline_summary": baseline_summary,
|
|
210
|
+
"current_summary": current_summary,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _load_baseline(path: Path) -> dict[str, Any]:
|
|
215
|
+
hint = "hint: create a baseline with `whykit snapshot --output <file>`"
|
|
216
|
+
try:
|
|
217
|
+
text = path.read_text(encoding="utf-8")
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
raise ValueError(f"snapshot not found: {path}\n{hint}") from None
|
|
220
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
221
|
+
raise ValueError(f"cannot read snapshot {path}: {getattr(exc, 'strerror', None) or exc}") from None
|
|
222
|
+
try:
|
|
223
|
+
payload = json.loads(text)
|
|
224
|
+
except json.JSONDecodeError as exc:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"not a WhyKit snapshot (invalid JSON at line {exc.lineno}, column {exc.colno}): {path}\n{hint}"
|
|
227
|
+
) from None
|
|
228
|
+
if not isinstance(payload, dict):
|
|
229
|
+
raise ValueError(f"not a WhyKit snapshot (expected a JSON object): {path}\n{hint}")
|
|
230
|
+
return payload
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main_snapshot(argv: list[str] | None = None) -> int:
|
|
234
|
+
parser = argparse.ArgumentParser(prog="whykit snapshot", description="Create a deterministic content snapshot of a WhyKit vault.")
|
|
235
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
236
|
+
parser.add_argument("--today", help="evaluate health/review status as of this ISO date")
|
|
237
|
+
parser.add_argument("--output", help="write JSON to this path instead of stdout")
|
|
238
|
+
parser.add_argument("--compact", action="store_true", help="emit compact JSON")
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"--format", dest="snapshot_format", choices=tuple(SNAPSHOT_FORMATS), default="v2",
|
|
241
|
+
help="v2 (default) hashes text with CRLF and a UTF-8 BOM normalized; v1 hashes raw bytes",
|
|
242
|
+
)
|
|
243
|
+
args = parser.parse_args(argv)
|
|
244
|
+
# Without --output the command's stdout is JSON, so failures are too.
|
|
245
|
+
json_mode = not args.output
|
|
246
|
+
requested_root = Path(args.root).expanduser().absolute() if args.root else find_vault_root()
|
|
247
|
+
root = requested_root.resolve() if requested_root is not None else None
|
|
248
|
+
if root is None or not is_vault_root(root):
|
|
249
|
+
return vault_not_found(args.root, json_mode=json_mode)
|
|
250
|
+
today = None
|
|
251
|
+
if args.today:
|
|
252
|
+
try:
|
|
253
|
+
today = dt.date.fromisoformat(args.today)
|
|
254
|
+
except ValueError:
|
|
255
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=json_mode)
|
|
256
|
+
payload = build_snapshot(root, today=today, snapshot_format=SNAPSHOT_FORMATS[args.snapshot_format])
|
|
257
|
+
rendered = json.dumps(payload, ensure_ascii=False, indent=None if args.compact else 2, sort_keys=args.compact)
|
|
258
|
+
if args.output:
|
|
259
|
+
target = Path(args.output).expanduser()
|
|
260
|
+
try:
|
|
261
|
+
if target.is_absolute():
|
|
262
|
+
try:
|
|
263
|
+
relative = target.relative_to(root)
|
|
264
|
+
except ValueError:
|
|
265
|
+
relative = target.relative_to(requested_root)
|
|
266
|
+
else:
|
|
267
|
+
relative = target
|
|
268
|
+
target = safe_export_target(root, relative)
|
|
269
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
270
|
+
return emit_error("unsafe_path", f"--output must be a safe path inside the vault: {exc}", json_mode=json_mode)
|
|
271
|
+
atomic_write_text(target, rendered + "\n")
|
|
272
|
+
print(f"snapshot {payload['snapshot_id']}: {rel(root, target)}")
|
|
273
|
+
else:
|
|
274
|
+
emit_machine(rendered)
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main_verify(argv: list[str] | None = None) -> int:
|
|
279
|
+
parser = argparse.ArgumentParser(prog="whykit verify-snapshot", description="Compare the vault with a prior WhyKit snapshot.")
|
|
280
|
+
parser.add_argument("snapshot", help="baseline snapshot JSON")
|
|
281
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
282
|
+
parser.add_argument("--today", help="evaluate health/review status as of this ISO date")
|
|
283
|
+
parser.add_argument("--json", action="store_true")
|
|
284
|
+
args = parser.parse_args(argv)
|
|
285
|
+
root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
|
|
286
|
+
if root is None or not is_vault_root(root):
|
|
287
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
288
|
+
snapshot_path = Path(args.snapshot).expanduser()
|
|
289
|
+
if not snapshot_path.is_absolute():
|
|
290
|
+
snapshot_path = (root / snapshot_path).resolve()
|
|
291
|
+
today = None
|
|
292
|
+
if args.today:
|
|
293
|
+
try:
|
|
294
|
+
today = dt.date.fromisoformat(args.today)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
|
|
297
|
+
try:
|
|
298
|
+
report = compare_snapshot(root, _load_baseline(snapshot_path), today=today)
|
|
299
|
+
except ValueError as exc:
|
|
300
|
+
return emit_error("invalid_argument", str(exc), json_mode=args.json)
|
|
301
|
+
if args.json:
|
|
302
|
+
emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
|
|
303
|
+
else:
|
|
304
|
+
state = "MATCH" if report["matches"] else "DRIFT"
|
|
305
|
+
print(f"snapshot verification: {state}")
|
|
306
|
+
print(f" baseline {report.get('baseline_snapshot_id') or '—'}")
|
|
307
|
+
print(f" current {report['current_snapshot_id']}")
|
|
308
|
+
for key in ("added", "removed", "changed", "metadata_changed"):
|
|
309
|
+
values = report[key]
|
|
310
|
+
print(f" {key:<16} {len(values)}")
|
|
311
|
+
for value in values[:30]:
|
|
312
|
+
print(f" {value}")
|
|
313
|
+
if len(values) > 30:
|
|
314
|
+
print(f" … {len(values) - 30} more")
|
|
315
|
+
if report["health_changed"]:
|
|
316
|
+
print(" health changes")
|
|
317
|
+
for key, delta in report["health_delta"].items():
|
|
318
|
+
print(f" {key}: {delta['before']} -> {delta['after']}")
|
|
319
|
+
return 0 if report["matches"] else 1
|
whykit/status.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Human and machine-readable vault status."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .io import consistent_read
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .contract import emit_error, vault_not_found
|
|
13
|
+
from .config import ConfigError, load_config
|
|
14
|
+
from .lint import DECISION_ID_RE, evidence_register, find_vault_root, is_vault_root, lint, path_cache
|
|
15
|
+
from .vault_index import VaultIndex
|
|
16
|
+
from .console import emit_machine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_decision_record(front: dict) -> bool:
|
|
20
|
+
"""True for a decision record with a real D-NNN.
|
|
21
|
+
|
|
22
|
+
The decision log and the decision template share `type: decision` but are
|
|
23
|
+
not decisions; counting them made `status` disagree with `trace` and the log.
|
|
24
|
+
"""
|
|
25
|
+
return (
|
|
26
|
+
front.get("type") == "decision"
|
|
27
|
+
and front.get("status") != "template"
|
|
28
|
+
and DECISION_ID_RE.fullmatch(str(front.get("decision_id") or "").strip()) is not None
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_review_queue(vault: VaultIndex, *, today: dt.date, due_days: int = 30) -> list[dict]:
|
|
33
|
+
"""Approved records whose `review_by` falls on or before today + due_days.
|
|
34
|
+
|
|
35
|
+
Independent of lint findings, so `review list` does not pay for a full lint.
|
|
36
|
+
"""
|
|
37
|
+
review_queue: list[dict] = []
|
|
38
|
+
horizon = today + dt.timedelta(days=max(0, due_days))
|
|
39
|
+
for note in vault.notes:
|
|
40
|
+
if note.front.get("status") != "approved":
|
|
41
|
+
continue
|
|
42
|
+
raw = str(note.front.get("review_by", "")).strip()
|
|
43
|
+
try:
|
|
44
|
+
date = dt.date.fromisoformat(raw)
|
|
45
|
+
except ValueError:
|
|
46
|
+
continue
|
|
47
|
+
if date <= horizon:
|
|
48
|
+
review_queue.append({
|
|
49
|
+
"path": vault.relative(note.path),
|
|
50
|
+
"title": str(note.front.get("title") or note.path.stem),
|
|
51
|
+
"owner": str(note.front.get("owner") or ""),
|
|
52
|
+
"review_by": date.isoformat(),
|
|
53
|
+
"days": (date - today).days,
|
|
54
|
+
"state": "overdue" if date < today else "due",
|
|
55
|
+
})
|
|
56
|
+
review_queue.sort(key=lambda item: (item["review_by"], item["path"]))
|
|
57
|
+
return review_queue
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@consistent_read
|
|
61
|
+
@path_cache()
|
|
62
|
+
def build_status(
|
|
63
|
+
root: Path,
|
|
64
|
+
*,
|
|
65
|
+
today: dt.date | None = None,
|
|
66
|
+
due_days: int = 30,
|
|
67
|
+
vault: VaultIndex | None = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
today = today or dt.date.today()
|
|
70
|
+
root = root.resolve()
|
|
71
|
+
vault = vault or VaultIndex.load(root)
|
|
72
|
+
notes = vault.notes
|
|
73
|
+
_, findings = lint(root, today=today, vault=vault)
|
|
74
|
+
active_evidence, retired_evidence, _ = evidence_register(root)
|
|
75
|
+
|
|
76
|
+
canonical = [n for n in notes if n.front.get("source_of_truth") is True and n.front.get("status") == "approved"]
|
|
77
|
+
decisions = [n for n in notes if is_decision_record(n.front)]
|
|
78
|
+
review_queue = build_review_queue(vault, today=today, due_days=due_days)
|
|
79
|
+
errors = [f for f in findings if f.level == "error"]
|
|
80
|
+
warnings = [f for f in findings if f.level == "warning"]
|
|
81
|
+
decision_states: dict[str, int] = {}
|
|
82
|
+
document_states: dict[str, int] = {}
|
|
83
|
+
sensitivity_counts: dict[str, int] = {}
|
|
84
|
+
ownership_gaps = 0
|
|
85
|
+
for note in notes:
|
|
86
|
+
status_value = str(note.front.get("status") or "")
|
|
87
|
+
if status_value:
|
|
88
|
+
document_states[status_value] = document_states.get(status_value, 0) + 1
|
|
89
|
+
sensitivity = str(note.front.get("sensitivity") or "")
|
|
90
|
+
if sensitivity:
|
|
91
|
+
sensitivity_counts[sensitivity] = sensitivity_counts.get(sensitivity, 0) + 1
|
|
92
|
+
if is_decision_record(note.front):
|
|
93
|
+
decision_states[status_value or "unknown"] = decision_states.get(status_value or "unknown", 0) + 1
|
|
94
|
+
if status_value == "approved" and str(note.front.get("owner") or "").strip() in {"", "TODO"}:
|
|
95
|
+
ownership_gaps += 1
|
|
96
|
+
by_code: dict[str, int] = {}
|
|
97
|
+
for finding in findings:
|
|
98
|
+
by_code[finding.code] = by_code.get(finding.code, 0) + 1
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"contract_version": 1,
|
|
102
|
+
"root": str(root),
|
|
103
|
+
"as_of": today.isoformat(),
|
|
104
|
+
"documents": len(notes),
|
|
105
|
+
"canonical": len(canonical),
|
|
106
|
+
"decisions": len(decisions),
|
|
107
|
+
"evidence_active": len(active_evidence),
|
|
108
|
+
"evidence_retired": len(retired_evidence),
|
|
109
|
+
"errors": len(errors),
|
|
110
|
+
"warnings": len(warnings),
|
|
111
|
+
"finding_codes": dict(sorted(by_code.items())),
|
|
112
|
+
"review_queue": review_queue,
|
|
113
|
+
"review_overdue": sum(1 for item in review_queue if item["state"] == "overdue"),
|
|
114
|
+
"decision_states": dict(sorted(decision_states.items())),
|
|
115
|
+
"document_states": dict(sorted(document_states.items())),
|
|
116
|
+
"sensitivity": dict(sorted(sensitivity_counts.items())),
|
|
117
|
+
"approved_without_owner": ownership_gaps,
|
|
118
|
+
"findings": [asdict(f) for f in findings],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
parser = argparse.ArgumentParser(prog="whykit status", description="Summarize vault health and upcoming review work.")
|
|
124
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
125
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
126
|
+
parser.add_argument("--today", help="evaluate as of this ISO date")
|
|
127
|
+
parser.add_argument("--due-days", type=int, help="include reviews due within N days (default: policy setting)")
|
|
128
|
+
parser.add_argument("--strict", action="store_true", help="exit 1 when warnings exist as well as errors")
|
|
129
|
+
args = parser.parse_args(argv)
|
|
130
|
+
|
|
131
|
+
if args.today:
|
|
132
|
+
try:
|
|
133
|
+
today = dt.date.fromisoformat(args.today)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
|
|
136
|
+
else:
|
|
137
|
+
today = dt.date.today()
|
|
138
|
+
root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
|
|
139
|
+
if root is None or not is_vault_root(root):
|
|
140
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
141
|
+
try:
|
|
142
|
+
config, _ = load_config(root)
|
|
143
|
+
except ConfigError as exc:
|
|
144
|
+
return emit_error("invalid_config", str(exc), json_mode=args.json)
|
|
145
|
+
due_days = args.due_days if args.due_days is not None else int(config["defaults"]["status_due_days"])
|
|
146
|
+
if due_days < 0:
|
|
147
|
+
return emit_error("invalid_argument", "--due-days must be >= 0", json_mode=args.json)
|
|
148
|
+
report = build_status(root, today=today, due_days=due_days)
|
|
149
|
+
if args.json:
|
|
150
|
+
emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
|
|
151
|
+
else:
|
|
152
|
+
print(f"WhyKit status — {report['as_of']}")
|
|
153
|
+
print(f" documents {report['documents']}")
|
|
154
|
+
print(f" canonical {report['canonical']}")
|
|
155
|
+
print(f" decisions {report['decisions']}")
|
|
156
|
+
print(f" evidence {report['evidence_active']} active / {report['evidence_retired']} retired")
|
|
157
|
+
print(f" lint {report['errors']} error(s), {report['warnings']} warning(s)")
|
|
158
|
+
queue = report["review_queue"]
|
|
159
|
+
print(f" review queue {len(queue)} due within {due_days} day(s); {report['review_overdue']} overdue")
|
|
160
|
+
for item in queue[:20]:
|
|
161
|
+
marker = "OVERDUE" if item["state"] == "overdue" else "DUE"
|
|
162
|
+
print(f" {marker:<7} {item['review_by']} {item['path']} ({item['owner'] or 'no owner'})")
|
|
163
|
+
if len(queue) > 20:
|
|
164
|
+
print(f" … {len(queue) - 20} more")
|
|
165
|
+
if report["errors"] or (args.strict and report["warnings"]):
|
|
166
|
+
return 1
|
|
167
|
+
return 0
|