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.
Files changed (84) hide show
  1. whykit/__init__.py +21 -0
  2. whykit/adopt.py +776 -0
  3. whykit/backlinks.py +106 -0
  4. whykit/check.py +320 -0
  5. whykit/ci_formats.py +283 -0
  6. whykit/cli.py +1719 -0
  7. whykit/completion.py +193 -0
  8. whykit/config.py +233 -0
  9. whykit/console.py +217 -0
  10. whykit/context.py +204 -0
  11. whykit/contract.py +210 -0
  12. whykit/contract_schemas/backlinks-report.schema.json +61 -0
  13. whykit/contract_schemas/context-bundle.schema.json +27 -0
  14. whykit/contract_schemas/context-pack.schema.json +29 -0
  15. whykit/contract_schemas/impact-report.schema.json +122 -0
  16. whykit/contract_schemas/query-result.schema.json +105 -0
  17. whykit/contract_schemas/status-report.schema.json +176 -0
  18. whykit/contract_schemas/trace-report.schema.json +211 -0
  19. whykit/diff.py +823 -0
  20. whykit/evidence.py +178 -0
  21. whykit/explorer_index.py +554 -0
  22. whykit/graph.py +318 -0
  23. whykit/immutability.py +624 -0
  24. whykit/impact.py +272 -0
  25. whykit/init_layout.py +205 -0
  26. whykit/io.py +563 -0
  27. whykit/lint.py +2190 -0
  28. whykit/lsp.py +1241 -0
  29. whykit/markdown_code.py +107 -0
  30. whykit/mcp_server.py +2289 -0
  31. whykit/messages.py +46 -0
  32. whykit/pack.py +294 -0
  33. whykit/parse_cache.py +752 -0
  34. whykit/placeholders.py +84 -0
  35. whykit/promote.py +651 -0
  36. whykit/py.typed +0 -0
  37. whykit/query.py +174 -0
  38. whykit/review.py +527 -0
  39. whykit/rule_policy.py +1063 -0
  40. whykit/rules.py +274 -0
  41. whykit/scaffold.py +784 -0
  42. whykit/sensitivity.py +95 -0
  43. whykit/snapshot.py +319 -0
  44. whykit/status.py +167 -0
  45. whykit/tables.py +120 -0
  46. whykit/template/00-context/README.md +17 -0
  47. whykit/template/00-context/company.md +52 -0
  48. whykit/template/00-context/evidence-register.md +34 -0
  49. whykit/template/00-context/goals.md +38 -0
  50. whykit/template/00-context/review-log.md +22 -0
  51. whykit/template/00-context/terminology.md +34 -0
  52. whykit/template/01-strategy/README.md +7 -0
  53. whykit/template/02-discoverability/README.md +10 -0
  54. whykit/template/03-website/README.md +7 -0
  55. whykit/template/04-automation/README.md +10 -0
  56. whykit/template/05-operations/README.md +19 -0
  57. whykit/template/06-decisions/README.md +11 -0
  58. whykit/template/06-decisions/decision-log.md +38 -0
  59. whykit/template/07-research/README.md +10 -0
  60. whykit/template/AGENTS.md +163 -0
  61. whykit/template/Home.md +57 -0
  62. whykit/template/INTEROP.md +117 -0
  63. whykit/template/OBSIDIAN.md +92 -0
  64. whykit/template/README.md +51 -0
  65. whykit/template/assets/README.md +6 -0
  66. whykit/template/reports/README.md +7 -0
  67. whykit/template/templates/README.md +7 -0
  68. whykit/template/templates/automation-spec-template.md +70 -0
  69. whykit/template/templates/decision-record-template.md +65 -0
  70. whykit/template/templates/document-template.md +55 -0
  71. whykit/template/templates/experiment-template.md +62 -0
  72. whykit/template/templates/meeting-note-template.md +47 -0
  73. whykit/template/templates/page-brief-template.md +63 -0
  74. whykit/template/templates/research-note-template.md +53 -0
  75. whykit/template/templates/source-ingestion-template.md +43 -0
  76. whykit/template/whykit.toml +39 -0
  77. whykit/trace.py +272 -0
  78. whykit/vault_index.py +190 -0
  79. whykit-0.3.1.dist-info/METADATA +162 -0
  80. whykit-0.3.1.dist-info/RECORD +84 -0
  81. whykit-0.3.1.dist-info/WHEEL +4 -0
  82. whykit-0.3.1.dist-info/entry_points.txt +3 -0
  83. whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
  84. whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/rules.py ADDED
