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/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
|
+
]
|