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/ci_formats.py ADDED
@@ -0,0 +1,283 @@
1
+ """Lint findings in the formats CI systems already read.
2
+
3
+ Two renderings of the same findings ``whykit lint --json`` reports:
4
+
5
+ * SARIF 2.1.0 (``--format sarif``), for code-scanning dashboards such as
6
+ GitHub's. ``ruleId`` is the stable rule code, and every rule's ``helpUri``
7
+ points at that code's anchor in ``docs/rules.md``.
8
+ * GitHub Actions workflow commands (``--format github``), which the runner
9
+ turns into inline pull-request annotations.
10
+
11
+ Both speak in paths relative to the Git work tree, because that is what a
12
+ pull request diff and a code-scanning upload are keyed on. A vault in a
13
+ subdirectory (``--root knowledge``) therefore reports ``knowledge/Home.md``,
14
+ not ``Home.md``. Outside Git the paths stay vault-relative.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import shutil
20
+ import subprocess
21
+ from collections.abc import Iterable, Mapping
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import quote
25
+
26
+ from . import __version__
27
+ from .rules import RULES, Rule
28
+
29
+ SARIF_VERSION = "2.1.0"
30
+ SARIF_SCHEMA_URI = "https://json.schemastore.org/sarif-2.1.0.json"
31
+ INFORMATION_URI = "https://github.com/CometWeb-io/whykit"
32
+ RULES_DOC_URI = f"{INFORMATION_URI}/blob/main/docs/rules.md"
33
+ # The uriBaseId every result location is relative to: the Git work tree root
34
+ # (or the vault root outside Git). Code-scanning uploads resolve it against
35
+ # the checkout.
36
+ SRCROOT = "%SRCROOT%"
37
+ FINGERPRINT_KEY = "whykitFinding/v1"
38
+
39
+
40
+ def rule_anchor(code: str) -> str:
41
+ """The fragment that addresses *code* in ``docs/rules.md``."""
42
+ return code
43
+
44
+
45
+ def rule_help_uri(code: str) -> str:
46
+ return f"{RULES_DOC_URI}#{rule_anchor(code)}"
47
+
48
+
49
+ def _git_location(root: Path) -> tuple[Path, str] | None:
50
+ """Return ``(work tree top, vault prefix)`` when *root* is inside Git."""
51
+ if shutil.which("git") is None:
52
+ return None
53
+ try:
54
+ result = subprocess.run(
55
+ ["git", "-C", str(root), "rev-parse", "--show-toplevel", "--show-prefix"],
56
+ capture_output=True, text=True, encoding="utf-8", errors="surrogateescape", check=False,
57
+ )
58
+ except OSError:
59
+ return None
60
+ lines = result.stdout.splitlines()
61
+ if result.returncode != 0 or not lines:
62
+ return None
63
+ prefix = lines[1].strip().replace("\\", "/") if len(lines) > 1 else ""
64
+ if prefix and not prefix.endswith("/"):
65
+ prefix += "/"
66
+ return Path(lines[0].strip()), prefix
67
+
68
+
69
+ def source_root(root: Path) -> tuple[Path, str]:
70
+ """The directory results are relative to, and the vault's prefix in it."""
71
+ located = _git_location(root)
72
+ if located is None:
73
+ return root, ""
74
+ return located
75
+
76
+
77
+ def _source_path(path: str, prefix: str) -> str:
78
+ """A vault-relative finding path as a work-tree-relative POSIX path."""
79
+ path = path.replace("\\", "/")
80
+ if Path(path).is_absolute():
81
+ return path
82
+ if path in ("", "."):
83
+ return prefix.rstrip("/") or "."
84
+ return f"{prefix}{path}"
85
+
86
+
87
+ def _level(finding: Mapping[str, Any]) -> str:
88
+ return "error" if finding["level"] == "error" else "warning"
89
+
90
+
91
+ def _fingerprint(code: str, path: str, message: str) -> str:
92
+ # The line is deliberately left out: editing text above a finding must not
93
+ # turn it into a "new" alert on the code-scanning page.
94
+ digest = hashlib.sha256(f"{code}\0{path}\0{message}".encode("utf-8", "surrogateescape"))
95
+ return digest.hexdigest()
96
+
97
+
98
+ def is_custom(code: str) -> bool:
99
+ """A team rule from whykit.toml; it has no entry in ``docs/rules.md``."""
100
+ return code.startswith("custom.")
101
+
102
+
103
+ def _sarif_rule(rule: Rule) -> dict[str, Any]:
104
+ entry: dict[str, Any] = {
105
+ "id": rule.code,
106
+ "shortDescription": {"text": rule.summary},
107
+ "fullDescription": {"text": rule.why},
108
+ "help": {
109
+ "text": f"{rule.summary}\nWhy: {rule.why}\nFix: {rule.fix}",
110
+ "markdown": f"{rule.summary}\n\n**Why:** {rule.why}\n\n**Fix:** {rule.fix}",
111
+ },
112
+ "helpUri": rule_help_uri(rule.code),
113
+ "defaultConfiguration": {"level": rule.default_level},
114
+ "properties": {"tags": ["whykit", rule.code.split(".", 1)[0]]},
115
+ }
116
+ if is_custom(rule.code):
117
+ del entry["helpUri"]
118
+ if rule.security:
119
+ entry["properties"]["tags"].append("security")
120
+ return entry
121
+
122
+
123
+ def _custom_catalog(root: Path) -> list[Rule]:
124
+ """Catalog entries for the vault's own custom rules (none if the policy is invalid)."""
125
+ from .config import ConfigError, load_config
126
+ from .rule_policy import policy_from_config
127
+
128
+ try:
129
+ config, _ = load_config(root)
130
+ except ConfigError:
131
+ return []
132
+ return [
133
+ Rule(rule.code, rule.level, rule.summary, rule.why, rule.fix)
134
+ for rule in policy_from_config(config).custom
135
+ ]
136
+
137
+
138
+ def to_sarif(
139
+ findings: Iterable[Mapping[str, Any]],
140
+ root: Path,
141
+ *,
142
+ files: int,
143
+ overrides: Iterable[Any] = (),
144
+ ) -> dict[str, Any]:
145
+ """Render lint findings (``asdict(Finding)`` mappings) as one SARIF log.
146
+
147
+ Findings that a security-relevant override switched off are included as
148
+ results carrying an external ``suppressions`` entry with the policy's
149
+ reason, so a code-scanning dashboard shows them as suppressed instead of
150
+ never learning they existed.
151
+ """
152
+ from dataclasses import asdict
153
+
154
+ base, prefix = source_root(root)
155
+ findings = list(findings)
156
+ overrides = list(overrides)
157
+ suppressed: list[tuple[Mapping[str, Any], str]] = []
158
+ for entry in overrides:
159
+ if entry.security:
160
+ suppressed.extend((asdict(item), entry.reason or "") for item in entry.suppressed)
161
+ rules = list(RULES)
162
+ codes = {f["code"] for f in findings} | {f["code"] for f, _ in suppressed}
163
+ if any(is_custom(code) for code in codes):
164
+ rules.extend(_custom_catalog(root))
165
+ index = {rule.code: position for position, rule in enumerate(rules)}
166
+ results: list[dict[str, Any]] = []
167
+ justification = {id(f): reason for f, reason in suppressed}
168
+ ordered = sorted(
169
+ [*findings, *(f for f, _ in suppressed)],
170
+ key=lambda f: (f["path"], f["line"] or 0, f["code"], f["message"]),
171
+ )
172
+ for finding in ordered:
173
+ code = finding["code"]
174
+ if code not in index:
175
+ # Every emitted code is catalogued (tests enforce it); stay valid
176
+ # SARIF even if one slips through.
177
+ rules.append(Rule(code, _level(finding), code, "", ""))
178
+ index[code] = len(rules) - 1
179
+ path = _source_path(finding["path"], prefix)
180
+ location: dict[str, Any] = {
181
+ "physicalLocation": {
182
+ "artifactLocation": {"uri": quote(path, safe="/"), "uriBaseId": SRCROOT},
183
+ },
184
+ }
185
+ if finding["line"]:
186
+ location["physicalLocation"]["region"] = {"startLine": int(finding["line"])}
187
+ result: dict[str, Any] = {
188
+ "ruleId": code,
189
+ "ruleIndex": index[code],
190
+ "level": _level(finding),
191
+ "message": {"text": finding["message"]},
192
+ "locations": [location],
193
+ "partialFingerprints": {FINGERPRINT_KEY: _fingerprint(code, path, finding["message"])},
194
+ }
195
+ if id(finding) in justification:
196
+ result["suppressions"] = [{"kind": "external", "justification": justification[id(finding)]}]
197
+ results.append(result)
198
+ base_uri = base.resolve().as_uri()
199
+ log: dict[str, Any] = {
200
+ "$schema": SARIF_SCHEMA_URI,
201
+ "version": SARIF_VERSION,
202
+ "runs": [{
203
+ "tool": {
204
+ "driver": {
205
+ "name": "WhyKit",
206
+ "semanticVersion": __version__,
207
+ "informationUri": INFORMATION_URI,
208
+ "rules": [_sarif_rule(rule) for rule in rules],
209
+ },
210
+ },
211
+ "originalUriBaseIds": {SRCROOT: {"uri": base_uri if base_uri.endswith("/") else base_uri + "/"}},
212
+ "columnKind": "utf16CodeUnits",
213
+ "results": results,
214
+ "properties": {"contract_version": 1, "files": files, "vault_prefix": prefix},
215
+ }],
216
+ }
217
+ # A security rule lowered by policy, or a secret scan switched off, is
218
+ # part of what the log means: say so as a configuration notification.
219
+ notices = [
220
+ {
221
+ "level": "note",
222
+ "message": {"text": f"whykit.toml policy: {entry.describe()}" if not entry.skipped_by else entry.describe()},
223
+ "descriptor": {"id": entry.rule},
224
+ }
225
+ for entry in overrides
226
+ if entry.security
227
+ ]
228
+ if notices:
229
+ log["runs"][0]["invocations"] = [{"executionSuccessful": True, "toolConfigurationNotifications": notices}]
230
+ return log
231
+
232
+
233
+ def _escape_data(value: str) -> str:
234
+ # The Unicode line and paragraph separators and NEL end a line for some
235
+ # log readers; inside an annotation they become an escaped line feed.
236
+ value = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
237
+ return value.replace("\u2028", "%0A").replace("\u2029", "%0A").replace("\x85", "%0A")
238
+
239
+
240
+ def _escape_property(value: str) -> str:
241
+ return _escape_data(value).replace(":", "%3A").replace(",", "%2C")
242
+
243
+
244
+ def workflow_command(level: str, message: str, *, file: str | None = None, line: int | None = None, title: str | None = None) -> str:
245
+ """One ``::error``/``::warning`` workflow command, escaped per GitHub's rules."""
246
+ props = []
247
+ if file:
248
+ props.append(f"file={_escape_property(file)}")
249
+ if file and line:
250
+ props.append(f"line={int(line)}")
251
+ if title:
252
+ props.append(f"title={_escape_property(title)}")
253
+ head = f"::{level}" + (" " + ",".join(props) if props else "")
254
+ return f"{head}::{_escape_data(message)}"
255
+
256
+
257
+ def github_annotations(findings: Iterable[Mapping[str, Any]], root: Path) -> list[str]:
258
+ """Workflow commands for every finding, errors first."""
259
+ _, prefix = source_root(root)
260
+ ordered = sorted(findings, key=lambda f: (f["level"] != "error", f["path"], f["line"] or 0, f["code"]))
261
+ return [
262
+ workflow_command(
263
+ _level(finding),
264
+ finding["message"] if is_custom(finding["code"]) else f"{finding['message']} ({rule_help_uri(finding['code'])})",
265
+ file=_source_path(finding["path"], prefix),
266
+ line=finding["line"],
267
+ title=f"WhyKit {finding['code']}",
268
+ )
269
+ for finding in ordered
270
+ ]
271
+
272
+
273
+ def override_notices(overrides: Iterable[Any]) -> list[str]:
274
+ """One ``::notice`` per security-relevant override, so CI logs show it."""
275
+ return [
276
+ workflow_command(
277
+ "notice",
278
+ entry.describe() if entry.skipped_by else f"whykit.toml policy: {entry.describe()}",
279
+ title="WhyKit policy",
280
+ )
281
+ for entry in overrides
282
+ if entry.security
283
+ ]