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/cli.py
ADDED
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The `whykit` command.
|
|
3
|
+
|
|
4
|
+
Zero runtime dependencies beyond the standard library. Every subcommand runs
|
|
5
|
+
in-process rather than shelling out to a sibling script, so the tool behaves the
|
|
6
|
+
same whether it was installed with pip, run with `pipx run`, or called from a
|
|
7
|
+
checkout via `scripts/whykit.py`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import errno
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import traceback
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import NoReturn
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .contract import (
|
|
26
|
+
CONTRACT_VERSION,
|
|
27
|
+
ISSUES_URL,
|
|
28
|
+
argv_wants_json,
|
|
29
|
+
describe_os_error,
|
|
30
|
+
emit_error,
|
|
31
|
+
error_payload,
|
|
32
|
+
vault_not_found,
|
|
33
|
+
)
|
|
34
|
+
from .lint import find_vault_root, is_markdown_name, is_vault_root, iter_markdown, lint as run_lint, rel
|
|
35
|
+
from .completion import SHELLS, render_completion
|
|
36
|
+
from .messages import print_no_vault
|
|
37
|
+
from .vault_index import VaultIndex
|
|
38
|
+
from .console import emit_machine, harden_stdio
|
|
39
|
+
|
|
40
|
+
TEMPLATE_DIR = Path(__file__).resolve().parent / "template"
|
|
41
|
+
|
|
42
|
+
VAULT_GITIGNORE = """# Raw exports, staged before normalization. Never commit these directly.
|
|
43
|
+
.import-staging/
|
|
44
|
+
|
|
45
|
+
# This repository holds knowledge, never credentials.
|
|
46
|
+
.env
|
|
47
|
+
*.key
|
|
48
|
+
*.pem
|
|
49
|
+
|
|
50
|
+
# OS and editor noise
|
|
51
|
+
.DS_Store
|
|
52
|
+
Thumbs.db
|
|
53
|
+
.obsidian/workspace.json
|
|
54
|
+
.obsidian/workspace-mobile.json
|
|
55
|
+
|
|
56
|
+
# WhyKit's own write lock, crash-recovery journal and parse cache. Machine state, not knowledge.
|
|
57
|
+
.whykit/mutation.lock
|
|
58
|
+
.whykit/transactions/
|
|
59
|
+
.whykit/cache/
|
|
60
|
+
"""
|
|
61
|
+
VAULT_GITIGNORE_MARKER = "# WhyKit protective defaults (managed by whykit init)"
|
|
62
|
+
# Points at the repository when running from a checkout; harmless in a wheel.
|
|
63
|
+
SOURCE_ROOT = Path(__file__).resolve().parents[2]
|
|
64
|
+
_PROJECT_NAME_RE = re.compile(r"(?m)^name\s*=\s*[\"']whykit[\"']")
|
|
65
|
+
# macOS (and similar) expose these as root-level directory aliases. Following
|
|
66
|
+
# them does not redirect a user-named vault into a different tree; it is how
|
|
67
|
+
# the OS spells the same path. Refuse every other symlink on the init path.
|
|
68
|
+
_ROOT_DIR_ALIASES = frozenset({"var", "tmp", "etc", "private"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_vault(explicit: str | None) -> Path | None:
|
|
72
|
+
if explicit:
|
|
73
|
+
path = Path(explicit).expanduser().resolve()
|
|
74
|
+
return path if is_vault_root(path) else None
|
|
75
|
+
return find_vault_root()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _absolute_without_following_symlinks(raw: str) -> Path:
|
|
79
|
+
"""Expand ``~`` and make the path absolute without resolving symlinks."""
|
|
80
|
+
return Path(os.path.abspath(os.path.expanduser(raw)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _first_symlink_on_path(path: Path) -> Path | None:
|
|
84
|
+
"""Return the first refused symlink from the filesystem root through ``path``.
|
|
85
|
+
|
|
86
|
+
Root-level OS aliases such as macOS ``/var`` → ``/private/var`` are allowed
|
|
87
|
+
when they appear as an ancestor. The named target itself is never followed
|
|
88
|
+
when it is a symlink, and any non-alias ancestor symlink is refused so
|
|
89
|
+
init cannot write through a redirect into another directory.
|
|
90
|
+
"""
|
|
91
|
+
path = Path(path)
|
|
92
|
+
if not path.is_absolute():
|
|
93
|
+
raise ValueError(f"path must be absolute: {path}")
|
|
94
|
+
anchor = Path(path.anchor)
|
|
95
|
+
current = anchor
|
|
96
|
+
for part in path.parts[1:]:
|
|
97
|
+
current = current / part
|
|
98
|
+
try:
|
|
99
|
+
if not current.is_symlink():
|
|
100
|
+
continue
|
|
101
|
+
except OSError:
|
|
102
|
+
return None
|
|
103
|
+
is_root_alias_ancestor = (
|
|
104
|
+
current.parent == anchor
|
|
105
|
+
and current.name in _ROOT_DIR_ALIASES
|
|
106
|
+
and current != path
|
|
107
|
+
)
|
|
108
|
+
if is_root_alias_ancestor:
|
|
109
|
+
continue
|
|
110
|
+
return current
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_whykit_package_source_root(path: Path) -> bool:
|
|
115
|
+
"""True when ``path`` is a WhyKit package checkout (``src/whykit`` + project pyproject)."""
|
|
116
|
+
pyproject = path / "pyproject.toml"
|
|
117
|
+
package_dir = path / "src" / "whykit"
|
|
118
|
+
if pyproject.is_symlink() or package_dir.is_symlink():
|
|
119
|
+
return False
|
|
120
|
+
if not pyproject.is_file() or not package_dir.is_dir():
|
|
121
|
+
return False
|
|
122
|
+
if not (package_dir / "cli.py").is_file() and not (package_dir / "__init__.py").is_file():
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
126
|
+
except OSError:
|
|
127
|
+
return False
|
|
128
|
+
return _PROJECT_NAME_RE.search(text) is not None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _stamp_vault_dates(target: Path, today: dt.date | None = None) -> None:
|
|
132
|
+
"""Make a newly generated vault honest about when its starter files were created."""
|
|
133
|
+
value = (today or dt.date.today()).isoformat()
|
|
134
|
+
for path in iter_markdown(target):
|
|
135
|
+
try:
|
|
136
|
+
text = path.read_text(encoding="utf-8")
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
if not text.startswith("---\n"):
|
|
140
|
+
continue
|
|
141
|
+
end = text.find("\n---\n", 4)
|
|
142
|
+
if end < 0:
|
|
143
|
+
continue
|
|
144
|
+
front = text[:end]
|
|
145
|
+
front = re.sub(r"(?m)^created:\s*.*$", f"created: {value}", front, count=1)
|
|
146
|
+
front = re.sub(r"(?m)^last_updated:\s*.*$", f"last_updated: {value}", front, count=1)
|
|
147
|
+
path.write_text(front + text[end:], encoding="utf-8")
|
|
148
|
+
|
|
149
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
150
|
+
import tempfile
|
|
151
|
+
|
|
152
|
+
from .io import atomic_write_bytes, atomic_write_text, safe_vault_dir, safe_vault_target
|
|
153
|
+
|
|
154
|
+
json_mode = bool(getattr(args, "json", False))
|
|
155
|
+
if getattr(args, "minimal", False) and getattr(args, "full", False):
|
|
156
|
+
return emit_error("usage", "--minimal and --full cannot be used together", json_mode=json_mode)
|
|
157
|
+
|
|
158
|
+
minimal_layout = not getattr(args, "full", False)
|
|
159
|
+
# Do not Path.resolve(): that follows symlinks and can write outside the
|
|
160
|
+
# path the user named. Expand ~ and absolutize only.
|
|
161
|
+
target = _absolute_without_following_symlinks(args.target)
|
|
162
|
+
linked = _first_symlink_on_path(target)
|
|
163
|
+
if linked is not None:
|
|
164
|
+
return emit_error("unsafe_path", f"refusing to initialize through a symlink: {linked}", json_mode=json_mode)
|
|
165
|
+
if _is_whykit_package_source_root(target):
|
|
166
|
+
return emit_error(
|
|
167
|
+
"invalid_target",
|
|
168
|
+
f"refusing to initialize inside WhyKit's own package source tree: {target}",
|
|
169
|
+
json_mode=json_mode,
|
|
170
|
+
)
|
|
171
|
+
if target.exists() and not target.is_dir():
|
|
172
|
+
return emit_error("invalid_target", f"refusing to initialize a non-directory target: {target}", json_mode=json_mode)
|
|
173
|
+
if target.exists() and any(target.iterdir()) and not args.force:
|
|
174
|
+
return emit_error(
|
|
175
|
+
"target_exists",
|
|
176
|
+
f"refusing to write into non-empty directory: {target}",
|
|
177
|
+
hint="use --force only when you have reviewed the destination",
|
|
178
|
+
json_mode=json_mode,
|
|
179
|
+
)
|
|
180
|
+
if not TEMPLATE_DIR.is_dir():
|
|
181
|
+
return emit_error("missing_dependency", "template is missing from this installation", json_mode=json_mode)
|
|
182
|
+
|
|
183
|
+
# Prepare the selected layout in an isolated staging directory first.
|
|
184
|
+
# The vendor-neutral layout trims the workstream-rich starter here, never
|
|
185
|
+
# against an existing adopter directory.
|
|
186
|
+
with tempfile.TemporaryDirectory(prefix="whykit-init-") as tmp:
|
|
187
|
+
prepared = Path(tmp) / "vault"
|
|
188
|
+
shutil.copytree(TEMPLATE_DIR, prepared)
|
|
189
|
+
if minimal_layout:
|
|
190
|
+
from .init_layout import apply_minimal_layout
|
|
191
|
+
apply_minimal_layout(prepared)
|
|
192
|
+
_stamp_vault_dates(prepared)
|
|
193
|
+
|
|
194
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
try:
|
|
196
|
+
gitignore = safe_vault_target(target, ".gitignore")
|
|
197
|
+
if gitignore.exists() and not gitignore.is_file():
|
|
198
|
+
raise RuntimeError(f"expected a regular .gitignore file: {gitignore}")
|
|
199
|
+
existing_ignore = gitignore.read_text(encoding="utf-8") if gitignore.exists() else None
|
|
200
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
201
|
+
return emit_error("unsafe_path", f"refusing unsafe init destination: {exc}", json_mode=json_mode)
|
|
202
|
+
preserved = 0
|
|
203
|
+
for source in sorted(prepared.rglob("*")):
|
|
204
|
+
relative = source.relative_to(prepared)
|
|
205
|
+
try:
|
|
206
|
+
if source.is_dir():
|
|
207
|
+
safe_vault_dir(target, relative)
|
|
208
|
+
continue
|
|
209
|
+
destination = safe_vault_target(target, relative)
|
|
210
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
211
|
+
return emit_error("unsafe_path", f"refusing unsafe init destination: {exc}", json_mode=json_mode)
|
|
212
|
+
if destination.is_symlink():
|
|
213
|
+
return emit_error("unsafe_path", f"refusing to overwrite symlink: {destination}", json_mode=json_mode)
|
|
214
|
+
if destination.exists():
|
|
215
|
+
if not destination.is_file():
|
|
216
|
+
return emit_error(
|
|
217
|
+
"invalid_target",
|
|
218
|
+
f"refusing to replace a non-file destination: {destination}",
|
|
219
|
+
json_mode=json_mode,
|
|
220
|
+
)
|
|
221
|
+
if not args.force:
|
|
222
|
+
return emit_error("target_exists", f"refusing to overwrite existing file: {destination}", json_mode=json_mode)
|
|
223
|
+
preserved += 1
|
|
224
|
+
continue
|
|
225
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
atomic_write_bytes(destination, source.read_bytes())
|
|
227
|
+
|
|
228
|
+
# Deliberately no LICENSE: the vault holds the adopter's own knowledge, and
|
|
229
|
+
# copying WhyKit's Apache-2.0 text into it would appear to license their
|
|
230
|
+
# company's strategy and decisions under it.
|
|
231
|
+
if existing_ignore is None:
|
|
232
|
+
atomic_write_text(gitignore, VAULT_GITIGNORE)
|
|
233
|
+
elif not existing_ignore.endswith(VAULT_GITIGNORE):
|
|
234
|
+
separator = "" if not existing_ignore or existing_ignore.endswith("\n") else "\n"
|
|
235
|
+
atomic_write_text(
|
|
236
|
+
gitignore,
|
|
237
|
+
existing_ignore + separator + "\n" + VAULT_GITIGNORE_MARKER + "\n" + VAULT_GITIGNORE,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if json_mode:
|
|
241
|
+
import json
|
|
242
|
+
emit_machine(json.dumps({
|
|
243
|
+
"contract_version": CONTRACT_VERSION,
|
|
244
|
+
"root": str(target),
|
|
245
|
+
"layout": "minimal" if minimal_layout else "full",
|
|
246
|
+
"preserved": preserved,
|
|
247
|
+
}, ensure_ascii=False, indent=2))
|
|
248
|
+
return 0
|
|
249
|
+
print(f"WhyKit vault created: {target}")
|
|
250
|
+
if preserved:
|
|
251
|
+
print(f"Preserved {preserved} existing template file(s); only missing files were added.")
|
|
252
|
+
if minimal_layout:
|
|
253
|
+
print("Layout: vendor-neutral (default)")
|
|
254
|
+
else:
|
|
255
|
+
print("Layout: full starter with optional workstreams (--full)")
|
|
256
|
+
print()
|
|
257
|
+
print("Next, in order:")
|
|
258
|
+
print(" 1. Answer every TODO in AGENTS.md - that file is the contract agents work under.")
|
|
259
|
+
print(" 2. Replace the starter content in 00-context/ before treating anything as canonical.")
|
|
260
|
+
# Match the README cold-install path (`uv sync` + `uv run whykit` from the
|
|
261
|
+
# checkout), where bare `whykit` is not on PATH. An installed tool has no
|
|
262
|
+
# checkout to run from, so it gets the bare command instead.
|
|
263
|
+
if _is_whykit_package_source_root(SOURCE_ROOT):
|
|
264
|
+
print(f" 3. From the WhyKit checkout: uv run whykit lint --root {target}")
|
|
265
|
+
else:
|
|
266
|
+
print(f" 3. Check it: whykit lint --root {target}")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_lint(args: argparse.Namespace) -> int:
|
|
271
|
+
argv: list[str] = list(args.paths)
|
|
272
|
+
if args.root:
|
|
273
|
+
argv += ["--root", args.root]
|
|
274
|
+
for flag in ("strict", "quiet", "json", "no_orphans", "no_secrets"):
|
|
275
|
+
if getattr(args, flag, False):
|
|
276
|
+
argv.append("--" + flag.replace("_", "-"))
|
|
277
|
+
if getattr(args, "today", None):
|
|
278
|
+
argv += ["--today", args.today]
|
|
279
|
+
if getattr(args, "format", None):
|
|
280
|
+
argv += ["--format", args.format]
|
|
281
|
+
from .lint import main as lint_main
|
|
282
|
+
return lint_main(argv)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_new(args: argparse.Namespace) -> int:
|
|
287
|
+
from .scaffold import main as new_main
|
|
288
|
+
argv: list[str] = []
|
|
289
|
+
if args.root:
|
|
290
|
+
argv += ["--root", args.root]
|
|
291
|
+
argv.append(args.new_kind)
|
|
292
|
+
if args.new_kind == "decision":
|
|
293
|
+
if args.title is not None:
|
|
294
|
+
argv.append(args.title)
|
|
295
|
+
if args.from_path:
|
|
296
|
+
argv += ["--from", args.from_path]
|
|
297
|
+
if args.write:
|
|
298
|
+
argv.append("--write")
|
|
299
|
+
argv += ["--status", args.status]
|
|
300
|
+
if args.owner:
|
|
301
|
+
argv += ["--owner", args.owner]
|
|
302
|
+
if args.sensitivity:
|
|
303
|
+
argv += ["--sensitivity", args.sensitivity]
|
|
304
|
+
for source_id in args.source_ids:
|
|
305
|
+
argv += ["--source", source_id]
|
|
306
|
+
if args.review_by:
|
|
307
|
+
argv += ["--review-by", args.review_by]
|
|
308
|
+
if args.supersedes:
|
|
309
|
+
argv += ["--supersedes", args.supersedes]
|
|
310
|
+
if args.json:
|
|
311
|
+
argv.append("--json")
|
|
312
|
+
elif args.new_kind == "evidence":
|
|
313
|
+
argv += ["--source", args.source_name, "--location", args.location, "--type", args.evidence_type, "--claims", args.claims]
|
|
314
|
+
if args.date:
|
|
315
|
+
argv += ["--date", args.date]
|
|
316
|
+
if args.accessed:
|
|
317
|
+
argv += ["--accessed", args.accessed]
|
|
318
|
+
if args.sensitivity:
|
|
319
|
+
argv += ["--sensitivity", args.sensitivity]
|
|
320
|
+
if args.json:
|
|
321
|
+
argv.append("--json")
|
|
322
|
+
else:
|
|
323
|
+
argv += [args.title, "--workstream", args.workstream, "--type", args.doc_type]
|
|
324
|
+
if args.owner:
|
|
325
|
+
argv += ["--owner", args.owner]
|
|
326
|
+
if args.sensitivity:
|
|
327
|
+
argv += ["--sensitivity", args.sensitivity]
|
|
328
|
+
if args.link_from:
|
|
329
|
+
argv += ["--link-from", args.link_from]
|
|
330
|
+
if args.json:
|
|
331
|
+
argv.append("--json")
|
|
332
|
+
return new_main(argv)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
336
|
+
from .status import main as status_main
|
|
337
|
+
argv: list[str] = []
|
|
338
|
+
if args.root:
|
|
339
|
+
argv += ["--root", args.root]
|
|
340
|
+
if args.json:
|
|
341
|
+
argv.append("--json")
|
|
342
|
+
if args.today:
|
|
343
|
+
argv += ["--today", args.today]
|
|
344
|
+
if args.due_days is not None:
|
|
345
|
+
argv += ["--due-days", str(args.due_days)]
|
|
346
|
+
if args.strict:
|
|
347
|
+
argv.append("--strict")
|
|
348
|
+
return status_main(argv)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _json_format(args: argparse.Namespace, command: str) -> str | None:
|
|
352
|
+
"""Fold the shared ``--json`` flag into a command's ``--format`` choice."""
|
|
353
|
+
if not getattr(args, "json", False):
|
|
354
|
+
return args.format or "json"
|
|
355
|
+
if args.format not in (None, "json"):
|
|
356
|
+
emit_error(
|
|
357
|
+
"usage",
|
|
358
|
+
f"--json conflicts with --format {args.format}\nhint: pass one of them to `whykit {command}`",
|
|
359
|
+
json_mode=True,
|
|
360
|
+
)
|
|
361
|
+
return None
|
|
362
|
+
return "json"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def cmd_graph(args: argparse.Namespace) -> int:
|
|
366
|
+
from .graph import main as graph_main
|
|
367
|
+
fmt = _json_format(args, "graph")
|
|
368
|
+
if fmt is None:
|
|
369
|
+
return 2
|
|
370
|
+
argv: list[str] = []
|
|
371
|
+
if args.root:
|
|
372
|
+
argv += ["--root", args.root]
|
|
373
|
+
argv += ["--format", fmt]
|
|
374
|
+
if args.canonical_only:
|
|
375
|
+
argv.append("--canonical-only")
|
|
376
|
+
if getattr(args, "output", None):
|
|
377
|
+
argv += ["--output", args.output]
|
|
378
|
+
return graph_main(argv)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cmd_backlinks(args: argparse.Namespace) -> int:
|
|
382
|
+
from .backlinks import main as backlinks_main
|
|
383
|
+
argv = [args.target]
|
|
384
|
+
if args.root:
|
|
385
|
+
argv += ["--root", args.root]
|
|
386
|
+
if args.json:
|
|
387
|
+
argv.append("--json")
|
|
388
|
+
return backlinks_main(argv)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def cmd_impact(args: argparse.Namespace) -> int:
|
|
392
|
+
from .impact import main as impact_main
|
|
393
|
+
argv = [args.target]
|
|
394
|
+
if args.root:
|
|
395
|
+
argv += ["--root", args.root]
|
|
396
|
+
if args.json:
|
|
397
|
+
argv.append("--json")
|
|
398
|
+
return impact_main(argv)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cmd_trace(args: argparse.Namespace) -> int:
|
|
402
|
+
from .trace import main as trace_main
|
|
403
|
+
argv: list[str] = []
|
|
404
|
+
if args.root:
|
|
405
|
+
argv += ["--root", args.root]
|
|
406
|
+
if args.decision:
|
|
407
|
+
argv += ["--decision", args.decision]
|
|
408
|
+
if args.today:
|
|
409
|
+
argv += ["--today", args.today]
|
|
410
|
+
if args.max_age_days is not None:
|
|
411
|
+
argv += ["--max-age-days", str(args.max_age_days)]
|
|
412
|
+
for flag in ("gaps_only", "strict", "json"):
|
|
413
|
+
if getattr(args, flag):
|
|
414
|
+
argv.append("--" + flag.replace("_", "-"))
|
|
415
|
+
return trace_main(argv)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def cmd_query(args: argparse.Namespace) -> int:
|
|
419
|
+
from .query import main as query_main
|
|
420
|
+
argv: list[str] = []
|
|
421
|
+
if args.text:
|
|
422
|
+
argv.append(args.text)
|
|
423
|
+
if args.root:
|
|
424
|
+
argv += ["--root", args.root]
|
|
425
|
+
for flag, value in (("--type", args.doc_type), ("--status", args.status), ("--owner", args.owner), ("--sensitivity", args.sensitivity), ("--source", args.source_id), ("--tag", args.tag)):
|
|
426
|
+
if value:
|
|
427
|
+
argv += [flag, value]
|
|
428
|
+
if args.canonical_only:
|
|
429
|
+
argv.append("--canonical-only")
|
|
430
|
+
argv += ["--limit", str(args.limit)]
|
|
431
|
+
if args.json:
|
|
432
|
+
argv.append("--json")
|
|
433
|
+
return query_main(argv)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def cmd_context(args: argparse.Namespace) -> int:
|
|
437
|
+
from .context import main as context_main
|
|
438
|
+
argv = [args.target, "--max-chars", str(args.max_chars)]
|
|
439
|
+
if args.root:
|
|
440
|
+
argv += ["--root", args.root]
|
|
441
|
+
if args.no_body:
|
|
442
|
+
argv.append("--no-body")
|
|
443
|
+
if args.json:
|
|
444
|
+
argv.append("--json")
|
|
445
|
+
return context_main(argv)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_pack(args: argparse.Namespace) -> int:
|
|
449
|
+
from .pack import main as pack_main
|
|
450
|
+
fmt = _json_format(args, "pack")
|
|
451
|
+
if fmt is None:
|
|
452
|
+
return 2
|
|
453
|
+
argv: list[str] = list(args.targets)
|
|
454
|
+
if args.root:
|
|
455
|
+
argv += ["--root", args.root]
|
|
456
|
+
if args.query:
|
|
457
|
+
argv += ["--query", args.query]
|
|
458
|
+
argv += ["--max-docs", str(args.max_docs), "--max-chars", str(args.max_chars), "--format", fmt]
|
|
459
|
+
if args.canonical_only:
|
|
460
|
+
argv.append("--canonical-only")
|
|
461
|
+
if getattr(args, "agent", None):
|
|
462
|
+
argv += ["--for", args.agent]
|
|
463
|
+
return pack_main(argv)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def cmd_review(args: argparse.Namespace) -> int:
|
|
467
|
+
from .review import main as review_main
|
|
468
|
+
argv: list[str] = []
|
|
469
|
+
if args.root:
|
|
470
|
+
argv += ["--root", args.root]
|
|
471
|
+
argv.append(args.review_command)
|
|
472
|
+
if args.review_command == "list":
|
|
473
|
+
if args.today:
|
|
474
|
+
argv += ["--today", args.today]
|
|
475
|
+
if args.due_days is not None:
|
|
476
|
+
argv += ["--due-days", str(args.due_days)]
|
|
477
|
+
if args.owner:
|
|
478
|
+
argv += ["--owner", args.owner]
|
|
479
|
+
if args.overdue_only:
|
|
480
|
+
argv.append("--overdue-only")
|
|
481
|
+
if args.json:
|
|
482
|
+
argv.append("--json")
|
|
483
|
+
else:
|
|
484
|
+
argv.append(args.target)
|
|
485
|
+
argv += ["--reviewer", args.reviewer]
|
|
486
|
+
if args.review_command == "record":
|
|
487
|
+
argv += ["--outcome", args.outcome]
|
|
488
|
+
if getattr(args, "write", False):
|
|
489
|
+
argv.append("--write")
|
|
490
|
+
if getattr(args, "expected_sha256", None):
|
|
491
|
+
argv += ["--expect-hash", args.expected_sha256]
|
|
492
|
+
if args.next_review:
|
|
493
|
+
argv += ["--next-review", args.next_review]
|
|
494
|
+
if getattr(args, "note_text", None):
|
|
495
|
+
argv += ["--note", args.note_text]
|
|
496
|
+
if args.today:
|
|
497
|
+
argv += ["--today", args.today]
|
|
498
|
+
if args.json:
|
|
499
|
+
argv.append("--json")
|
|
500
|
+
return review_main(argv)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def cmd_snapshot(args: argparse.Namespace) -> int:
|
|
504
|
+
from .snapshot import main_snapshot
|
|
505
|
+
argv: list[str] = []
|
|
506
|
+
if args.root:
|
|
507
|
+
argv += ["--root", args.root]
|
|
508
|
+
if args.today:
|
|
509
|
+
argv += ["--today", args.today]
|
|
510
|
+
if args.output:
|
|
511
|
+
argv += ["--output", args.output]
|
|
512
|
+
if args.compact:
|
|
513
|
+
argv.append("--compact")
|
|
514
|
+
if args.snapshot_format:
|
|
515
|
+
argv += ["--format", args.snapshot_format]
|
|
516
|
+
return main_snapshot(argv)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def cmd_verify_snapshot(args: argparse.Namespace) -> int:
|
|
520
|
+
from .snapshot import main_verify
|
|
521
|
+
argv = [args.snapshot]
|
|
522
|
+
if args.root:
|
|
523
|
+
argv += ["--root", args.root]
|
|
524
|
+
if args.today:
|
|
525
|
+
argv += ["--today", args.today]
|
|
526
|
+
if args.json:
|
|
527
|
+
argv.append("--json")
|
|
528
|
+
return main_verify(argv)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
532
|
+
from .check import main as check_main
|
|
533
|
+
argv = ["--profile", args.profile, "--head", args.head]
|
|
534
|
+
if args.root:
|
|
535
|
+
argv += ["--root", args.root]
|
|
536
|
+
if args.base:
|
|
537
|
+
argv += ["--base", args.base]
|
|
538
|
+
if args.today:
|
|
539
|
+
argv += ["--today", args.today]
|
|
540
|
+
if args.json:
|
|
541
|
+
argv.append("--json")
|
|
542
|
+
if args.format:
|
|
543
|
+
argv += ["--format", args.format]
|
|
544
|
+
return check_main(argv)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def cmd_policy(args: argparse.Namespace) -> int:
|
|
548
|
+
from .config import ConfigError, config_summary
|
|
549
|
+
vault = _resolve_vault(args.root)
|
|
550
|
+
if vault is None:
|
|
551
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
552
|
+
try:
|
|
553
|
+
payload = config_summary(vault)
|
|
554
|
+
except ConfigError as exc:
|
|
555
|
+
return emit_error("invalid_config", str(exc), json_mode=args.json)
|
|
556
|
+
if args.json:
|
|
557
|
+
import json
|
|
558
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
559
|
+
else:
|
|
560
|
+
print(f"WhyKit policy — {payload['source']}")
|
|
561
|
+
defaults = payload["config"]["defaults"]
|
|
562
|
+
print(f" default owner {defaults['owner']}")
|
|
563
|
+
print(f" default sensitivity {defaults['sensitivity']}")
|
|
564
|
+
print(f" decision review days {defaults['decision_review_days']}")
|
|
565
|
+
print(f" status due days {defaults['status_due_days']}")
|
|
566
|
+
for name, profile in payload["config"]["profiles"].items():
|
|
567
|
+
print(f" profile {name:<10} strict={profile['strict']} orphans={profile['orphans']} secrets={profile['secrets']} history={profile['history']}")
|
|
568
|
+
rules = payload["config"].get("rules") or {}
|
|
569
|
+
if rules:
|
|
570
|
+
print(f" custom rules {len(rules.get('custom', []))} (see `whykit rules`)")
|
|
571
|
+
print(f" rule overrides {', '.join(sorted(rules.get('overrides', {}))) or 'none'}")
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def cmd_evidence(args: argparse.Namespace) -> int:
|
|
576
|
+
from .evidence import main as evidence_main
|
|
577
|
+
argv: list[str] = []
|
|
578
|
+
if args.root:
|
|
579
|
+
argv += ["--root", args.root]
|
|
580
|
+
argv.append(args.evidence_command)
|
|
581
|
+
if args.evidence_command == "list":
|
|
582
|
+
argv += ["--state", args.state]
|
|
583
|
+
if args.json:
|
|
584
|
+
argv.append("--json")
|
|
585
|
+
else:
|
|
586
|
+
argv.append(args.id)
|
|
587
|
+
argv += ["--why", args.reason]
|
|
588
|
+
if args.replaced_by:
|
|
589
|
+
argv += ["--replaced-by", args.replaced_by]
|
|
590
|
+
if args.today:
|
|
591
|
+
argv += ["--today", args.today]
|
|
592
|
+
if args.json:
|
|
593
|
+
argv.append("--json")
|
|
594
|
+
return evidence_main(argv)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def cmd_adopt(args: argparse.Namespace) -> int:
|
|
598
|
+
from .adopt import main as adopt_main
|
|
599
|
+
argv = [args.source]
|
|
600
|
+
if args.into:
|
|
601
|
+
argv += ["--into", args.into]
|
|
602
|
+
if args.owner:
|
|
603
|
+
argv += ["--owner", args.owner]
|
|
604
|
+
if args.profile:
|
|
605
|
+
argv += ["--profile", args.profile]
|
|
606
|
+
if args.compare is not None:
|
|
607
|
+
argv += ["--compare", args.compare]
|
|
608
|
+
if args.write:
|
|
609
|
+
argv.append("--write")
|
|
610
|
+
if args.json:
|
|
611
|
+
argv.append("--json")
|
|
612
|
+
return adopt_main(argv)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
616
|
+
from .immutability import main as immutability_main
|
|
617
|
+
argv: list[str] = []
|
|
618
|
+
if args.base is not None:
|
|
619
|
+
argv += ["--base", args.base]
|
|
620
|
+
if args.head is not None:
|
|
621
|
+
argv += ["--head", args.head]
|
|
622
|
+
if args.staged:
|
|
623
|
+
argv.append("--staged")
|
|
624
|
+
if args.root:
|
|
625
|
+
argv += ["--root", args.root]
|
|
626
|
+
if args.json:
|
|
627
|
+
argv.append("--json")
|
|
628
|
+
code = immutability_main(argv)
|
|
629
|
+
if code in (0, 1) and not args.staged and args.head in (None, "HEAD"):
|
|
630
|
+
_warn_uncommitted_markdown(_resolve_vault(args.root))
|
|
631
|
+
return code
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
635
|
+
from .diff import main as diff_main
|
|
636
|
+
argv = ["--base", args.base, "--head", args.head]
|
|
637
|
+
if args.root:
|
|
638
|
+
argv += ["--root", args.root]
|
|
639
|
+
if args.today:
|
|
640
|
+
argv += ["--today", args.today]
|
|
641
|
+
if args.format:
|
|
642
|
+
argv += ["--format", args.format]
|
|
643
|
+
if args.json:
|
|
644
|
+
argv.append("--json")
|
|
645
|
+
return diff_main(argv)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _uncommitted_markdown(vault: Path) -> list[str]:
|
|
649
|
+
"""Markdown under *vault* that differs from HEAD (staged, unstaged or new)."""
|
|
650
|
+
try:
|
|
651
|
+
result = subprocess.run(
|
|
652
|
+
["git", "-C", str(vault), "status", "--porcelain", "--untracked-files=all", "--", "."],
|
|
653
|
+
capture_output=True, text=True, check=False,
|
|
654
|
+
)
|
|
655
|
+
except OSError:
|
|
656
|
+
return []
|
|
657
|
+
if result.returncode != 0:
|
|
658
|
+
return []
|
|
659
|
+
paths = []
|
|
660
|
+
for line in result.stdout.splitlines():
|
|
661
|
+
path = line[3:].split(" -> ")[-1].strip().strip('"')
|
|
662
|
+
if is_markdown_name(path):
|
|
663
|
+
paths.append(path)
|
|
664
|
+
return paths
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _warn_uncommitted_markdown(vault: Path | None) -> None:
|
|
668
|
+
"""Say so when `history` compared commits but the edits are not committed yet.
|
|
669
|
+
|
|
670
|
+
`history` checks revisions, so an uncommitted rewrite of accepted reasoning
|
|
671
|
+
passes locally and only fails once it reaches CI. That reads as a false OK.
|
|
672
|
+
"""
|
|
673
|
+
if vault is None:
|
|
674
|
+
return
|
|
675
|
+
pending = _uncommitted_markdown(vault)
|
|
676
|
+
if not pending:
|
|
677
|
+
return
|
|
678
|
+
sys.stdout.flush()
|
|
679
|
+
print(
|
|
680
|
+
f"note: {len(pending)} uncommitted Markdown change(s) were not checked; "
|
|
681
|
+
"history compares commits only (e.g. "
|
|
682
|
+
f"{pending[0]})",
|
|
683
|
+
file=sys.stderr,
|
|
684
|
+
)
|
|
685
|
+
print("hint: commit the change, then rerun `whykit history --base <ref>`", file=sys.stderr)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
PRE_COMMIT_HOOK = """#!/bin/sh
|
|
689
|
+
# Installed by `whykit install-hooks`.
|
|
690
|
+
set -e
|
|
691
|
+
root=$(git rev-parse --show-toplevel)
|
|
692
|
+
cd "$root"
|
|
693
|
+
# __WHYKIT_VAULT_CD__
|
|
694
|
+
|
|
695
|
+
if ! command -v whykit >/dev/null 2>&1; then
|
|
696
|
+
echo "whykit is not on PATH; skipping the vault check." >&2
|
|
697
|
+
exit 0
|
|
698
|
+
fi
|
|
699
|
+
|
|
700
|
+
if ! whykit check --profile local; then
|
|
701
|
+
echo ""
|
|
702
|
+
echo "WhyKit local policy failed. Fix the errors before committing."
|
|
703
|
+
echo "To commit anyway (and you should have a reason): git commit --no-verify"
|
|
704
|
+
exit 1
|
|
705
|
+
fi
|
|
706
|
+
"""
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _git_hooks_location(vault: Path) -> tuple[Path, str] | None:
|
|
710
|
+
"""Where Git runs hooks for ``vault``, and the vault's path inside the work tree.
|
|
711
|
+
|
|
712
|
+
The vault may be a subdirectory of a larger repository or sit in a linked
|
|
713
|
+
worktree (where `.git` is a file), and `core.hooksPath` may move the hooks,
|
|
714
|
+
so ask Git instead of assuming `<vault>/.git/hooks`.
|
|
715
|
+
"""
|
|
716
|
+
if not shutil.which("git"):
|
|
717
|
+
return None
|
|
718
|
+
result = subprocess.run(
|
|
719
|
+
["git", "-C", str(vault), "rev-parse", "--git-path", "hooks", "--show-prefix"],
|
|
720
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
721
|
+
)
|
|
722
|
+
lines = result.stdout.splitlines()
|
|
723
|
+
if result.returncode != 0 or not lines:
|
|
724
|
+
return None
|
|
725
|
+
hooks = Path(lines[0])
|
|
726
|
+
if not hooks.is_absolute():
|
|
727
|
+
hooks = vault / hooks
|
|
728
|
+
prefix = lines[1].strip() if len(lines) > 1 else ""
|
|
729
|
+
return hooks, prefix
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _render_pre_commit_hook(prefix: str) -> str:
|
|
733
|
+
"""The hook runs from the work-tree root; step into the vault when it is a subdirectory."""
|
|
734
|
+
line = f"cd -- {shlex.quote(prefix.rstrip('/'))}" if prefix else ""
|
|
735
|
+
return PRE_COMMIT_HOOK.replace("# __WHYKIT_VAULT_CD__\n", f"{line}\n" if line else "")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def cmd_install_hooks(args: argparse.Namespace) -> int:
|
|
739
|
+
vault = _resolve_vault(args.root)
|
|
740
|
+
if vault is None:
|
|
741
|
+
print_no_vault(args.root)
|
|
742
|
+
return 2
|
|
743
|
+
located = _git_hooks_location(vault)
|
|
744
|
+
if located is None:
|
|
745
|
+
print(f"not a git repository: {vault}", file=sys.stderr)
|
|
746
|
+
print("run `git init` first - the ledger's guarantees come from version control", file=sys.stderr)
|
|
747
|
+
return 2
|
|
748
|
+
hooks, prefix = located
|
|
749
|
+
target = hooks / "pre-commit"
|
|
750
|
+
if target.exists() and not args.force:
|
|
751
|
+
print(f"a pre-commit hook already exists: {target}", file=sys.stderr)
|
|
752
|
+
print("inspect it, then re-run with --force to replace it", file=sys.stderr)
|
|
753
|
+
return 2
|
|
754
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
755
|
+
target.write_text(_render_pre_commit_hook(prefix), encoding="utf-8")
|
|
756
|
+
target.chmod(0o755)
|
|
757
|
+
print(f"pre-commit hook installed: {target}")
|
|
758
|
+
return 0
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _check(label: str, ok: bool, detail: str) -> bool:
|
|
762
|
+
print(f"{'OK' if ok else 'FAIL':<4} {label:<20} {detail}")
|
|
763
|
+
return ok
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _note(label: str, ok: bool, detail: str) -> None:
|
|
767
|
+
"""Report something optional. Printing FAIL for a missing nicety trains people
|
|
768
|
+
to ignore the whole report, which is how a real FAIL gets missed."""
|
|
769
|
+
print(f"{'OK' if ok else '--':<4} {label:<20} {detail}")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
774
|
+
from .rules import main as rules_main
|
|
775
|
+
argv: list[str] = []
|
|
776
|
+
if args.code:
|
|
777
|
+
argv.append(args.code)
|
|
778
|
+
if args.json:
|
|
779
|
+
argv.append("--json")
|
|
780
|
+
if args.markdown:
|
|
781
|
+
argv.append("--markdown")
|
|
782
|
+
if args.root:
|
|
783
|
+
argv += ["--root", args.root]
|
|
784
|
+
return rules_main(argv)
|
|
785
|
+
|
|
786
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
787
|
+
import json
|
|
788
|
+
from .config import ConfigError, load_config
|
|
789
|
+
|
|
790
|
+
today: dt.date | None = None
|
|
791
|
+
if getattr(args, "today", None):
|
|
792
|
+
try:
|
|
793
|
+
today = dt.date.fromisoformat(args.today)
|
|
794
|
+
except ValueError:
|
|
795
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}\nhint: use YYYY-MM-DD", json_mode=args.json)
|
|
796
|
+
vault = _resolve_vault(args.root)
|
|
797
|
+
checks: list[dict[str, object]] = []
|
|
798
|
+
|
|
799
|
+
def record(label: str, ok: bool, detail: str, *, required: bool = True) -> None:
|
|
800
|
+
checks.append({"name": label, "passed": bool(ok), "detail": detail, "required": required})
|
|
801
|
+
|
|
802
|
+
record("python", sys.version_info >= (3, 11), sys.version.split()[0])
|
|
803
|
+
record("whykit", True, __version__)
|
|
804
|
+
if vault is None:
|
|
805
|
+
record("vault_root", False, "no vault found - run `whykit init <dir>` or pass --root")
|
|
806
|
+
payload: dict[str, object] = {"contract_version": CONTRACT_VERSION, "root": None, "passed": False, "checks": checks}
|
|
807
|
+
if args.json:
|
|
808
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
809
|
+
else:
|
|
810
|
+
for item in checks:
|
|
811
|
+
marker = "OK" if item["passed"] else "FAIL"
|
|
812
|
+
print(f"{marker:<4} {str(item['name']):<20} {item['detail']}")
|
|
813
|
+
return 1
|
|
814
|
+
|
|
815
|
+
record("vault_root", True, str(vault))
|
|
816
|
+
record("evidence_register", (vault / "00-context/evidence-register.md").exists(), "00-context/evidence-register.md")
|
|
817
|
+
record("decision_log", (vault / "06-decisions/decision-log.md").exists(), "06-decisions/decision-log.md")
|
|
818
|
+
record("review_log", (vault / "00-context/review-log.md").exists(), "00-context/review-log.md", required=False)
|
|
819
|
+
record("agent_contract", (vault / "AGENTS.md").exists(), "AGENTS.md")
|
|
820
|
+
try:
|
|
821
|
+
_config, config_path = load_config(vault)
|
|
822
|
+
except ConfigError as exc:
|
|
823
|
+
record("policy", False, str(exc))
|
|
824
|
+
else:
|
|
825
|
+
record("policy", True, rel(vault, config_path) if config_path else "built-in defaults")
|
|
826
|
+
|
|
827
|
+
git = shutil.which("git")
|
|
828
|
+
record("git", bool(git), git or "not found")
|
|
829
|
+
tracked = False
|
|
830
|
+
if git:
|
|
831
|
+
inside = subprocess.run(["git", "-C", str(vault), "rev-parse", "--git-dir"], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
832
|
+
tracked = inside.returncode == 0
|
|
833
|
+
record("git_repository", tracked, "tracked" if tracked else "not a git repository yet - `git init`", required=False)
|
|
834
|
+
located = _git_hooks_location(vault) if tracked else None
|
|
835
|
+
hook_installed = bool(located and (located[0] / "pre-commit").exists())
|
|
836
|
+
record("pre_commit_hook", hook_installed, "installed" if hook_installed else "not installed - `whykit install-hooks`", required=False)
|
|
837
|
+
|
|
838
|
+
record("node_explorer", bool(shutil.which("node")), shutil.which("node") or "not found (only needed for `serve`)", required=False)
|
|
839
|
+
|
|
840
|
+
# Same findings as `whykit lint` and `whykit status`, so the counts agree.
|
|
841
|
+
files, findings = run_lint(vault, [], today=today)
|
|
842
|
+
errors = [f for f in findings if f.level == "error"]
|
|
843
|
+
warnings = [f for f in findings if f.level == "warning"]
|
|
844
|
+
record("vault_lint", not errors, f"{len(files)} files, {len(errors)} errors, {len(warnings)} warnings")
|
|
845
|
+
|
|
846
|
+
overdue = [f for f in findings if f.code in ("review_by.overdue", "decision.review_missing")]
|
|
847
|
+
record(
|
|
848
|
+
"review_hygiene",
|
|
849
|
+
not overdue,
|
|
850
|
+
"every approved decision has a live review date" if not overdue else f"{len(overdue)} document(s) need a review date or re-check",
|
|
851
|
+
required=False,
|
|
852
|
+
)
|
|
853
|
+
sensitive = _sensitive_docs(vault)
|
|
854
|
+
record("sensitive_docs", True, f"{len(sensitive)} confidential/restricted document(s)", required=False)
|
|
855
|
+
|
|
856
|
+
passed = all(bool(item["passed"]) for item in checks if item["required"])
|
|
857
|
+
payload = {"contract_version": CONTRACT_VERSION, "root": str(vault), "passed": passed, "checks": checks}
|
|
858
|
+
if args.json:
|
|
859
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
860
|
+
else:
|
|
861
|
+
for item in checks:
|
|
862
|
+
if item["passed"]:
|
|
863
|
+
marker = "OK"
|
|
864
|
+
elif item["required"]:
|
|
865
|
+
marker = "FAIL"
|
|
866
|
+
else:
|
|
867
|
+
marker = "--"
|
|
868
|
+
print(f"{marker:<4} {str(item['name']):<20} {item['detail']}")
|
|
869
|
+
return 0 if passed else 1
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _sensitive_docs(vault: Path) -> list[str]:
|
|
873
|
+
"""Documents marked confidential or restricted (doctor inventory)."""
|
|
874
|
+
index = VaultIndex.load(vault)
|
|
875
|
+
sensitive: list[str] = []
|
|
876
|
+
for note in index.notes:
|
|
877
|
+
if str(note.front.get("sensitivity", "")).lower() in {"confidential", "restricted"}:
|
|
878
|
+
sensitive.append(index.relative(note.path))
|
|
879
|
+
return sensitive
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _non_public_docs(vault: Path) -> list[str]:
|
|
883
|
+
index = VaultIndex.load(vault)
|
|
884
|
+
from .sensitivity import note_sensitivity_level, classified_evidence, evidence_register_notes
|
|
885
|
+
|
|
886
|
+
result: list[str] = []
|
|
887
|
+
for note in index.notes:
|
|
888
|
+
if note_sensitivity_level(note) != 0:
|
|
889
|
+
result.append(index.relative(note.path))
|
|
890
|
+
active, retired, _, per_row = classified_evidence(index)
|
|
891
|
+
# The private viewer serves the raw table, including incomplete/placeholder rows.
|
|
892
|
+
if per_row:
|
|
893
|
+
result.extend(index.relative(note.path) for note in evidence_register_notes(index)
|
|
894
|
+
if index.relative(note.path) not in result)
|
|
895
|
+
result.extend(f"evidence:{key}" for key, row in (*active.items(), *retired.items()) if row["sensitivity"] != "public")
|
|
896
|
+
return result
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def cmd_recover(args: argparse.Namespace) -> int:
|
|
900
|
+
import json
|
|
901
|
+
from .io import vault_mutation_lock
|
|
902
|
+
|
|
903
|
+
root = _resolve_vault(args.root)
|
|
904
|
+
if root is None:
|
|
905
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
906
|
+
with vault_mutation_lock(root):
|
|
907
|
+
pass # Recovery and terminal-journal collection finish before the lock yields.
|
|
908
|
+
if args.json:
|
|
909
|
+
emit_machine(json.dumps({"contract_version": 1, "recovery_completed": True}))
|
|
910
|
+
else:
|
|
911
|
+
print("Vault recovery completed.")
|
|
912
|
+
return 0
|
|
913
|
+
|
|
914
|
+
def cmd_explorer_index(args: argparse.Namespace) -> int:
|
|
915
|
+
argv: list[str] = []
|
|
916
|
+
if args.root:
|
|
917
|
+
argv += ["--root", args.root]
|
|
918
|
+
if getattr(args, "today", None):
|
|
919
|
+
argv += ["--today", args.today]
|
|
920
|
+
if getattr(args, "private", False):
|
|
921
|
+
argv += ["--private"]
|
|
922
|
+
if getattr(args, "publication_preview", False):
|
|
923
|
+
argv += ["--publication-preview"]
|
|
924
|
+
from .explorer_index import main as explorer_index_main
|
|
925
|
+
return explorer_index_main(argv)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
REPOSITORY_URL = "https://github.com/CometWeb-io/whykit"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _install_origin() -> dict | None:
|
|
932
|
+
"""Where this copy was installed from: its PEP 610 ``direct_url.json``.
|
|
933
|
+
|
|
934
|
+
Installers write it for a local directory (``uv tool install ./whykit``,
|
|
935
|
+
``pip install .``) or a VCS URL, and not for a package index.
|
|
936
|
+
"""
|
|
937
|
+
import json
|
|
938
|
+
from importlib import metadata
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
raw = metadata.distribution("whykit").read_text("direct_url.json")
|
|
942
|
+
except (metadata.PackageNotFoundError, OSError):
|
|
943
|
+
return None
|
|
944
|
+
if not raw:
|
|
945
|
+
return None
|
|
946
|
+
try:
|
|
947
|
+
data = json.loads(raw)
|
|
948
|
+
except ValueError:
|
|
949
|
+
return None
|
|
950
|
+
return data if isinstance(data, dict) else None
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _serve_hint(origin: dict | None, vault: str) -> list[str]:
|
|
954
|
+
"""What to do when the running copy has no Explorer next to it."""
|
|
955
|
+
from urllib.parse import urlparse
|
|
956
|
+
from urllib.request import url2pathname
|
|
957
|
+
|
|
958
|
+
target = shlex.quote(str(Path(vault).expanduser().resolve()))
|
|
959
|
+
url = str((origin or {}).get("url") or "")
|
|
960
|
+
if url.startswith("file://") and isinstance((origin or {}).get("dir_info"), dict):
|
|
961
|
+
checkout = Path(url2pathname(urlparse(url).path))
|
|
962
|
+
if (checkout / "apps" / "explorer" / "package.json").is_file():
|
|
963
|
+
return [
|
|
964
|
+
f"This copy was installed from the checkout at {checkout}, which has the Explorer.",
|
|
965
|
+
"Run it from that checkout:",
|
|
966
|
+
f" cd {shlex.quote(str(checkout))} && uv run whykit serve {target}",
|
|
967
|
+
]
|
|
968
|
+
return [
|
|
969
|
+
f"This copy was installed from {checkout}, which no longer has apps/explorer/.",
|
|
970
|
+
"Clone the repository and run `whykit serve` from the clone:",
|
|
971
|
+
f" git clone {REPOSITORY_URL}",
|
|
972
|
+
]
|
|
973
|
+
vcs = (origin or {}).get("vcs_info")
|
|
974
|
+
if url and isinstance(vcs, dict) and vcs.get("vcs") == "git":
|
|
975
|
+
lines = [
|
|
976
|
+
"This copy was installed from Git. Clone the same source and run `whykit serve` from the clone:",
|
|
977
|
+
f" git clone {shlex.quote(url)} whykit",
|
|
978
|
+
]
|
|
979
|
+
commit = str(vcs.get("commit_id") or "")
|
|
980
|
+
if re.fullmatch(r"[0-9a-f]{7,64}", commit):
|
|
981
|
+
lines.append(f" git -C whykit checkout {commit}")
|
|
982
|
+
lines.append(f" cd whykit && uv run whykit serve {target}")
|
|
983
|
+
return lines
|
|
984
|
+
return [
|
|
985
|
+
"Clone the repository and run `whykit serve` from the clone:",
|
|
986
|
+
f" git clone {REPOSITORY_URL}",
|
|
987
|
+
f" cd whykit && uv run whykit serve {target}",
|
|
988
|
+
]
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def cmd_lsp(args: argparse.Namespace) -> int:
|
|
992
|
+
argv: list[str] = ["--debounce", str(args.debounce)]
|
|
993
|
+
if args.root:
|
|
994
|
+
argv += ["--root", args.root]
|
|
995
|
+
if args.today:
|
|
996
|
+
argv += ["--today", args.today]
|
|
997
|
+
from .lsp import main as lsp_main
|
|
998
|
+
return lsp_main(argv)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _serve_target(args: argparse.Namespace) -> str:
|
|
1002
|
+
"""The vault ``serve`` should show: the positional argument or ``--root``."""
|
|
1003
|
+
positional, root = getattr(args, "vault", None), getattr(args, "root", None)
|
|
1004
|
+
if positional and root and Path(positional).expanduser().resolve() != Path(root).expanduser().resolve():
|
|
1005
|
+
raise ValueError(f"two different vaults given: {positional} and --root {root}; pass one")
|
|
1006
|
+
return positional or root or "."
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
1010
|
+
try:
|
|
1011
|
+
target = _serve_target(args)
|
|
1012
|
+
except ValueError as exc:
|
|
1013
|
+
print(str(exc), file=sys.stderr)
|
|
1014
|
+
return 2
|
|
1015
|
+
explorer = SOURCE_ROOT / "apps" / "explorer"
|
|
1016
|
+
if not (explorer / "package.json").exists():
|
|
1017
|
+
print("The Explorer ships with the source repository, not the package.", file=sys.stderr)
|
|
1018
|
+
for line in _serve_hint(_install_origin(), target):
|
|
1019
|
+
print(line, file=sys.stderr)
|
|
1020
|
+
return 2
|
|
1021
|
+
npm = shutil.which("npm")
|
|
1022
|
+
if not npm:
|
|
1023
|
+
print("npm is required for the Explorer", file=sys.stderr)
|
|
1024
|
+
return 2
|
|
1025
|
+
vault = _resolve_vault(None if target == "." else target) or Path(target).expanduser().resolve()
|
|
1026
|
+
if not is_vault_root(vault):
|
|
1027
|
+
print(f"not a WhyKit vault: {vault}", file=sys.stderr)
|
|
1028
|
+
return 2
|
|
1029
|
+
loopback_hosts = {"127.0.0.1", "localhost", "::1"}
|
|
1030
|
+
if args.host not in loopback_hosts and not args.allow_sensitive_network:
|
|
1031
|
+
non_public = _non_public_docs(vault)
|
|
1032
|
+
if non_public:
|
|
1033
|
+
print("refusing to expose non-public vault content on a non-loopback interface", file=sys.stderr)
|
|
1034
|
+
print(f"{len(non_public)} non-public document(s) would be bundled into the Explorer", file=sys.stderr)
|
|
1035
|
+
print("use the default 127.0.0.1, or pass --allow-sensitive-network after reviewing the risk", file=sys.stderr)
|
|
1036
|
+
return 2
|
|
1037
|
+
if not (explorer / "node_modules").exists():
|
|
1038
|
+
print("Explorer dependencies are not installed. Run: npm --prefix apps/explorer install", file=sys.stderr)
|
|
1039
|
+
return 2
|
|
1040
|
+
env = os.environ.copy()
|
|
1041
|
+
env["WHYKIT_VAULT_DIR"] = str(vault)
|
|
1042
|
+
env["WHYKIT_EXPLORER_PRIVATE"] = "1"
|
|
1043
|
+
return subprocess.run([npm, "run", "dev", "--", "--host", args.host, "--port", str(args.port)],
|
|
1044
|
+
cwd=explorer, env=env).returncode
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
ROOT_HELP = "vault root (default: nearest vault at or above the working directory)"
|
|
1048
|
+
TODAY_HELP = "evaluate review dates as of this YYYY-MM-DD date instead of today"
|
|
1049
|
+
JSON_HELP = "emit machine-readable JSON on stdout"
|
|
1050
|
+
SENSITIVITIES = ("public", "internal", "confidential", "restricted")
|
|
1051
|
+
|
|
1052
|
+
EPILOG = """\
|
|
1053
|
+
exit codes:
|
|
1054
|
+
0 success (no errors; no warnings under --strict)
|
|
1055
|
+
1 the check ran and found problems that should fail the build
|
|
1056
|
+
2 the tool could not run: no vault, invalid input or configuration
|
|
1057
|
+
70 internal error: a bug in WhyKit, not a problem with the vault
|
|
1058
|
+
130 interrupted
|
|
1059
|
+
|
|
1060
|
+
Run `whykit <command> -h` for a command's options.
|
|
1061
|
+
Docs: https://github.com/CometWeb-io/whykit#readme"""
|
|
1062
|
+
|
|
1063
|
+
# Top-level help groups the commands by the job they do, so ~30 commands read
|
|
1064
|
+
# as five verbs rather than one flat list. Every command belongs to exactly one
|
|
1065
|
+
# group; tests/test_cli_consistency.py holds this table to the parser.
|
|
1066
|
+
COMMAND_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
1067
|
+
("author", "create and change records", ("init", "adopt", "new", "review", "evidence")),
|
|
1068
|
+
("check", "gate a vault or a change", ("lint", "check", "status", "trace", "history", "diff", "snapshot", "verify-snapshot")),
|
|
1069
|
+
("explore", "find and hand over context", ("query", "context", "pack", "graph", "backlinks", "impact")),
|
|
1070
|
+
("integrate", "connect hooks, viewers and shells", ("install-hooks", "explorer-index", "serve", "lsp", "completion")),
|
|
1071
|
+
("maintain", "inspect policy, rules and setup", ("policy", "rules", "doctor", "recover")),
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
COMMANDS_PREAMBLE = (
|
|
1075
|
+
"Vault commands take --root DIR before or after the command (default: the\n"
|
|
1076
|
+
"nearest vault), --json for one JSON document on stdout, and --today\n"
|
|
1077
|
+
"YYYY-MM-DD where a result depends on the date."
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
class _Parser(argparse.ArgumentParser):
|
|
1082
|
+
"""ArgumentParser whose usage errors honour the machine contract.
|
|
1083
|
+
|
|
1084
|
+
argparse reports a bad command line before any command runs, so the
|
|
1085
|
+
``--json`` choice is read from the raw arguments (see ``main``). Human
|
|
1086
|
+
output and the exit code (2) are exactly argparse's own.
|
|
1087
|
+
"""
|
|
1088
|
+
|
|
1089
|
+
json_errors = False
|
|
1090
|
+
# The command path being parsed (e.g. "review list"), read from the raw
|
|
1091
|
+
# arguments in ``main`` so a top-level usage error can name its subcommand.
|
|
1092
|
+
command_path = ""
|
|
1093
|
+
|
|
1094
|
+
def error(self, message: str) -> NoReturn:
|
|
1095
|
+
hint = _usage_hint(message, _Parser.command_path)
|
|
1096
|
+
self.print_usage(sys.stderr)
|
|
1097
|
+
sys.stderr.write(f"{self.prog}: error: {message}\n")
|
|
1098
|
+
if hint:
|
|
1099
|
+
sys.stderr.write(f"hint: {hint}\n")
|
|
1100
|
+
if _Parser.json_errors:
|
|
1101
|
+
import json
|
|
1102
|
+
emit_machine(json.dumps(
|
|
1103
|
+
error_payload("usage", f"{self.prog}: error: {message}", hint or f"run `{self.prog} -h` for the accepted options"),
|
|
1104
|
+
ensure_ascii=False,
|
|
1105
|
+
indent=2,
|
|
1106
|
+
))
|
|
1107
|
+
self.exit(2)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _usage_hint(message: str, command_path: str = "") -> str | None:
|
|
1111
|
+
"""A next step for argparse errors where the message alone misleads."""
|
|
1112
|
+
if "invalid choice: 'accepted'" in message and "draft" in message:
|
|
1113
|
+
# Decision logs render the approved state as "Accepted", so people type it.
|
|
1114
|
+
return "decision logs display approved decisions as \"Accepted\"; create a draft, complete it, then use `whykit review approve`"
|
|
1115
|
+
if message.startswith("unrecognized arguments") and command_path:
|
|
1116
|
+
# argparse reports these from the top-level parser, whose usage line
|
|
1117
|
+
# lists no subcommand options at all.
|
|
1118
|
+
return f"`whykit {command_path} -h` lists the options of `whykit {command_path}`"
|
|
1119
|
+
return None
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
class _TopParser(_Parser):
|
|
1123
|
+
"""The ``whykit`` parser: help lists commands grouped by job, and a
|
|
1124
|
+
leading ``--root DIR`` is handed to the command that follows it."""
|
|
1125
|
+
|
|
1126
|
+
def parse_known_args(self, args=None, namespace=None): # type: ignore[no-untyped-def]
|
|
1127
|
+
raw = list(sys.argv[1:] if args is None else args)
|
|
1128
|
+
return super().parse_known_args(_hoist_root(raw, set(_subcommand_helps(self))), namespace)
|
|
1129
|
+
|
|
1130
|
+
def format_help(self) -> str:
|
|
1131
|
+
formatter = self._get_formatter()
|
|
1132
|
+
formatter.add_usage(self.usage, self._actions, self._mutually_exclusive_groups)
|
|
1133
|
+
formatter.add_text(self.description)
|
|
1134
|
+
formatter.start_section("options")
|
|
1135
|
+
formatter.add_arguments([a for a in self._actions if a.option_strings])
|
|
1136
|
+
formatter.end_section()
|
|
1137
|
+
text = formatter.format_help().rstrip("\n") + "\n\n"
|
|
1138
|
+
return text + _grouped_commands(self) + "\n" + (self.epilog or "") + "\n"
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _subcommand_helps(parser: argparse.ArgumentParser) -> dict[str, str]:
|
|
1142
|
+
for action in parser._actions: # argparse has no public introspection API
|
|
1143
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
1144
|
+
return {str(choice.dest): str(choice.help or "") for choice in action._choices_actions}
|
|
1145
|
+
return {}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _grouped_commands(parser: argparse.ArgumentParser) -> str:
|
|
1149
|
+
import textwrap
|
|
1150
|
+
|
|
1151
|
+
helps = _subcommand_helps(parser)
|
|
1152
|
+
width = max(len(name) for name in helps) + 2
|
|
1153
|
+
columns = max(60, min(shutil.get_terminal_size().columns, 100) - 2)
|
|
1154
|
+
lines = ["commands:", *(f" {line}" for line in COMMANDS_PREAMBLE.splitlines()), ""]
|
|
1155
|
+
for job, blurb, names in COMMAND_GROUPS:
|
|
1156
|
+
lines.append(f" {job} - {blurb}")
|
|
1157
|
+
for name in names:
|
|
1158
|
+
wrapped = textwrap.wrap(helps.get(name, ""), max(20, columns - width - 4)) or [""]
|
|
1159
|
+
lines.append(f" {name:<{width}}{wrapped[0]}".rstrip())
|
|
1160
|
+
lines.extend(f" {'':<{width}}{rest}" for rest in wrapped[1:])
|
|
1161
|
+
return "\n".join(lines) + "\n"
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _command_path(parser: argparse.ArgumentParser, raw: list[str]) -> str:
|
|
1165
|
+
"""The subcommand names in *raw*, in order (e.g. ``review list``)."""
|
|
1166
|
+
path: list[str] = []
|
|
1167
|
+
current: argparse.ArgumentParser | None = parser
|
|
1168
|
+
for token in raw:
|
|
1169
|
+
if token == "--" or current is None:
|
|
1170
|
+
break
|
|
1171
|
+
children = next(
|
|
1172
|
+
(a.choices for a in current._actions if isinstance(a, argparse._SubParsersAction)), None
|
|
1173
|
+
)
|
|
1174
|
+
if not children:
|
|
1175
|
+
break
|
|
1176
|
+
if token in children:
|
|
1177
|
+
path.append(token)
|
|
1178
|
+
current = children[token]
|
|
1179
|
+
return " ".join(path)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _hoist_root(raw: list[str], commands: set[str]) -> list[str]:
|
|
1183
|
+
"""Move a leading ``--root DIR`` behind the command name.
|
|
1184
|
+
|
|
1185
|
+
Every vault command accepts ``--root`` after its name; ``whykit --root DIR
|
|
1186
|
+
lint`` used to fail with "invalid choice: 'DIR'". Only a leading
|
|
1187
|
+
``--root`` directly followed by a command is moved, so the command's own
|
|
1188
|
+
parser still decides whether it accepts the option.
|
|
1189
|
+
"""
|
|
1190
|
+
if raw and raw[0] == "--root" and len(raw) >= 3 and raw[2] in commands:
|
|
1191
|
+
return [raw[2], raw[0], raw[1], *raw[3:]]
|
|
1192
|
+
if raw and raw[0].startswith("--root=") and len(raw) >= 2 and raw[1] in commands:
|
|
1193
|
+
return [raw[1], raw[0], *raw[2:]]
|
|
1194
|
+
return raw
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _add_leaf_root(parser: argparse.ArgumentParser) -> None:
|
|
1198
|
+
"""Accept ``--root`` after a nested action as well as before it.
|
|
1199
|
+
|
|
1200
|
+
``whykit new --root V decision ...`` was the only spelling that worked,
|
|
1201
|
+
and ``whykit new decision ... --root V`` -- the form every other command
|
|
1202
|
+
accepts -- failed with "unrecognized arguments". SUPPRESS keeps a value
|
|
1203
|
+
given at the parent level from being overwritten by the leaf default.
|
|
1204
|
+
"""
|
|
1205
|
+
parser.add_argument("--root", default=argparse.SUPPRESS, help=ROOT_HELP)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1209
|
+
parser = _TopParser(
|
|
1210
|
+
prog="whykit",
|
|
1211
|
+
description="WhyKit - a Git-native evidence and decision ledger.",
|
|
1212
|
+
epilog=EPILOG,
|
|
1213
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1214
|
+
)
|
|
1215
|
+
parser.add_argument("-V", "--version", action="version", version=f"whykit {__version__}")
|
|
1216
|
+
parser.add_argument(
|
|
1217
|
+
"--no-cache", action="store_true",
|
|
1218
|
+
help="do not read or update the parse cache in .whykit/cache/ (same as WHYKIT_NO_CACHE=1)",
|
|
1219
|
+
)
|
|
1220
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>", title="commands")
|
|
1221
|
+
|
|
1222
|
+
init = sub.add_parser("init", help="create a new vault", description="Create a new WhyKit vault from the bundled template.")
|
|
1223
|
+
init.add_argument("target", help="directory to create (must be empty unless --force)")
|
|
1224
|
+
init.add_argument("--force", action="store_true", help="add missing template files in a non-empty directory without replacing existing files")
|
|
1225
|
+
init.add_argument(
|
|
1226
|
+
"--minimal",
|
|
1227
|
+
action="store_true",
|
|
1228
|
+
help="explicitly select the default vendor-neutral layout (legacy alias)",
|
|
1229
|
+
)
|
|
1230
|
+
init.add_argument(
|
|
1231
|
+
"--full",
|
|
1232
|
+
action="store_true",
|
|
1233
|
+
help="also create the optional starter workstreams (strategy, website, automation, operations, research) and their templates",
|
|
1234
|
+
)
|
|
1235
|
+
init.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1236
|
+
init.set_defaults(func=cmd_init)
|
|
1237
|
+
|
|
1238
|
+
lint_cmd = sub.add_parser("lint", help="check a vault", description="Check vault structure, links, evidence and decisions.")
|
|
1239
|
+
lint_cmd.add_argument("paths", nargs="*", help="Markdown files or directories to check, relative to the vault root (default: whole vault)")
|
|
1240
|
+
lint_cmd.add_argument("--root", help=ROOT_HELP)
|
|
1241
|
+
lint_cmd.add_argument("--strict", action="store_true", help="treat warnings as failures (exit 1)")
|
|
1242
|
+
lint_cmd.add_argument("--quiet", action="store_true", help="print only the summary line")
|
|
1243
|
+
lint_cmd.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1244
|
+
lint_cmd.add_argument("--no-orphans", action="store_true", help="skip orphan-note warnings")
|
|
1245
|
+
lint_cmd.add_argument("--no-secrets", action="store_true", help="skip the secret scan")
|
|
1246
|
+
lint_cmd.add_argument("--today", help=TODAY_HELP)
|
|
1247
|
+
lint_cmd.add_argument("--format", choices=("text", "json", "sarif", "github"), default=None, help="output format: text (default), json, sarif (SARIF 2.1.0) or github (workflow annotations)")
|
|
1248
|
+
lint_cmd.set_defaults(func=cmd_lint)
|
|
1249
|
+
|
|
1250
|
+
new = sub.add_parser("new", help="create a decision, evidence row or note", description="Create a record and keep the vault indexes in sync.")
|
|
1251
|
+
new.add_argument("--root", help=ROOT_HELP)
|
|
1252
|
+
new_sub = new.add_subparsers(dest="new_kind", required=True, metavar="<kind>", title="kinds")
|
|
1253
|
+
new_decision = new_sub.add_parser("decision", help="create a decision record and index row")
|
|
1254
|
+
new_decision.add_argument("title", nargs="?", help="decision title, e.g. \"Adopt usage-based pricing\" (optional with --from)")
|
|
1255
|
+
new_decision.add_argument("--from", dest="from_path", metavar="FILE", help="promote an existing ADR (MADR, Nygard, Y-statement or Polish headings), e.g. one staged by `adopt`; prints the mapping unless --write")
|
|
1256
|
+
new_decision.add_argument("--write", action="store_true", help="with --from: create the record and its decision-log row (default: dry run)")
|
|
1257
|
+
new_decision.add_argument("--owner", help="accountable person (default: policy defaults.owner)")
|
|
1258
|
+
new_decision.add_argument("--status", choices=("draft", "in_review"), default="draft", help="initial status (default: %(default)s)")
|
|
1259
|
+
new_decision.add_argument("--sensitivity", choices=SENSITIVITIES, help="default: policy defaults.sensitivity")
|
|
1260
|
+
new_decision.add_argument("--source", action="append", default=[], dest="source_ids", metavar="E-NNN", help="supporting evidence ID (repeatable)")
|
|
1261
|
+
new_decision.add_argument("--review-by", metavar="YYYY-MM-DD", help="date by which the decision must be re-checked")
|
|
1262
|
+
new_decision.add_argument("--supersedes", metavar="D-NNN", help="decision this one replaces")
|
|
1263
|
+
new_decision.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1264
|
+
_add_leaf_root(new_decision)
|
|
1265
|
+
new_decision.set_defaults(func=cmd_new)
|
|
1266
|
+
|
|
1267
|
+
new_evidence = new_sub.add_parser("evidence", help="append a source to the evidence register")
|
|
1268
|
+
new_evidence.add_argument("--source", required=True, dest="source_name", help="who or what produced the evidence")
|
|
1269
|
+
new_evidence.add_argument("--location", required=True, help="URL or path where the source can be checked")
|
|
1270
|
+
new_evidence.add_argument("--type", required=True, dest="evidence_type", help="kind of source, e.g. interview, analytics, report")
|
|
1271
|
+
new_evidence.add_argument("--claims", required=True, help="what the source supports, in one line")
|
|
1272
|
+
new_evidence.add_argument("--date", metavar="YYYY-MM-DD", help="when the source was produced (default: today)")
|
|
1273
|
+
new_evidence.add_argument("--accessed", metavar="YYYY-MM-DD", help="when it was last checked (default: today)")
|
|
1274
|
+
new_evidence.add_argument("--sensitivity", choices=SENSITIVITIES, help="optional row label; cannot lower the register floor")
|
|
1275
|
+
new_evidence.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1276
|
+
_add_leaf_root(new_evidence)
|
|
1277
|
+
new_evidence.set_defaults(func=cmd_new)
|
|
1278
|
+
|
|
1279
|
+
new_note = new_sub.add_parser("note", help="create a draft note in a workstream")
|
|
1280
|
+
new_note.add_argument("title", help="note title")
|
|
1281
|
+
new_note.add_argument("--workstream", required=True, help="existing workstream directory, e.g. notes")
|
|
1282
|
+
new_note.add_argument("--owner", help="default: policy defaults.owner")
|
|
1283
|
+
new_note.add_argument("--type", choices=("strategy", "research", "framework", "specification", "guide", "reference"), default="guide", dest="doc_type", help="document type (default: %(default)s)")
|
|
1284
|
+
new_note.add_argument("--sensitivity", choices=SENSITIVITIES, help="default: policy defaults.sensitivity")
|
|
1285
|
+
new_note.add_argument("--link-from", metavar="PATH", help="vault-relative Markdown map to link the new note from")
|
|
1286
|
+
new_note.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1287
|
+
_add_leaf_root(new_note)
|
|
1288
|
+
new_note.set_defaults(func=cmd_new)
|
|
1289
|
+
|
|
1290
|
+
status = sub.add_parser("status", help="summarize vault health and review queue")
|
|
1291
|
+
status.add_argument("--root", help=ROOT_HELP)
|
|
1292
|
+
status.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1293
|
+
status.add_argument("--today", help=TODAY_HELP)
|
|
1294
|
+
status.add_argument("--due-days", type=int, metavar="N", help="review window in days (default: policy defaults.status_due_days)")
|
|
1295
|
+
status.add_argument("--strict", action="store_true", help="exit 1 when anything needs attention")
|
|
1296
|
+
status.set_defaults(func=cmd_status)
|
|
1297
|
+
|
|
1298
|
+
graph = sub.add_parser("graph", help="export the vault wikilink graph")
|
|
1299
|
+
graph.add_argument("--root", help=ROOT_HELP)
|
|
1300
|
+
graph.add_argument("--format", choices=("json", "dot", "mermaid", "obsidian"), default=None, help="output format (default: json)")
|
|
1301
|
+
graph.add_argument("--json", action="store_true", help="shorthand for --format json")
|
|
1302
|
+
graph.add_argument("--canonical-only", action="store_true", help="only include canonical documents")
|
|
1303
|
+
graph.add_argument("--output", metavar="PATH", help="write inside the vault (e.g. .whykit/graph.json) instead of stdout")
|
|
1304
|
+
graph.set_defaults(func=cmd_graph)
|
|
1305
|
+
|
|
1306
|
+
backlinks = sub.add_parser("backlinks", help="list inbound links to a note, decision or evidence ID")
|
|
1307
|
+
backlinks.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
|
|
1308
|
+
backlinks.add_argument("--root", help=ROOT_HELP)
|
|
1309
|
+
backlinks.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1310
|
+
backlinks.set_defaults(func=cmd_backlinks)
|
|
1311
|
+
|
|
1312
|
+
impact = sub.add_parser("impact", help="show what depends on evidence, a decision or a document")
|
|
1313
|
+
impact.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
|
|
1314
|
+
impact.add_argument("--root", help=ROOT_HELP)
|
|
1315
|
+
impact.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1316
|
+
impact.set_defaults(func=cmd_impact)
|
|
1317
|
+
|
|
1318
|
+
trace = sub.add_parser("trace", help="trace decisions to their evidence and flag missing, retired or stale sources")
|
|
1319
|
+
trace.add_argument("--root", help=ROOT_HELP)
|
|
1320
|
+
trace.add_argument("--decision", metavar="D-NNN", help="trace only this decision")
|
|
1321
|
+
trace.add_argument("--today", help="evaluate evidence age as of this YYYY-MM-DD date instead of today")
|
|
1322
|
+
trace.add_argument("--max-age-days", type=int, metavar="N", help="treat evidence as stale after N days when policy has no age for its type")
|
|
1323
|
+
trace.add_argument("--gaps-only", action="store_true", help="list only live decisions with gaps")
|
|
1324
|
+
trace.add_argument("--strict", action="store_true", help="exit 1 when any live decision has a gap")
|
|
1325
|
+
trace.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1326
|
+
trace.set_defaults(func=cmd_trace)
|
|
1327
|
+
|
|
1328
|
+
query = sub.add_parser("query", help="query notes by text, metadata and evidence")
|
|
1329
|
+
query.add_argument("text", nargs="?", help="free-text search (optional)")
|
|
1330
|
+
query.add_argument("--root", help=ROOT_HELP)
|
|
1331
|
+
query.add_argument("--type", dest="doc_type", help="filter by document type")
|
|
1332
|
+
query.add_argument("--status", help="filter by status")
|
|
1333
|
+
query.add_argument("--owner", help="filter by owner")
|
|
1334
|
+
query.add_argument("--sensitivity", help="filter by sensitivity")
|
|
1335
|
+
query.add_argument("--source", dest="source_id", metavar="E-NNN", help="only records citing this evidence ID")
|
|
1336
|
+
query.add_argument("--tag", help="filter by tag")
|
|
1337
|
+
query.add_argument("--canonical-only", action="store_true", help="only canonical documents")
|
|
1338
|
+
query.add_argument("--limit", type=int, default=100, metavar="N", help="maximum results (default: %(default)s)")
|
|
1339
|
+
query.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1340
|
+
query.set_defaults(func=cmd_query)
|
|
1341
|
+
|
|
1342
|
+
context = sub.add_parser("context", help="build an evidence-aware context pack for one target")
|
|
1343
|
+
context.add_argument("target", help="vault-relative path, D-NNN or E-NNN")
|
|
1344
|
+
context.add_argument("--root", help=ROOT_HELP)
|
|
1345
|
+
context.add_argument("--max-chars", type=int, default=20_000, metavar="N", help="body character budget (default: %(default)s)")
|
|
1346
|
+
context.add_argument("--no-body", action="store_true", help="omit the document body")
|
|
1347
|
+
context.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1348
|
+
context.set_defaults(func=cmd_context)
|
|
1349
|
+
|
|
1350
|
+
pack = sub.add_parser("pack", help="build a bounded multi-record context bundle")
|
|
1351
|
+
pack.add_argument("targets", nargs="*", help="vault-relative paths, D-NNN or E-NNN")
|
|
1352
|
+
pack.add_argument("--root", help=ROOT_HELP)
|
|
1353
|
+
pack.add_argument("--query", help="add records matching this text query")
|
|
1354
|
+
pack.add_argument("--max-docs", type=int, default=8, metavar="N", help="maximum records (default: %(default)s)")
|
|
1355
|
+
pack.add_argument("--max-chars", type=int, default=30_000, metavar="N", help="total body character budget (default: %(default)s)")
|
|
1356
|
+
pack.add_argument("--canonical-only", action="store_true", help="only canonical documents")
|
|
1357
|
+
pack.add_argument("--format", choices=("json", "markdown"), default=None, help="output format (default: json)")
|
|
1358
|
+
pack.add_argument("--json", action="store_true", help="shorthand for --format json")
|
|
1359
|
+
pack.add_argument("--for", dest="agent", choices=("generic", "cursor", "claude", "codex"), default="generic", help="tailor the preamble to an agent host (default: %(default)s)")
|
|
1360
|
+
pack.set_defaults(func=cmd_pack)
|
|
1361
|
+
|
|
1362
|
+
review = sub.add_parser("review", help="list review work or record a review event")
|
|
1363
|
+
review.add_argument("--root", help=ROOT_HELP)
|
|
1364
|
+
review_sub = review.add_subparsers(dest="review_command", required=True, metavar="<action>", title="actions")
|
|
1365
|
+
review_list = review_sub.add_parser("list", help="show upcoming/overdue reviews")
|
|
1366
|
+
review_list.add_argument("--today", help=TODAY_HELP)
|
|
1367
|
+
review_list.add_argument("--due-days", type=int, metavar="N", help="review window in days (default: policy)")
|
|
1368
|
+
review_list.add_argument("--owner", help="only reviews owned by this person")
|
|
1369
|
+
review_list.add_argument("--overdue-only", action="store_true", help="hide reviews that are not yet due")
|
|
1370
|
+
review_list.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1371
|
+
_add_leaf_root(review_list)
|
|
1372
|
+
review_list.set_defaults(func=cmd_review)
|
|
1373
|
+
review_record = review_sub.add_parser("record", help="record a review event")
|
|
1374
|
+
review_record.add_argument("target", help="vault-relative path or D-NNN")
|
|
1375
|
+
review_record.add_argument("--reviewer", required=True, help="who performed the review")
|
|
1376
|
+
review_record.add_argument("--outcome", choices=("confirmed", "update-required", "supersede-required", "archived"), default="confirmed", help="review result (default: %(default)s)")
|
|
1377
|
+
review_record.add_argument("--next-review", metavar="YYYY-MM-DD", help="next review date")
|
|
1378
|
+
review_record.add_argument("--note", default="", dest="note_text", help="short note for the review log")
|
|
1379
|
+
review_record.add_argument("--today", help="record the review as of this YYYY-MM-DD date")
|
|
1380
|
+
review_record.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1381
|
+
_add_leaf_root(review_record)
|
|
1382
|
+
review_record.set_defaults(func=cmd_review)
|
|
1383
|
+
|
|
1384
|
+
review_approve = review_sub.add_parser("approve", help="preview approval, then apply the reviewed snapshot")
|
|
1385
|
+
review_approve.add_argument("target", help="vault-relative decision path or D-NNN")
|
|
1386
|
+
review_approve.add_argument("--reviewer", required=True, help="who reviewed the snapshot; an attribution, not authentication")
|
|
1387
|
+
review_approve.add_argument("--next-review", metavar="YYYY-MM-DD", help="default: policy decision_review_days after approval")
|
|
1388
|
+
review_approve.add_argument("--today", help="approval date as YYYY-MM-DD")
|
|
1389
|
+
review_approve.add_argument("--write", action="store_true", help="apply only the snapshot identified by --expect-hash")
|
|
1390
|
+
review_approve.add_argument("--expect-hash", dest="expected_sha256", help="expected_sha256 from a reviewed preview")
|
|
1391
|
+
review_approve.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1392
|
+
_add_leaf_root(review_approve)
|
|
1393
|
+
review_approve.set_defaults(func=cmd_review)
|
|
1394
|
+
|
|
1395
|
+
snapshot = sub.add_parser("snapshot", help="create a deterministic vault snapshot")
|
|
1396
|
+
snapshot.add_argument("--root", help=ROOT_HELP)
|
|
1397
|
+
snapshot.add_argument("--today", help=TODAY_HELP)
|
|
1398
|
+
snapshot.add_argument("--output", metavar="PATH", help="write the JSON snapshot here instead of stdout")
|
|
1399
|
+
snapshot.add_argument("--compact", action="store_true", help="emit compact JSON")
|
|
1400
|
+
snapshot.add_argument(
|
|
1401
|
+
"--format", dest="snapshot_format", choices=("v1", "v2"), default=None,
|
|
1402
|
+
help="snapshot format: v2 (default) hashes text with CRLF line endings and a UTF-8 BOM "
|
|
1403
|
+
"normalized, so checkouts on different platforms match; v1 hashes raw bytes",
|
|
1404
|
+
)
|
|
1405
|
+
snapshot.add_argument("--json", action="store_true", help="accepted for symmetry; stdout is always JSON unless --output is given")
|
|
1406
|
+
snapshot.set_defaults(func=cmd_snapshot)
|
|
1407
|
+
|
|
1408
|
+
verify_snapshot = sub.add_parser("verify-snapshot", help="compare the vault with a prior snapshot")
|
|
1409
|
+
verify_snapshot.add_argument("snapshot", help="baseline snapshot JSON (relative paths resolve against the vault root)")
|
|
1410
|
+
verify_snapshot.add_argument("--root", help=ROOT_HELP)
|
|
1411
|
+
verify_snapshot.add_argument("--today", help=TODAY_HELP)
|
|
1412
|
+
verify_snapshot.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1413
|
+
verify_snapshot.set_defaults(func=cmd_verify_snapshot)
|
|
1414
|
+
|
|
1415
|
+
check = sub.add_parser("check", help="run a local/CI/release policy gate")
|
|
1416
|
+
check.add_argument("--root", help=ROOT_HELP)
|
|
1417
|
+
check.add_argument("--profile", default="ci", help="policy profile: local, ci, release or a custom one (default: %(default)s)")
|
|
1418
|
+
check.add_argument("--base", metavar="REF", help="base Git ref for the immutable-history check")
|
|
1419
|
+
check.add_argument("--head", default="HEAD", metavar="REF", help="head Git ref (default: %(default)s)")
|
|
1420
|
+
check.add_argument("--today", help=TODAY_HELP)
|
|
1421
|
+
check.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1422
|
+
check.add_argument("--format", choices=("text", "json", "github"), default=None, help="output format: text (default), json or github (workflow annotations)")
|
|
1423
|
+
check.set_defaults(func=cmd_check)
|
|
1424
|
+
|
|
1425
|
+
policy = sub.add_parser("policy", help="show the effective repository-local policy")
|
|
1426
|
+
policy.add_argument("--root", help=ROOT_HELP)
|
|
1427
|
+
policy.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1428
|
+
policy.set_defaults(func=cmd_policy)
|
|
1429
|
+
|
|
1430
|
+
evidence = sub.add_parser("evidence", help="inspect and manage evidence lifecycle")
|
|
1431
|
+
evidence.add_argument("--root", help=ROOT_HELP)
|
|
1432
|
+
evidence_sub = evidence.add_subparsers(dest="evidence_command", required=True, metavar="<action>", title="actions")
|
|
1433
|
+
evidence_list = evidence_sub.add_parser("list", help="list evidence rows")
|
|
1434
|
+
evidence_list.add_argument("--state", choices=("all", "active", "retired"), default="all", help="which rows to list (default: %(default)s)")
|
|
1435
|
+
evidence_list.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1436
|
+
_add_leaf_root(evidence_list)
|
|
1437
|
+
evidence_list.set_defaults(func=cmd_evidence)
|
|
1438
|
+
evidence_retire = evidence_sub.add_parser("retire", help="retire active evidence")
|
|
1439
|
+
evidence_retire.add_argument("id", metavar="E-NNN", help="evidence ID to retire")
|
|
1440
|
+
evidence_retire.add_argument("--why", required=True, dest="reason", help="why the evidence no longer holds")
|
|
1441
|
+
evidence_retire.add_argument("--replaced-by", metavar="E-NNN", help="evidence that supersedes it")
|
|
1442
|
+
evidence_retire.add_argument("--today", help="retire as of this YYYY-MM-DD date")
|
|
1443
|
+
evidence_retire.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1444
|
+
_add_leaf_root(evidence_retire)
|
|
1445
|
+
evidence_retire.set_defaults(func=cmd_evidence)
|
|
1446
|
+
|
|
1447
|
+
adopt = sub.add_parser("adopt", help="inventory existing Markdown and stage it for a vault")
|
|
1448
|
+
adopt.add_argument("source", help="directory of existing Markdown to inventory")
|
|
1449
|
+
adopt.add_argument("--into", "--root", dest="into", metavar="VAULT", help="vault to stage into (default: nearest vault); --root is the same option, spelled as on every other command")
|
|
1450
|
+
adopt.add_argument("--owner", default="TODO", help="owner for adopted notes (default: %(default)s)")
|
|
1451
|
+
adopt.add_argument("--profile", choices=("generic", "adr-only", "obsidian-loose"), default="generic", help="how to interpret the source (default: %(default)s)")
|
|
1452
|
+
adopt.add_argument("--write", action="store_true", help="write the staged files (default: dry run)")
|
|
1453
|
+
adopt.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1454
|
+
adopt.add_argument("--compare", metavar="DIR", help="read-only preservation check at matching relative paths; cannot combine with --write")
|
|
1455
|
+
adopt.set_defaults(func=cmd_adopt)
|
|
1456
|
+
|
|
1457
|
+
history = sub.add_parser("history", help="verify that accepted decisions were not rewritten")
|
|
1458
|
+
history.add_argument("--base", metavar="REF", help="base commit or ref, e.g. origin/main (required unless --staged, which defaults to HEAD)")
|
|
1459
|
+
history.add_argument("--head", default=None, metavar="REF", help="head commit or ref (default: HEAD)")
|
|
1460
|
+
history.add_argument("--staged", action="store_true", help="check the staged changes against --base instead of a head commit (for pre-commit hooks)")
|
|
1461
|
+
history.add_argument("--root", help=ROOT_HELP)
|
|
1462
|
+
history.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1463
|
+
history.set_defaults(func=cmd_history)
|
|
1464
|
+
|
|
1465
|
+
diff = sub.add_parser(
|
|
1466
|
+
"diff",
|
|
1467
|
+
help="show what changed in decisions, evidence and lint findings between two commits",
|
|
1468
|
+
description="Compare the vault at the merge base of --base and --head with --head, read from Git objects without a checkout.",
|
|
1469
|
+
)
|
|
1470
|
+
diff.add_argument("--base", required=True, metavar="REF", help="base commit or ref, e.g. origin/main")
|
|
1471
|
+
diff.add_argument("--head", default="HEAD", metavar="REF", help="head commit or ref (default: %(default)s)")
|
|
1472
|
+
diff.add_argument("--root", help=ROOT_HELP)
|
|
1473
|
+
diff.add_argument("--today", help=TODAY_HELP)
|
|
1474
|
+
diff.add_argument(
|
|
1475
|
+
"--format", choices=("text", "json", "markdown", "github"), default=None,
|
|
1476
|
+
help="output format: text (default), json, markdown (a pull request comment) or github (workflow annotations)",
|
|
1477
|
+
)
|
|
1478
|
+
diff.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1479
|
+
diff.set_defaults(func=cmd_diff)
|
|
1480
|
+
|
|
1481
|
+
rules = sub.add_parser("rules", help="list lint rules or explain one rule code")
|
|
1482
|
+
rules.add_argument("code", nargs="?", help="rule code to explain, e.g. evidence.unknown_id")
|
|
1483
|
+
rules.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1484
|
+
rules.add_argument("--markdown", action="store_true", help="emit the complete Markdown table")
|
|
1485
|
+
rules.add_argument("--root", help="vault whose whykit.toml custom rules and overrides are listed too (default: nearest vault, if any)")
|
|
1486
|
+
rules.set_defaults(func=cmd_rules)
|
|
1487
|
+
|
|
1488
|
+
doctor = sub.add_parser("doctor", help="check prerequisites and vault integrity")
|
|
1489
|
+
doctor.add_argument("--root", help=ROOT_HELP)
|
|
1490
|
+
doctor.add_argument("--today", help=TODAY_HELP)
|
|
1491
|
+
doctor.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1492
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
1493
|
+
|
|
1494
|
+
hooks = sub.add_parser("install-hooks", help="install the pre-commit vault check")
|
|
1495
|
+
hooks.add_argument("--root", help=ROOT_HELP)
|
|
1496
|
+
hooks.add_argument("--force", action="store_true", help="replace an existing pre-commit hook")
|
|
1497
|
+
hooks.set_defaults(func=cmd_install_hooks)
|
|
1498
|
+
|
|
1499
|
+
recover = sub.add_parser("recover", help="recover interrupted writes and collect completed transaction journals")
|
|
1500
|
+
recover.add_argument("--root", help=ROOT_HELP)
|
|
1501
|
+
recover.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
1502
|
+
recover.set_defaults(func=cmd_recover)
|
|
1503
|
+
|
|
1504
|
+
explorer_index = sub.add_parser(
|
|
1505
|
+
"explorer-index",
|
|
1506
|
+
help="export the Explorer vault index from the canonical Python parser",
|
|
1507
|
+
)
|
|
1508
|
+
explorer_index.add_argument("--root", help=ROOT_HELP)
|
|
1509
|
+
explorer_index.add_argument("--today", help=TODAY_HELP)
|
|
1510
|
+
explorer_index.add_argument("--json", action="store_true", default=True, help="accepted for symmetry; output is always JSON")
|
|
1511
|
+
explorer_index.add_argument("--private", action="store_true", help="full local index; never publish this output")
|
|
1512
|
+
explorer_index.add_argument("--publication-preview", action="store_true", help="private withheld-path diagnostics; never publish this report")
|
|
1513
|
+
explorer_index.set_defaults(func=cmd_explorer_index)
|
|
1514
|
+
|
|
1515
|
+
serve = sub.add_parser("serve", help="run the optional Explorer (source checkout only)")
|
|
1516
|
+
serve.add_argument("vault", nargs="?", default=None, help="vault to serve (default: current directory)")
|
|
1517
|
+
serve.add_argument("--root", help="vault to serve, as on every other command (same as the positional vault)")
|
|
1518
|
+
serve.add_argument("--host", default="127.0.0.1", help="interface to bind (default: %(default)s)")
|
|
1519
|
+
serve.add_argument("--port", type=int, default=5173, help="port to listen on (default: %(default)s)")
|
|
1520
|
+
serve.add_argument("--allow-sensitive-network", action="store_true", help="allow non-loopback serving even when non-public docs are present")
|
|
1521
|
+
serve.set_defaults(func=cmd_serve)
|
|
1522
|
+
|
|
1523
|
+
lsp = sub.add_parser(
|
|
1524
|
+
"lsp",
|
|
1525
|
+
help="run the read-only language server for editors (stdio)",
|
|
1526
|
+
description="Run the read-only WhyKit language server over stdio: lint diagnostics, "
|
|
1527
|
+
"wikilink and ID completion, hover, go-to-definition and document links. It never writes a file.",
|
|
1528
|
+
)
|
|
1529
|
+
lsp.add_argument("--root", help="serve only this vault (default: the vault around each open file)")
|
|
1530
|
+
lsp.add_argument("--debounce", type=int, default=300, metavar="MS", help="milliseconds to wait after an edit before linting (default: %(default)s)")
|
|
1531
|
+
lsp.add_argument("--today", help=TODAY_HELP)
|
|
1532
|
+
lsp.add_argument("--stdio", action="store_true", help="accepted for editor clients that pass it; stdio is the only transport")
|
|
1533
|
+
lsp.set_defaults(func=cmd_lsp)
|
|
1534
|
+
|
|
1535
|
+
completion = sub.add_parser(
|
|
1536
|
+
"completion",
|
|
1537
|
+
help="print a shell completion script",
|
|
1538
|
+
description="Print a completion script for your shell.",
|
|
1539
|
+
epilog=COMPLETION_EPILOG,
|
|
1540
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1541
|
+
)
|
|
1542
|
+
completion.add_argument("shell", choices=SHELLS, help="target shell")
|
|
1543
|
+
completion.set_defaults(func=cmd_completion)
|
|
1544
|
+
|
|
1545
|
+
return parser
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
COMPLETION_EPILOG = """\
|
|
1549
|
+
install:
|
|
1550
|
+
bash eval "$(whykit completion bash)" # add to ~/.bashrc
|
|
1551
|
+
zsh eval "$(whykit completion zsh)" # add to ~/.zshrc
|
|
1552
|
+
fish whykit completion fish > ~/.config/fish/completions/whykit.fish"""
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def cmd_completion(args: argparse.Namespace) -> int:
|
|
1556
|
+
print(render_completion(build_parser(), args.shell), end="")
|
|
1557
|
+
return 0
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _wants_json(args: argparse.Namespace) -> bool:
|
|
1561
|
+
"""Whether this invocation's stdout is a JSON document."""
|
|
1562
|
+
command = getattr(args, "command", None)
|
|
1563
|
+
if command == "explorer-index":
|
|
1564
|
+
return True
|
|
1565
|
+
if command == "snapshot":
|
|
1566
|
+
return not getattr(args, "output", None)
|
|
1567
|
+
if command in ("graph", "pack") and getattr(args, "format", None) in (None, "json"):
|
|
1568
|
+
return True
|
|
1569
|
+
if command in ("lint", "check", "diff") and getattr(args, "format", None) == "json":
|
|
1570
|
+
return True
|
|
1571
|
+
return bool(getattr(args, "json", False))
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
_WINDOWS = sys.platform == "win32"
|
|
1575
|
+
|
|
1576
|
+
# Commands that only read the vault. They reuse and refresh the parse cache;
|
|
1577
|
+
# commands that write the vault, or read other directories (`diff` exports
|
|
1578
|
+
# commits into a temporary directory), never touch it.
|
|
1579
|
+
_CACHED_COMMANDS = frozenset({
|
|
1580
|
+
"lint", "check", "status", "snapshot", "verify-snapshot", "query", "context", "pack",
|
|
1581
|
+
"graph", "backlinks", "impact", "trace", "explorer-index", "doctor",
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _uses_parse_cache(args: argparse.Namespace) -> bool:
|
|
1586
|
+
if getattr(args, "no_cache", False):
|
|
1587
|
+
return False
|
|
1588
|
+
command = getattr(args, "command", None)
|
|
1589
|
+
if command == "review":
|
|
1590
|
+
return getattr(args, "review_command", None) == "list"
|
|
1591
|
+
return command in _CACHED_COMMANDS
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _reader_gone(exc: OSError) -> bool:
|
|
1595
|
+
"""Whether *exc* means the reader of stdout went away (`| head -1`).
|
|
1596
|
+
|
|
1597
|
+
POSIX raises BrokenPipeError (EPIPE). Windows reports a write to a pipe
|
|
1598
|
+
whose reader closed as EINVAL; that errno never carries a file name there,
|
|
1599
|
+
which keeps a real filesystem refusal on the io_error path.
|
|
1600
|
+
"""
|
|
1601
|
+
if isinstance(exc, BrokenPipeError) or exc.errno == errno.EPIPE:
|
|
1602
|
+
return True
|
|
1603
|
+
return _WINDOWS and exc.errno == errno.EINVAL and exc.filename is None
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _silence_stdout() -> None:
|
|
1607
|
+
"""Point stdout at devnull so the interpreter's final flush stays quiet."""
|
|
1608
|
+
try:
|
|
1609
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
1610
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
1611
|
+
except (OSError, ValueError):
|
|
1612
|
+
pass
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _run_command(args: argparse.Namespace, func) -> int:
|
|
1616
|
+
"""Bind config, data and rendered output to one read; discard raced output."""
|
|
1617
|
+
from contextlib import redirect_stdout
|
|
1618
|
+
import tempfile
|
|
1619
|
+
from .io import vault_read_lock, vault_mutation_lock
|
|
1620
|
+
|
|
1621
|
+
read_only = args.command in _CACHED_COMMANDS | {"policy", "rules", "history"}
|
|
1622
|
+
read_only = read_only or (args.command == "evidence" and getattr(args, "evidence_command", None) == "list")
|
|
1623
|
+
read_only = read_only or (args.command == "review" and args.review_command == "list")
|
|
1624
|
+
root = _resolve_vault(getattr(args, "root", None)) if read_only else None
|
|
1625
|
+
if args.command == "lint":
|
|
1626
|
+
from .lint import resolve_root
|
|
1627
|
+
resolved, _, _note = resolve_root(args.root, args.paths)
|
|
1628
|
+
root = resolved if is_vault_root(resolved) else None
|
|
1629
|
+
if root is None:
|
|
1630
|
+
return func(args)
|
|
1631
|
+
# Spill large reports to a private temporary file instead of doubling their RAM.
|
|
1632
|
+
scope = vault_mutation_lock(root) if args.command in {"graph", "snapshot"} and args.output else vault_read_lock(root)
|
|
1633
|
+
with tempfile.SpooledTemporaryFile(max_size=1024 * 1024, mode="w+", encoding="utf-8", errors="surrogateescape") as output:
|
|
1634
|
+
with redirect_stdout(output), scope:
|
|
1635
|
+
result = func(args)
|
|
1636
|
+
output.seek(0)
|
|
1637
|
+
stream = sys.stdout
|
|
1638
|
+
machine = _wants_json(args) or args.command in {"graph", "pack"} or (args.command in {"lint", "check"} and args.format in {"sarif", "github"})
|
|
1639
|
+
from .console import is_utf
|
|
1640
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
1641
|
+
switch = machine and not is_utf(stream) and reconfigure is not None
|
|
1642
|
+
if switch:
|
|
1643
|
+
assert reconfigure is not None
|
|
1644
|
+
encoding, errors = stream.encoding, stream.errors
|
|
1645
|
+
stream.flush()
|
|
1646
|
+
reconfigure(encoding="utf-8", errors="surrogateescape")
|
|
1647
|
+
try:
|
|
1648
|
+
shutil.copyfileobj(output, stream)
|
|
1649
|
+
finally:
|
|
1650
|
+
if switch:
|
|
1651
|
+
assert reconfigure is not None
|
|
1652
|
+
stream.flush()
|
|
1653
|
+
reconfigure(encoding=encoding, errors=errors)
|
|
1654
|
+
return result
|
|
1655
|
+
|
|
1656
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1657
|
+
# Before any output: an ASCII or legacy code-page console must degrade
|
|
1658
|
+
# glyphs, not abort the report with UnicodeEncodeError.
|
|
1659
|
+
harden_stdio()
|
|
1660
|
+
parser = build_parser()
|
|
1661
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
1662
|
+
raw = _hoist_root(raw, set(_subcommand_helps(parser)))
|
|
1663
|
+
_Parser.json_errors = argv_wants_json(raw)
|
|
1664
|
+
_Parser.command_path = _command_path(parser, raw)
|
|
1665
|
+
try:
|
|
1666
|
+
args = parser.parse_args(raw)
|
|
1667
|
+
finally:
|
|
1668
|
+
_Parser.json_errors = False
|
|
1669
|
+
_Parser.command_path = ""
|
|
1670
|
+
func = getattr(args, "func", None)
|
|
1671
|
+
if func is None:
|
|
1672
|
+
parser.print_help(sys.stderr)
|
|
1673
|
+
return 2
|
|
1674
|
+
try:
|
|
1675
|
+
from .parse_cache import persistent
|
|
1676
|
+
|
|
1677
|
+
with persistent(_uses_parse_cache(args)):
|
|
1678
|
+
code = _run_command(args, func)
|
|
1679
|
+
# Flush here, not at interpreter exit: a short report sits in the buffer
|
|
1680
|
+
# until then, and a reader that already left (`| head -1`) would turn
|
|
1681
|
+
# into an "Exception ignored" message and exit status 120.
|
|
1682
|
+
sys.stdout.flush()
|
|
1683
|
+
return code
|
|
1684
|
+
except KeyboardInterrupt:
|
|
1685
|
+
return emit_error("interrupted", "interrupted", json_mode=_wants_json(args))
|
|
1686
|
+
except OSError as exc:
|
|
1687
|
+
if _reader_gone(exc):
|
|
1688
|
+
# The reader went away (`whykit query | head`): exit 1 quietly,
|
|
1689
|
+
# without a second traceback from the final flush.
|
|
1690
|
+
_silence_stdout()
|
|
1691
|
+
return 1
|
|
1692
|
+
# A filesystem refusal is a user-facing condition, not a crash.
|
|
1693
|
+
# WHYKIT_DEBUG=1 restores the traceback for bug reports.
|
|
1694
|
+
if os.environ.get("WHYKIT_DEBUG"):
|
|
1695
|
+
raise
|
|
1696
|
+
return emit_error(
|
|
1697
|
+
"io_error",
|
|
1698
|
+
f"cannot complete `whykit {args.command}`: {describe_os_error(exc)}\n"
|
|
1699
|
+
"hint: check the path and its permissions; set WHYKIT_DEBUG=1 for a traceback",
|
|
1700
|
+
json_mode=_wants_json(args),
|
|
1701
|
+
)
|
|
1702
|
+
except Exception as exc: # noqa: BLE001 - the last line of defence for the contract
|
|
1703
|
+
# A bug in WhyKit. Keep stdout one JSON document under --json, and keep
|
|
1704
|
+
# the traceback (which can quote vault paths and content) off by default.
|
|
1705
|
+
if os.environ.get("WHYKIT_DEBUG"):
|
|
1706
|
+
traceback.print_exc(file=sys.stderr)
|
|
1707
|
+
detail = str(exc).strip().splitlines()
|
|
1708
|
+
reason = f"{type(exc).__name__}: {detail[0]}" if detail else type(exc).__name__
|
|
1709
|
+
return emit_error(
|
|
1710
|
+
"internal_error",
|
|
1711
|
+
f"internal error in `whykit {args.command}`: {reason}\n"
|
|
1712
|
+
f"hint: this is a bug in WhyKit; please report it at {ISSUES_URL} "
|
|
1713
|
+
"with the command you ran and the output of the same command under WHYKIT_DEBUG=1",
|
|
1714
|
+
json_mode=_wants_json(args),
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
if __name__ == "__main__":
|
|
1719
|
+
raise SystemExit(main())
|