@@ -0,0 +1,274 @@
1
+ """Stable public catalog for WhyKit lint rule codes."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict, dataclass, replace
8
+ from typing import Any
9
+
10
+ from .console import emit_machine
11
+
12
+ from .contract import CONTRACT_VERSION, emit_error
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Rule:
17
+ code: str
18
+ default_level: str
19
+ summary: str
20
+ why: str
21
+ fix: str
22
+ # Security-relevant rules cannot be lowered or switched off in
23
+ # whykit.toml without a written reason, and every such override is
24
+ # reported with the lint and check output.
25
+ security: bool = False
26
+
27
+
28
+ _RULES = (
29
+ Rule("agents.absent", "warning", "No AGENTS.md contract is present.", "Agents otherwise invent operating rules from context.", "Add AGENTS.md and state what agents may read, write, commit and escalate."),
30
+ Rule("agents.unconfigured", "warning", "AGENTS.md still contains an unanswered TODO.", "A placeholder contract looks authoritative while leaving a material choice undefined.", "Replace every contract TODO with an explicit operating rule."),
31
+ Rule("canonical.owner", "warning", "A canonical document has no real owner.", "A source of truth without ownership has no accountable reviewer.", "Set owner to a person or accountable role."),
32
+ Rule("canonical.unapproved", "error", "A draft or review document is marked source_of_truth.", "Unapproved material must not silently become canonical.", "Set source_of_truth: false until status is approved."),
33
+ Rule("date.filename_mismatch", "warning", "A dated filename and created date disagree.", "Point-in-time artifacts become hard to order and audit.", "Align the filename date with created, or correct the metadata."),
34
+ Rule("date.invalid", "error", "created or last_updated is not a real ISO date.", "Invalid dates break deterministic review and timeline logic.", "Use a real YYYY-MM-DD date."),
35
+ Rule("date.order", "error", "last_updated is earlier than created.", "The lifecycle metadata is internally contradictory.", "Correct one of the dates."),
36
+ Rule("date.placeholder", "error", "A non-template document still uses YYYY-MM-DD.", "A placeholder date is indistinguishable from missing lifecycle data.", "Replace it with the real date."),
37
+ Rule("decision.duplicate", "error", "A decision ID is reused.", "D-NNN identifiers are stable references and must be unique forever.", "Allocate a new decision ID; never recycle an old one."),
38
+ Rule("decision.id_filename", "error", "decision_id and filename number disagree.", "Humans and tools use both forms as identifiers.", "Rename the file or correct decision_id so they match."),
39
+ Rule("decision.id_missing", "error", "A decision record has no D-NNN identifier.", "Without an ID, citations and supersession cannot be stable.", "Add decision_id and a matching d-NNN-*.md filename."),
40
+ Rule("decision.placeholder", "warning", "A draft, in-review or approved decision record still holds template placeholder text.", "A record that still says \"State the choice in one sentence.\" passes every other shape check while recording no decision.", "Replace the prompt with the real context, decision and rationale, fill the alternatives table or say there was one option, and list the consequences."),
41
+ Rule("decision.review_missing", "warning", "An approved decision has no review_by date.", "Accepted decisions otherwise look current forever as assumptions change.", "Set a realistic review_by date."),
42
+ Rule("decision.unreviewed", "warning", "An approved decision's provenance denies human review.", "Approval contradicts the record's own declared review state.", "Keep the record draft or in_review until an authorized person reviews it; changing a boolean is not authentication."),
43
+ Rule("decision.superseded_by_missing", "warning", "A superseded decision has no newer record pointing back to it.", "The chain of reasoning is incomplete.", "Create or fix the newer decision's supersedes field."),
44
+ Rule("decision.superseded_by_format", "warning", "superseded_by is not a D-NNN identifier.", "Lifecycle metadata should point to the same stable decision namespace as supersedes.", "Use a D-NNN identifier or remove the convenience field."),
45
+ Rule("decision.superseded_by_mismatch", "warning", "superseded_by disagrees with the approved reverse supersedes edge.", "Two replacement pointers make the decision lineage ambiguous.", "Align superseded_by with the approved record whose supersedes field points here."),
46
+ Rule("decision.supersedes_format", "error", "supersedes is not a D-NNN identifier.", "Supersession must point to a stable decision ID.", "Use a value such as D-012."),
47
+ Rule("decision.supersedes_missing", "error", "supersedes points to a decision record that does not exist.", "A missing predecessor breaks decision history.", "Restore the record or correct the referenced ID."),
48
+ Rule("decision.supersedes_status", "warning", "A newer decision supersedes an older record not marked superseded.", "The two records disagree about which decision is current.", "Mark the older record superseded after the replacement is accepted."),
49
+ Rule("decision.supersession_fork", "error", "One decision has multiple approved replacements.", "Two accepted successors make the current decision lineage ambiguous.", "Resolve the conflict so only one approved decision supersedes the predecessor; preserve rejected alternatives as non-approved records."),
50
+ Rule("decision.supersession_cycle", "error", "The supersedes relation contains a cycle.", "A decision cannot ultimately supersede itself; cyclic lineage makes historical/current state impossible to resolve.", "Break the cycle and restore an acyclic predecessor chain."),
51
+ Rule("decision_log.ambiguous", "error", "A decision-log wikilink resolves to multiple records.", "The index must identify one decision record deterministically.", "Use the full vault-relative path in the wikilink."),
52
+ Rule("decision_log.date_mismatch", "warning", "Decision log date differs from record created date.", "Two timelines for one decision are confusing and hard to audit.", "Align the row date and record metadata."),
53
+ Rule("decision_log.duplicate", "error", "A D-NNN appears more than once in the decision log.", "The log must be a unique index.", "Keep one row and preserve history in the decision record."),
54
+ Rule("decision_log.id_mismatch", "error", "A log row points to a record with another decision_id.", "The index would direct readers to the wrong rationale.", "Fix the row ID or record link."),
55
+ Rule("decision_log.missing_record", "error", "A decision-log row points to a missing note.", "Indexed decisions must remain inspectable.", "Restore the record or repair the wikilink."),
56
+ Rule("decision_log.record", "error", "A populated decision row has no record wikilink.", "The log cannot substitute for the rationale itself.", "Link the row to its d-NNN-*.md record."),
57
+ Rule("decision_log.status_mismatch", "error", "Log status and record status disagree.", "Readers cannot tell which lifecycle state is authoritative.", "Update the log row to match the record."),
58
+ Rule("decision_log.unindexed", "error", "A decision record is missing from decision-log.md.", "Unindexed decisions disappear from the operating memory.", "Add one decision-log row for the record."),
59
+ Rule("config.invalid", "error", "whykit.toml is invalid.", "A repository-local policy must parse identically for humans, CI and agents.", "Fix the TOML structure or unsupported policy values."),
60
+ Rule("delivery_status.invalid", "error", "delivery_status has an unsupported value.", "Downstream systems need a small stable delivery state machine.", "Use draft, ready_to_send or sent."),
61
+ Rule("delivery_status.sent_at", "error", "A sent document has no sent_at timestamp/date field.", "Delivery history must distinguish drafted content from delivered content.", "Record sent_at when setting delivery_status: sent."),
62
+ Rule("evidence.date", "error", "An evidence date or accessed date is invalid.", "Freshness cannot be reasoned about without valid dates.", "Use real YYYY-MM-DD dates."),
63
+ Rule("evidence.sensitivity", "warning", "An explicit evidence Sensitivity cell is empty or invalid.", "Filtered readers cannot safely classify the row.", "Use public, internal, confidential or restricted; omit the column only for legacy inheritance."),
64
+ Rule("evidence.access_missing", "warning", "An active source covered by an access-age policy has no Accessed date.", "The policy cannot tell whether this source was revisited.", "Record when the source was last accessed, or remove the policy for historical source types."),
65
+ Rule("evidence.access_future", "warning", "An active source has an Accessed date after the lint date.", "A future date cannot establish that a source has already been checked.", "Correct the Accessed date or run lint with the intended --today date."),
66
+ Rule("evidence.access_stale", "warning", "An active source has not been accessed within its configured age window.", "Mutable sources may have changed since the last check.", "Revisit the source and update Accessed after review; do not treat access alone as claim approval."),
67
+ Rule("evidence.duplicate", "error", "An E-NNN identifier is reused.", "Evidence IDs are durable citations and cannot safely refer to two sources.", "Allocate a new E-NNN."),
68
+ Rule("evidence.id_format", "error", "source_ids contains a malformed evidence ID.", "Machine resolution relies on the E-NNN contract.", "Use E- followed by at least three digits."),
69
+ Rule("embed.missing", "error", "An Obsidian embed target cannot be resolved.", "Broken embeds render as missing media and hide the intended note.", "Fix the ![[target]] path or restore the note."),
70
+ Rule("hub.unlinked_workstream", "warning", "Home.md does not link into a top-level working directory.", "The map of content should reach every workstream folder so agents and people can navigate.", "Add a wikilink from Home.md into the folder, or remove an unused directory."),
71
+ Rule("evidence.missing", "error", "A cited E-NNN has no populated active or retired row.", "A citation without a source location is not auditable evidence.", "Populate the register row or correct the citation."),
72
+ Rule("evidence.replaced_by_format", "error", "A retired source has a malformed replacement ID.", "Replacement chains must use stable evidence identifiers.", "Use a valid E-NNN or leave the field empty."),
73
+ Rule("evidence.replaced_by_missing", "error", "A retired source points to an unknown replacement.", "The provenance chain ends at a dangling identifier.", "Register the replacement source or correct the ID."),
74
+ Rule("evidence.retired", "warning", "A document cites retired evidence.", "Historical citations may be valid, but current claims may need re-checking. Superseded and archived records are history and are not flagged.", "Review the claim and cite the replacement where appropriate."),
75
+ Rule("evidence.retired_date", "error", "A retired evidence row has an invalid retirement date.", "Retirement history must be chronologically auditable.", "Use a real YYYY-MM-DD date."),
76
+ Rule("fact.inline_evidence", "warning", "A fact callout has no inline E-NNN citation.", "Fact callouts visually claim verification, so the supporting source should be one click away.", "Add an E-NNN in the callout."),
77
+ Rule("fact.evidence_missing", "warning", "A fact callout cites an E-NNN with no populated register row.", "A citation that resolves nowhere looks verified while pointing at nothing.", "Register the source or correct the cited ID."),
78
+ Rule("frontmatter.invalid", "error", "YAML front matter cannot be parsed.", "Governance metadata is unavailable when parsing fails.", "Fix the front matter syntax."),
79
+ Rule("frontmatter.empty", "warning", "A required metadata key is present but has no value.", "An empty key passes the presence check while every value check skips it.", "Fill in the value, or use the documented placeholder in templates."),
80
+ Rule("frontmatter.missing", "error", "A governed note has no YAML front matter.", "Status, owner, sensitivity and provenance would be undefined.", "Add the standard WhyKit front matter block."),
81
+ Rule("frontmatter.required", "error", "A required metadata key is missing.", "Core lifecycle and handling fields must be explicit.", "Add the named key."),
82
+ Rule("markdown_link.missing", "warning", "A relative Markdown link or image points to a missing local file.", "WhyKit is editor-agnostic, so standard Markdown navigation should be as auditable as wikilinks.", "Fix the relative path or remove the stale link."),
83
+ Rule("markdown_link.outside", "warning", "A relative Markdown link or image resolves outside the vault.", "The governed knowledge graph should not silently depend on untracked local files.", "Keep the asset/note inside the vault or use an explicit external URL/evidence location."),
84
+ Rule("note.orphan", "warning", "Nothing in the vault links to this note.", "Unreachable notes become invisible operationally even though the file still exists.", "Link it from Home.md or a workstream map."),
85
+ Rule("review_log.date", "error", "A review-log row has an invalid date.", "Review history needs an auditable timeline.", "Use a real YYYY-MM-DD date."),
86
+ Rule("review_log.next_review", "error", "A review-log row has an invalid next-review date.", "Review scheduling cannot act on an invalid date.", "Use YYYY-MM-DD or an em dash when no next review applies."),
87
+ Rule("review_log.outcome", "error", "A review-log row has an unsupported outcome.", "Automation depends on a stable review outcome vocabulary.", "Use confirmed, update-required, supersede-required or archived."),
88
+ Rule("review_log.reviewer", "error", "A review-log row has no real reviewer.", "A review without accountable ownership is not an auditable review.", "Name the person or team that performed the review."),
89
+ Rule("review_log.row", "error", "A review-log row is malformed.", "Malformed review history cannot be parsed reliably.", "Restore all seven review-log columns."),
90
+ Rule("review_log.table", "error", "The review-log table is missing or malformed.", "Review events need one deterministic append-only structure.", "Restore the standard review-log table header."),
91
+ Rule("review_log.target", "error", "A review-log target is missing or ambiguous.", "A review event must point to one governed record.", "Use an unambiguous vault wikilink."),
92
+ Rule("path.case_collision", "warning", "Two notes have paths that differ only by letter case or Unicode normalization.", "macOS and Windows checkouts keep only one of them, so the vault silently differs between machines.", "Rename or merge one of the notes so every path is unique ignoring case."),
93
+ Rule("report.undated", "error", "A point-in-time report filename has no YYYY-MM-DD suffix.", "Reports need an immutable temporal identity.", "Rename it to end in -YYYY-MM-DD.md."),
94
+ Rule("review_by.invalid", "error", "review_by is not a real ISO date.", "Review automation cannot act on an invalid date.", "Use YYYY-MM-DD."),
95
+ Rule("review_by.overdue", "warning", "An approved document is past review_by.", "The document may still be correct, but its freshness promise expired.", "Re-review it, update the evidence, and set the next review date."),
96
+ Rule("secret.detected", "error", "Text resembles a credential or private key.", "A knowledge vault is not a secret manager.", "Remove the value, rotate it if real, and store only a secret-manager location."),
97
+ Rule("secret.scan_non_utf8", "error", "A secret-scan candidate is not valid UTF-8.", "Unverified credential-shaped files create false confidence.", "Convert the file to UTF-8, exclude it explicitly, or scan it externally."),
98
+ Rule("secret.scan_skipped_large_file", "error", "A text asset is larger than the built-in secret scanner limit.", "Silent skips create false confidence that a vault contains no credentials.", "Scan the file externally, exclude it explicitly, or split it into smaller governed assets."),
99
+ Rule("secret.scan_unreadable", "error", "A secret-scan candidate could not be read.", "Unreadable credential-shaped files create false confidence.", "Fix permissions or exclude the path explicitly after an external scan."),
100
+ Rule("sensitivity.invalid", "error", "sensitivity uses an unsupported label.", "Handling policy depends on a small shared vocabulary.", "Use public, internal, confidential or restricted."),
101
+ Rule("source_of_truth.invalid", "warning", "source_of_truth is not a boolean.", "Values such as yes or \"true\" bypass the canonical-document checks that only fire on a real true.", "Use source_of_truth: true or source_of_truth: false."),
102
+ Rule("status.invalid", "error", "status uses an unsupported lifecycle value.", "Tools and people need one state model.", "Use template, draft, in_review, approved, superseded or archived."),
103
+ Rule("type.invalid", "error", "type uses an unsupported document type.", "Stable types make indexes and integrations predictable.", "Use a documented WhyKit type."),
104
+ Rule("wikilink.ambiguous", "error", "A wikilink matches multiple notes.", "The same link would resolve differently as the vault changes.", "Use a vault-relative path rather than a bare stem/alias."),
105
+ Rule("wikilink.missing", "error", "A wikilink cannot be resolved.", "Broken knowledge edges hide context and make the graph unreliable.", "Fix the target or remove the stale link."),
106
+ Rule("wikilink.outside", "warning", "A wikilink tries to traverse outside the vault.", "Vault links must not silently depend on files outside the governed boundary.", "Copy/register the source inside the vault or record an external location as evidence."),
107
+ )
108
+
109
+ SECURITY_RULE_CODES = frozenset({
110
+ "decision.unreviewed",
111
+ "markdown_link.outside",
112
+ "secret.detected",
113
+ "secret.scan_non_utf8",
114
+ "secret.scan_skipped_large_file",
115
+ "secret.scan_unreadable",
116
+ "sensitivity.invalid",
117
+ "wikilink.outside",
118
+ })
119
+ RULES = tuple(replace(rule, security=rule.code in SECURITY_RULE_CODES) for rule in _RULES)
120
+ RULE_BY_CODE = {rule.code: rule for rule in RULES}
121
+
122
+
123
+ def _md_cell(value: str) -> str:
124
+ return value.replace("|", r"\|").replace("\n", " ").strip()
125
+
126
+
127
+ def markdown_table() -> str:
128
+ lines = [
129
+ "| Code | Level | What it means | Default fix |",
130
+ "|---|---|---|---|",
131
+ ]
132
+ # The named anchor lets SARIF helpUri and annotation links address one
133
+ # rule: docs/rules.md#evidence.missing. Tables have no per-row headings.
134
+ lines.extend(
135
+ f'| <a name="{rule.code}"></a>`{rule.code}` | {rule.default_level} | {_md_cell(rule.summary)} | {_md_cell(rule.fix)} |'
136
+ for rule in RULES
137
+ )
138
+ return "\n".join(lines)
139
+
140
+
141
+ def builtin_entry(rule: Rule) -> dict[str, Any]:
142
+ return {**asdict(rule), "custom": False}
143
+
144
+
145
+ def _scope_text(applies_to: dict[str, list[str]]) -> str:
146
+ if not applies_to:
147
+ return "every governed note"
148
+ return "; ".join(f"{key}: {', '.join(values)}" for key, values in applies_to.items())
149
+
150
+
151
+ def _checks_text(checks: dict[str, Any]) -> str:
152
+ parts = []
153
+ for key, value in checks.items():
154
+ if isinstance(value, dict):
155
+ shown = ", ".join(f"{k} = {' or '.join(v)}" for k, v in value.items())
156
+ elif isinstance(value, list):
157
+ shown = ", ".join(f"`{item}`" if key.endswith("patterns") else item for item in value)
158
+ else:
159
+ shown = str(value)
160
+ parts.append(f"{key}: {shown}")
161
+ return "; ".join(parts)
162
+
163
+
164
+ def policy_markdown(policy: Any) -> str:
165
+ """Markdown tables for a vault's custom rules and overrides (empty when none)."""
166
+ blocks: list[str] = []
167
+ if policy.custom:
168
+ lines = [
169
+ "### Custom rules (whykit.toml)",
170
+ "",
171
+ "| Code | Level | What it means | Applies to | Checks |",
172
+ "|---|---|---|---|---|",
173
+ ]
174
+ lines.extend(
175
+ f"| `{rule.code}` | {rule.level} | {_md_cell(rule.summary)} | {_md_cell(_scope_text(rule.applies_to))} | {_md_cell(_checks_text(rule.checks))} |"
176
+ for rule in policy.custom
177
+ )
178
+ blocks.append("\n".join(lines))
179
+ if policy.overrides:
180
+ lines = [
181
+ "### Overrides (whykit.toml)",
182
+ "",
183
+ "| Rule | Level | Paths | Reason |",
184
+ "|---|---|---|---|",
185
+ ]
186
+ lines.extend(
187
+ f"| `{entry.rule}` | {entry.level} | {_md_cell(', '.join(entry.paths) or 'whole vault')} | {_md_cell(entry.reason or '')} |"
188
+ for entry in policy.overrides
189
+ )
190
+ blocks.append("\n".join(lines))
191
+ return "\n\n".join(blocks)
192
+
193
+
194
+ def _override_entry(entry: Any) -> dict[str, Any]:
195
+ return {"rule": entry.rule, "level": entry.level, "paths": list(entry.paths), "reason": entry.reason, "security": entry.security}
196
+
197
+
198
+ def main(argv: list[str] | None = None) -> int:
199
+ parser = argparse.ArgumentParser(prog="whykit rules", description="List WhyKit lint rules or explain one stable rule code.")
200
+ parser.add_argument("code", nargs="?", help="rule code, for example evidence.missing")
201
+ parser.add_argument("--root", help="vault whose whykit.toml custom rules and overrides are listed too (default: nearest vault, if any)")
202
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
203
+ parser.add_argument("--markdown", action="store_true", help="emit the complete Markdown rule table")
204
+ args = parser.parse_args(argv)
205
+ if args.json and args.markdown:
206
+ parser.print_usage(sys.stderr)
207
+ return emit_error("usage", f"{parser.prog}: error: --json and --markdown are mutually exclusive", json_mode=args.json)
208
+ if args.code and args.markdown:
209
+ parser.print_usage(sys.stderr)
210
+ return emit_error("usage", f"{parser.prog}: error: --markdown renders the full catalog and does not accept a single code", json_mode=args.json)
211
+
212
+ from pathlib import Path
213
+
214
+ from .config import ConfigError, load_config
215
+ from .contract import vault_not_found
216
+ from .lint import find_vault_root, is_vault_root
217
+ from .rule_policy import EMPTY_POLICY, policy_from_config
218
+
219
+ policy = EMPTY_POLICY
220
+ root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
221
+ if args.root and (root is None or not is_vault_root(root)):
222
+ return vault_not_found(args.root, json_mode=args.json)
223
+ if root is not None:
224
+ try:
225
+ config, _ = load_config(root)
226
+ policy = policy_from_config(config)
227
+ except ConfigError as exc:
228
+ return emit_error("invalid_config", str(exc), json_mode=args.json)
229
+
230
+ entries = [builtin_entry(rule) for rule in RULES] + [rule.catalog_entry() for rule in policy.custom]
231
+ by_code = {entry["code"]: entry for entry in entries}
232
+
233
+ if args.code:
234
+ entry = by_code.get(args.code)
235
+ if entry is None:
236
+ return emit_error("invalid_argument", f"unknown rule code: {args.code}", json_mode=args.json)
237
+ if args.json:
238
+ emit_machine(json.dumps({"contract_version": CONTRACT_VERSION, **entry}, ensure_ascii=False, indent=2))
239
+ else:
240
+ tags = " security" if entry["security"] else (" custom" if entry["custom"] else "")
241
+ print(f"{entry['code']} [{entry['default_level']}{tags}]")
242
+ print(entry["summary"])
243
+ print(f"Why: {entry['why']}")
244
+ print(f"Fix: {entry['fix']}")
245
+ if entry["custom"]:
246
+ print(f"Applies to: {_scope_text(entry['applies_to'])}")
247
+ print(f"Checks: {_checks_text(entry['checks'])}")
248
+ for override in policy.overrides:
249
+ if override.rule == entry["code"]:
250
+ print(f"Override: {override.describe().split(' (', 1)[0]}" + (f" — reason: {override.reason}" if override.reason else ""))
251
+ return 0
252
+
253
+ if args.json:
254
+ payload: dict[str, Any] = {"contract_version": CONTRACT_VERSION, "count": len(entries), "rules": entries}
255
+ if policy.overrides:
256
+ payload["overrides"] = [_override_entry(entry) for entry in policy.overrides]
257
+ emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
258
+ elif args.markdown:
259
+ extra = policy_markdown(policy)
260
+ print(markdown_table() + ("\n\n" + extra if extra else ""))
261
+ else:
262
+ for rule in RULES:
263
+ print(f"{rule.default_level:<7} {rule.code:<34} {rule.summary}")
264
+ if policy.custom:
265
+ print("\nCustom rules (whykit.toml)")
266
+ for custom in policy.custom:
267
+ print(f"{custom.level:<7} {custom.code:<34} {custom.summary}")
268
+ if policy.overrides:
269
+ print("\nOverrides (whykit.toml)")
270
+ for override in policy.overrides:
271
+ scope = ", ".join(override.paths) if override.paths else "whole vault"
272
+ reason = f" reason: {override.reason}" if override.reason else ""
273
+ print(f"{override.level:<7} {override.rule:<34} {scope}{reason}")
274
+ return 0