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/backlinks.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Inbound link discovery for documents, decisions and evidence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .io import consistent_read
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .contract import vault_not_found
|
|
12
|
+
from .graph import build_graph
|
|
13
|
+
from .lint import (
|
|
14
|
+
_within,
|
|
15
|
+
path_is_file,
|
|
16
|
+
path_cache,
|
|
17
|
+
strip_markdown_suffix,
|
|
18
|
+
DECISION_ID_RE,
|
|
19
|
+
EVIDENCE_ID_RE,
|
|
20
|
+
find_vault_root,
|
|
21
|
+
is_vault_root,
|
|
22
|
+
)
|
|
23
|
+
from .vault_index import VaultIndex
|
|
24
|
+
from .console import emit_machine
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_target(root: Path, target: str, vault: VaultIndex) -> tuple[str, str]:
|
|
28
|
+
"""Return (kind, graph_id) for a CLI target.
|
|
29
|
+
|
|
30
|
+
Documents resolve exactly as wikilinks do, so a bare stem or an alias finds
|
|
31
|
+
the note wherever it lives instead of being looked up as a root-level path.
|
|
32
|
+
"""
|
|
33
|
+
raw = target.strip()
|
|
34
|
+
if EVIDENCE_ID_RE.fullmatch(raw):
|
|
35
|
+
return "evidence", f"evidence:{raw}"
|
|
36
|
+
if DECISION_ID_RE.fullmatch(raw):
|
|
37
|
+
for note in vault.notes:
|
|
38
|
+
if str(note.front.get("decision_id") or "").strip() == raw:
|
|
39
|
+
return "decision", strip_markdown_suffix(vault.relative(note.path))
|
|
40
|
+
return "decision", raw
|
|
41
|
+
try:
|
|
42
|
+
path = Path(raw).expanduser()
|
|
43
|
+
except RuntimeError: # `~name` for a user that does not exist
|
|
44
|
+
path = Path(raw)
|
|
45
|
+
if path.is_absolute():
|
|
46
|
+
# Containment before any file system call: another drive or a UNC share
|
|
47
|
+
# is answered from the strings alone.
|
|
48
|
+
if path.suffix.lower() == ".md" and _within(root, path) and path_is_file(path) and vault.note_for(path) is not None:
|
|
49
|
+
return "document", strip_markdown_suffix(vault.relative(path))
|
|
50
|
+
return "document", strip_markdown_suffix(raw)
|
|
51
|
+
resolved, ambiguous = vault.resolve_link(raw)
|
|
52
|
+
if resolved is not None and not ambiguous:
|
|
53
|
+
return "document", strip_markdown_suffix(vault.relative(resolved))
|
|
54
|
+
return "document", strip_markdown_suffix(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@consistent_read
|
|
58
|
+
@path_cache()
|
|
59
|
+
def build_backlinks(root: Path, target: str, *, vault: VaultIndex | None = None) -> dict:
|
|
60
|
+
root = root.resolve()
|
|
61
|
+
vault = vault or VaultIndex.load(root)
|
|
62
|
+
kind, node_id = _normalize_target(root, target, vault)
|
|
63
|
+
graph = build_graph(root, vault=vault)
|
|
64
|
+
known = {node["id"] for node in graph["nodes"]}
|
|
65
|
+
exists = node_id in known
|
|
66
|
+
inbound = [
|
|
67
|
+
{
|
|
68
|
+
"from": edge["from"],
|
|
69
|
+
"type": edge["type"],
|
|
70
|
+
"to": edge["to"],
|
|
71
|
+
}
|
|
72
|
+
for edge in graph["edges"]
|
|
73
|
+
if edge["to"] == node_id
|
|
74
|
+
]
|
|
75
|
+
inbound.sort(key=lambda item: (item["type"], item["from"]))
|
|
76
|
+
return {
|
|
77
|
+
"contract_version": 1,
|
|
78
|
+
"target": target,
|
|
79
|
+
"id": node_id,
|
|
80
|
+
"kind": kind,
|
|
81
|
+
"exists": exists,
|
|
82
|
+
"backlinks": inbound,
|
|
83
|
+
"count": len(inbound),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
parser = argparse.ArgumentParser(prog="whykit backlinks", description="List inbound links to a note, decision or evidence ID.")
|
|
89
|
+
parser.add_argument("target", help="path, stem, D-NNN or E-NNN")
|
|
90
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
91
|
+
parser.add_argument("--json", action="store_true")
|
|
92
|
+
args = parser.parse_args(argv)
|
|
93
|
+
root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
|
|
94
|
+
if root is None or not is_vault_root(root):
|
|
95
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
96
|
+
report = build_backlinks(root, args.target)
|
|
97
|
+
if args.json:
|
|
98
|
+
emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
|
|
99
|
+
else:
|
|
100
|
+
if not report["exists"]:
|
|
101
|
+
print(f"target not found in graph: {report['id']}", file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
print(f"backlinks for {report['id']} ({report['count']})")
|
|
104
|
+
for item in report["backlinks"]:
|
|
105
|
+
print(f" {item['type']:<10} {item['from']}")
|
|
106
|
+
return 0 if report["exists"] else 1
|
whykit/check.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Policy-profile quality gates for local work, CI and releases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .io import consistent_read
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .contract import emit_error, vault_not_found
|
|
15
|
+
from .config import CONFIG_FILE, ConfigError, configuration_readiness, get_profile, load_config, parse_config
|
|
16
|
+
from .immutability import _git_prefix, _require_revisions, changed_records, git
|
|
17
|
+
from .lint import _parse_date, find_vault_root, is_vault_root, lint, rel, path_cache
|
|
18
|
+
from .console import emit_machine, one_line
|
|
19
|
+
from .rule_policy import Override, describe_override, secret_scan_skipped
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _git_repo(root: Path) -> bool:
|
|
23
|
+
if not shutil.which("git"):
|
|
24
|
+
return False
|
|
25
|
+
result = subprocess.run(["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
26
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _git_clean(root: Path) -> bool:
|
|
30
|
+
result = subprocess.run(["git", "-C", str(root), "status", "--porcelain"], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
31
|
+
return result.returncode == 0 and not result.stdout.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_profile(
|
|
35
|
+
root: Path,
|
|
36
|
+
config: dict,
|
|
37
|
+
config_path: Path | None,
|
|
38
|
+
*,
|
|
39
|
+
profile_name: str,
|
|
40
|
+
base: str | None = None,
|
|
41
|
+
head: str = "HEAD",
|
|
42
|
+
today: dt.date | None = None,
|
|
43
|
+
) -> dict:
|
|
44
|
+
profile = get_profile(config, profile_name)
|
|
45
|
+
applied: list[Override] = []
|
|
46
|
+
files, findings = lint(
|
|
47
|
+
root,
|
|
48
|
+
orphans=bool(profile["orphans"]),
|
|
49
|
+
secrets=bool(profile["secrets"]),
|
|
50
|
+
hub_links=bool(profile.get("require_hub_links", False)),
|
|
51
|
+
today=today,
|
|
52
|
+
overrides=applied,
|
|
53
|
+
config=config,
|
|
54
|
+
)
|
|
55
|
+
if not profile["secrets"]:
|
|
56
|
+
applied.append(secret_scan_skipped(f"profile {profile_name} (secrets = false)"))
|
|
57
|
+
errors = [item for item in findings if item.level == "error"]
|
|
58
|
+
warnings = [item for item in findings if item.level == "warning"]
|
|
59
|
+
checks: list[dict] = []
|
|
60
|
+
|
|
61
|
+
lint_passed = not errors and not (profile["strict"] and warnings)
|
|
62
|
+
checks.append({
|
|
63
|
+
"name": "lint",
|
|
64
|
+
"passed": lint_passed,
|
|
65
|
+
"detail": f"{len(files)} files, {len(errors)} errors, {len(warnings)} warnings",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
git_repo = _git_repo(root)
|
|
69
|
+
if profile["require_git"]:
|
|
70
|
+
checks.append({"name": "git_repository", "passed": git_repo, "detail": "git work tree required"})
|
|
71
|
+
else:
|
|
72
|
+
checks.append({"name": "git_repository", "passed": True, "detail": "not required" if not git_repo else "detected"})
|
|
73
|
+
|
|
74
|
+
if profile["require_clean_tree"]:
|
|
75
|
+
clean = git_repo and _git_clean(root)
|
|
76
|
+
checks.append({"name": "clean_tree", "passed": clean, "detail": "working tree must be clean"})
|
|
77
|
+
else:
|
|
78
|
+
checks.append({"name": "clean_tree", "passed": True, "detail": "not required"})
|
|
79
|
+
|
|
80
|
+
if profile["require_configured"]:
|
|
81
|
+
ready, reasons = configuration_readiness(config, config_path)
|
|
82
|
+
checks.append({
|
|
83
|
+
"name": "policy_configuration",
|
|
84
|
+
"passed": ready,
|
|
85
|
+
"detail": "repository policy is configured" if ready else "; ".join(reasons),
|
|
86
|
+
})
|
|
87
|
+
else:
|
|
88
|
+
checks.append({"name": "policy_configuration", "passed": True, "detail": "not required"})
|
|
89
|
+
|
|
90
|
+
history_checked = False
|
|
91
|
+
history_mode = profile["history"]
|
|
92
|
+
if history_mode == "off":
|
|
93
|
+
checks.append({"name": "history", "passed": True, "detail": "disabled by profile"})
|
|
94
|
+
elif base:
|
|
95
|
+
if not git_repo:
|
|
96
|
+
checks.append({"name": "history", "passed": False, "detail": "--base supplied but vault is not in a git work tree"})
|
|
97
|
+
else:
|
|
98
|
+
try:
|
|
99
|
+
history_reasons: dict[str, str] = {}
|
|
100
|
+
blocked = changed_records(base, head, str(root), reasons=history_reasons)
|
|
101
|
+
except subprocess.CalledProcessError as exc:
|
|
102
|
+
checks.append({"name": "history", "passed": False, "detail": (exc.stderr or str(exc)).strip()})
|
|
103
|
+
else:
|
|
104
|
+
history_checked = True
|
|
105
|
+
checks.append({
|
|
106
|
+
"name": "history",
|
|
107
|
+
"passed": not blocked,
|
|
108
|
+
"detail": "immutable reasoning unchanged" if not blocked else f"{len(blocked)} immutable decision change(s)",
|
|
109
|
+
"blocked": [{"status": status, "path": path, **({"reason": history_reasons[path]} if path in history_reasons else {})} for status, path in blocked],
|
|
110
|
+
})
|
|
111
|
+
elif history_mode == "required":
|
|
112
|
+
checks.append({"name": "history", "passed": False, "detail": "profile requires --base <git-ref>"})
|
|
113
|
+
else:
|
|
114
|
+
checks.append({"name": "history", "passed": True, "detail": "optional; pass --base to enforce"})
|
|
115
|
+
|
|
116
|
+
checks[-1]["checked"] = history_checked
|
|
117
|
+
passed = all(item["passed"] for item in checks)
|
|
118
|
+
lint_report: dict = {
|
|
119
|
+
"files": len(files),
|
|
120
|
+
"errors": len(errors),
|
|
121
|
+
"warnings": len(warnings),
|
|
122
|
+
"findings": [asdict(item) for item in findings],
|
|
123
|
+
}
|
|
124
|
+
if applied:
|
|
125
|
+
lint_report["overrides"] = [entry.report() for entry in applied]
|
|
126
|
+
return {
|
|
127
|
+
"contract_version": 1,
|
|
128
|
+
"profile": profile_name,
|
|
129
|
+
"profile_config": profile,
|
|
130
|
+
"config_source": rel(root, config_path) if config_path else "built-in defaults",
|
|
131
|
+
"as_of": (today or dt.date.today()).isoformat(),
|
|
132
|
+
"passed": passed,
|
|
133
|
+
"history_checked": history_checked,
|
|
134
|
+
"checks": checks,
|
|
135
|
+
"lint": lint_report,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@consistent_read
|
|
140
|
+
@path_cache()
|
|
141
|
+
def run_check(
|
|
142
|
+
root: Path,
|
|
143
|
+
*,
|
|
144
|
+
profile_name: str,
|
|
145
|
+
base: str | None = None,
|
|
146
|
+
head: str = "HEAD",
|
|
147
|
+
today: dt.date | None = None,
|
|
148
|
+
) -> dict:
|
|
149
|
+
"""Check the working vault against both head and protected base policies."""
|
|
150
|
+
config, path = load_config(root)
|
|
151
|
+
report = _run_profile(root, config, path, profile_name=profile_name, base=base, head=head, today=today)
|
|
152
|
+
if base is None:
|
|
153
|
+
return report
|
|
154
|
+
try:
|
|
155
|
+
_require_revisions(str(root), base)
|
|
156
|
+
policy_path = _git_prefix(str(root)) + CONFIG_FILE
|
|
157
|
+
entry = git("ls-tree", "-z", "--full-tree", base, "--", policy_path, root=str(root))
|
|
158
|
+
if entry:
|
|
159
|
+
if entry.split(" ", 1)[0] not in {"100644", "100755"}:
|
|
160
|
+
raise ConfigError("baseline policy must be a regular file")
|
|
161
|
+
baseline_config = parse_config(git("show", f"{base}:{policy_path}", root=str(root)))
|
|
162
|
+
else:
|
|
163
|
+
baseline_config = parse_config("")
|
|
164
|
+
baseline = _run_profile(
|
|
165
|
+
root, baseline_config, root / CONFIG_FILE if entry else None,
|
|
166
|
+
profile_name=profile_name, base=base, head=head, today=today,
|
|
167
|
+
)
|
|
168
|
+
baseline["config_source"] = f"{base}:{policy_path}" if entry else f"{base}:built-in defaults"
|
|
169
|
+
except (ConfigError, subprocess.CalledProcessError) as exc:
|
|
170
|
+
report["checks"].append({
|
|
171
|
+
"name": "baseline_policy", "passed": False,
|
|
172
|
+
"detail": f"cannot validate policy at {base}: " + (getattr(exc, "stderr", None) or str(exc)).strip(),
|
|
173
|
+
})
|
|
174
|
+
else:
|
|
175
|
+
report["baseline"] = {"ref": base, **baseline}
|
|
176
|
+
report["history_checked"] = report["history_checked"] or baseline["history_checked"]
|
|
177
|
+
failed = "; ".join(f"{check['name']}: {check['detail']}" for check in baseline["checks"] if not check["passed"])
|
|
178
|
+
report["checks"].append({
|
|
179
|
+
"name": "baseline_policy", "passed": baseline["passed"],
|
|
180
|
+
"detail": f"policy at {base}: " + (failed or "all checks passed"),
|
|
181
|
+
})
|
|
182
|
+
report["passed"] = all(check["passed"] for check in report["checks"])
|
|
183
|
+
return report
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
parser = argparse.ArgumentParser(prog="whykit check", description="Run a versioned local/CI/release WhyKit policy profile.")
|
|
188
|
+
parser.add_argument("--root", help="vault root (default: nearest vault)")
|
|
189
|
+
parser.add_argument("--profile", default="ci", help="policy profile from whykit.toml (default: ci)")
|
|
190
|
+
parser.add_argument("--base", help="base git ref for immutable-history verification")
|
|
191
|
+
parser.add_argument("--head", default="HEAD")
|
|
192
|
+
parser.add_argument("--today", help="evaluate review dates as of this ISO date")
|
|
193
|
+
parser.add_argument("--json", action="store_true")
|
|
194
|
+
parser.add_argument("--format", choices=("text", "json", "github"), default=None, help="output format (default: text)")
|
|
195
|
+
args = parser.parse_args(argv)
|
|
196
|
+
if args.json and args.format not in (None, "json"):
|
|
197
|
+
return emit_error(
|
|
198
|
+
"usage",
|
|
199
|
+
f"--json conflicts with --format {args.format}\nhint: pass one of them to `whykit check`",
|
|
200
|
+
json_mode=True,
|
|
201
|
+
)
|
|
202
|
+
fmt = "json" if args.json else (args.format or "text")
|
|
203
|
+
args.json = fmt == "json"
|
|
204
|
+
root = Path(args.root).expanduser().resolve() if args.root else find_vault_root()
|
|
205
|
+
if root is None or not is_vault_root(root):
|
|
206
|
+
return vault_not_found(args.root, json_mode=args.json)
|
|
207
|
+
today = None
|
|
208
|
+
if args.today:
|
|
209
|
+
# Same YYYY-MM-DD contract as `whykit lint --today`; fromisoformat alone
|
|
210
|
+
# would also accept forms such as 20260917 or 2026-W38-4.
|
|
211
|
+
today = _parse_date(args.today)
|
|
212
|
+
if today is None:
|
|
213
|
+
return emit_error("invalid_argument", f"--today is not a real ISO date: {args.today}", json_mode=args.json)
|
|
214
|
+
try:
|
|
215
|
+
report = run_check(root, profile_name=args.profile, base=args.base, head=args.head, today=today)
|
|
216
|
+
except ConfigError as exc:
|
|
217
|
+
return emit_error("invalid_config", str(exc), json_mode=args.json)
|
|
218
|
+
if args.json:
|
|
219
|
+
emit_machine(json.dumps(report, ensure_ascii=False, indent=2))
|
|
220
|
+
else:
|
|
221
|
+
if fmt == "github":
|
|
222
|
+
annotations = gate_annotations(report, root)
|
|
223
|
+
if annotations:
|
|
224
|
+
emit_machine("\n".join(annotations))
|
|
225
|
+
state = "PASS" if report["passed"] else "FAIL"
|
|
226
|
+
print(f"WhyKit {report['profile']} gate — {state}")
|
|
227
|
+
print(f" config {one_line(report['config_source'])}")
|
|
228
|
+
width = max(len(check["name"]) for check in report["checks"])
|
|
229
|
+
for check in report["checks"]:
|
|
230
|
+
marker = "SKIP" if check.get("checked") is False and check["passed"] else ("OK" if check["passed"] else "FAIL")
|
|
231
|
+
print(f" {marker:<4} {check['name']:<{width}} {one_line(check['detail'])}")
|
|
232
|
+
for item in check.get("blocked", []):
|
|
233
|
+
print(f" {item['status']} {one_line(item['path'])}")
|
|
234
|
+
if report["lint"]["errors"] or report["lint"]["warnings"]:
|
|
235
|
+
print(f" lint findings {report['lint']['errors']} error(s), {report['lint']['warnings']} warning(s)")
|
|
236
|
+
_print_gate_findings(report)
|
|
237
|
+
if report.get("baseline", {}).get("lint", {}).get("findings"):
|
|
238
|
+
print(" findings under the baseline policy:")
|
|
239
|
+
_print_gate_findings(report["baseline"])
|
|
240
|
+
for entry in report["lint"].get("overrides", []):
|
|
241
|
+
if entry["security"]:
|
|
242
|
+
print(f" policy {one_line(describe_override(entry))}")
|
|
243
|
+
return 0 if report["passed"] else 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
MAX_GATE_FINDINGS = 20
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def gate_annotations(report: dict, root: Path) -> list[str]:
|
|
250
|
+
"""GitHub workflow commands for a gate report.
|
|
251
|
+
|
|
252
|
+
Every lint finding is annotated at its level, so a reviewer sees warnings
|
|
253
|
+
inline even when the profile does not fail on them. Rewritten decision
|
|
254
|
+
history is an error on the record; any other failed check is an error
|
|
255
|
+
without a file.
|
|
256
|
+
"""
|
|
257
|
+
from .ci_formats import github_annotations, source_root, workflow_command
|
|
258
|
+
|
|
259
|
+
lines = github_annotations(report["lint"]["findings"], root)
|
|
260
|
+
for entry in report["lint"].get("overrides", []):
|
|
261
|
+
if entry["security"]:
|
|
262
|
+
text = describe_override(entry)
|
|
263
|
+
lines.append(workflow_command(
|
|
264
|
+
"notice", text if entry.get("skipped_by") else f"whykit.toml policy: {text}", title="WhyKit policy",
|
|
265
|
+
))
|
|
266
|
+
_, prefix = source_root(root)
|
|
267
|
+
for check in report["checks"]:
|
|
268
|
+
if check["passed"] or check["name"] == "lint":
|
|
269
|
+
continue
|
|
270
|
+
blocked = check.get("blocked") or []
|
|
271
|
+
for item in blocked:
|
|
272
|
+
path = item["path"].split(" -> ")[0]
|
|
273
|
+
reason = item.get("reason")
|
|
274
|
+
message = (
|
|
275
|
+
f"{reason}: include a newly appended matching review event with this record."
|
|
276
|
+
if reason else
|
|
277
|
+
f"Accepted decision reasoning is append-only ({item['status']}); supersede it with a new record instead of rewriting it."
|
|
278
|
+
)
|
|
279
|
+
lines.append(workflow_command(
|
|
280
|
+
"error",
|
|
281
|
+
message,
|
|
282
|
+
file=f"{prefix}{path}",
|
|
283
|
+
title="WhyKit history",
|
|
284
|
+
))
|
|
285
|
+
if not blocked:
|
|
286
|
+
lines.append(workflow_command("error", f"{check['name']}: {check['detail']}", title=f"WhyKit {report['profile']} gate"))
|
|
287
|
+
lint_check = next((c for c in report["checks"] if c["name"] == "lint"), None)
|
|
288
|
+
if lint_check is not None and not lint_check["passed"] and not report["lint"]["errors"]:
|
|
289
|
+
lines.append(workflow_command(
|
|
290
|
+
"error",
|
|
291
|
+
f"profile {report['profile']} is strict: {report['lint']['warnings']} warning(s) fail this gate",
|
|
292
|
+
title=f"WhyKit {report['profile']} gate",
|
|
293
|
+
))
|
|
294
|
+
return lines
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _print_gate_findings(report: dict) -> None:
|
|
298
|
+
"""Show the findings that failed the lint check.
|
|
299
|
+
|
|
300
|
+
A CI log is often all a reviewer sees; a bare "5 warnings" sends them off
|
|
301
|
+
to rerun the linter locally just to learn what broke the gate.
|
|
302
|
+
"""
|
|
303
|
+
lint_check = next((c for c in report["checks"] if c["name"] == "lint"), None)
|
|
304
|
+
if lint_check is None or lint_check["passed"]:
|
|
305
|
+
return
|
|
306
|
+
strict = bool(report["profile_config"].get("strict"))
|
|
307
|
+
failing = [
|
|
308
|
+
item for item in report["lint"]["findings"]
|
|
309
|
+
if item["level"] == "error" or (strict and item["level"] == "warning")
|
|
310
|
+
]
|
|
311
|
+
failing.sort(key=lambda item: (item["level"] != "error", item["path"], item["line"] or 0))
|
|
312
|
+
print("")
|
|
313
|
+
print(" findings that fail this gate:")
|
|
314
|
+
for item in failing[:MAX_GATE_FINDINGS]:
|
|
315
|
+
location = f"{item['path']}:{item['line']}" if item["line"] else item["path"]
|
|
316
|
+
print(f" {item['level']:<7} {one_line(location)} [{item['code']}] {one_line(item['message'])}")
|
|
317
|
+
if len(failing) > MAX_GATE_FINDINGS:
|
|
318
|
+
print(f" ... {len(failing) - MAX_GATE_FINDINGS} more; run `whykit lint` for the full list")
|
|
319
|
+
if strict and any(item["level"] == "warning" for item in failing):
|
|
320
|
+
print(" this profile is strict: warnings fail it (see `whykit policy`)")
|