workflow-toolkit 1.0.1
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.
- package/.agents/skills/ponytail/SKILL.md +111 -0
- package/.agents/skills/ponytail-audit/SKILL.md +37 -0
- package/.agents/skills/ponytail-debt/SKILL.md +41 -0
- package/.agents/skills/ponytail-gain/SKILL.md +47 -0
- package/.agents/skills/ponytail-help/SKILL.md +70 -0
- package/.agents/skills/ponytail-review/SKILL.md +52 -0
- package/.agents/skills/prompt-review/SKILL.md +60 -0
- package/.agents/skills/wtk/SKILL.md +64 -0
- package/.agents/skills/wtk/references/artifacts.md +82 -0
- package/.agents/skills/wtk/references/evidence.md +68 -0
- package/.agents/skills/wtk/references/git.md +75 -0
- package/.agents/skills/wtk/references/test-contract.md +76 -0
- package/.agents/skills/wtk/references/validation.md +127 -0
- package/.agents/skills/wtk-config/SKILL.md +102 -0
- package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
- package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
- package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
- package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
- package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
- package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
- package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
- package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
- package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
- package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
- package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
- package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
- package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
- package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
- package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
- package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
- package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
- package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
- package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
- package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
- package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
- package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
- package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
- package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
- package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
- package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
- package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
- package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
- package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
- package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
- package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
- package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
- package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
- package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
- package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
- package/.agents/skills/wtk-discover/SKILL.md +270 -0
- package/.agents/skills/wtk-discover/references/document-format.md +149 -0
- package/.agents/skills/wtk-implement/SKILL.md +95 -0
- package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
- package/.agents/skills/wtk-implement/references/screens.md +129 -0
- package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
- package/.agents/skills/wtk-implement/references/verify.md +297 -0
- package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
- package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
- package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
- package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
- package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
- package/.agents/skills/wtk-lean/NOTICE.md +18 -0
- package/.agents/skills/wtk-lean/SKILL.md +137 -0
- package/.agents/skills/wtk-lean/references/build.md +168 -0
- package/.agents/skills/wtk-lean/references/checks.md +253 -0
- package/.agents/skills/wtk-lean/references/memory.md +156 -0
- package/.agents/skills/wtk-lean/references/plan.md +514 -0
- package/.agents/skills/wtk-lean/references/verify.md +336 -0
- package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
- package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
- package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
- package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
- package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
- package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
- package/.agents/skills/wtk-plan/SKILL.md +102 -0
- package/.agents/skills/wtk-plan/references/document-format.md +222 -0
- package/.agents/skills/wtk-qa/SKILL.md +14 -0
- package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
- package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
- package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
- package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
- package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
- package/.agents/skills/wtk-ship/SKILL.md +49 -0
- package/.agents/skills/wtk-ship/remediation.py +170 -0
- package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
- package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
- package/.wtk.toml.example +93 -0
- package/AGENTS.md +129 -0
- package/NOTICE.md +12 -0
- package/README.md +440 -0
- package/bin/wtk.js +25 -0
- package/docs/toolkit/README.md +56 -0
- package/docs/toolkit/decisions.md +61 -0
- package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
- package/docs/toolkit/guidelines/DX.md +67 -0
- package/docs/toolkit/guidelines/FRONTEND.md +73 -0
- package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
- package/docs/toolkit/guidelines/MODELING.md +134 -0
- package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
- package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
- package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
- package/docs/toolkit/guidelines/SECURITY.md +160 -0
- package/docs/toolkit/guidelines/UI-UX.md +117 -0
- package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
- package/docs/toolkit/guidelines.md +50 -0
- package/docs/toolkit/loop.md +77 -0
- package/docs/toolkit/purpose.md +51 -0
- package/docs/toolkit/repository-intelligence.md +53 -0
- package/docs/toolkit/reviews.md +77 -0
- package/knowledge/AGENTS.md +316 -0
- package/knowledge/raw/README.md +17 -0
- package/package.json +74 -0
- package/scripts/install_security_skills.py +816 -0
- package/scripts/installer/engine.js +254 -0
- package/scripts/installer/knowledge.js +28 -0
- package/scripts/installer/packets.js +77 -0
- package/scripts/installer/terminal.js +98 -0
- package/scripts/installer/transaction.js +75 -0
- package/skills-lock.json +92 -0
- package/templates/adoption/agents/core.md +23 -0
- package/templates/adoption/agents/quality.md +3 -0
- package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
- package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/design/index.md +3 -0
- package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
- package/templates/adoption/knowledge/wiki/index.md +3 -0
- package/templates/adoption/knowledge/wiki/log.md +3 -0
- package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/product/index.md +3 -0
- package/templates/adoption/knowledge/wiki/research/index.md +3 -0
- package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Conditional Graphify context preparation for Deep Review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
RI_SCRIPTS = Path(__file__).resolve().parents[2] / "wtk-config" / "scripts"
|
|
11
|
+
if str(RI_SCRIPTS) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(RI_SCRIPTS))
|
|
13
|
+
|
|
14
|
+
import repository_intelligence as ri # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def question_hash(question: str) -> str:
|
|
18
|
+
return hashlib.sha256(question.encode("utf-8")).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fallback(path: Path, digest: str, reason: str) -> dict[str, str]:
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
path.write_text(
|
|
24
|
+
"\n".join([
|
|
25
|
+
"# Graphify context",
|
|
26
|
+
"",
|
|
27
|
+
"status: degraded",
|
|
28
|
+
f"question_hash: {digest}",
|
|
29
|
+
f"reason: {reason}",
|
|
30
|
+
"",
|
|
31
|
+
"Graphify context is unavailable; use targeted repository inspection.",
|
|
32
|
+
]) + "\n",
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
)
|
|
35
|
+
return {"status": "degraded", "path": str(path), "question_hash": digest, "reason": reason}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _result_error(error: Exception) -> str:
|
|
39
|
+
if isinstance(error, ri.IntelligenceError):
|
|
40
|
+
return ri._redact(error.reason)
|
|
41
|
+
return "Graphify query failed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def prepare_graphify_context(repo: Path, out: Path, question: str) -> dict[str, str]:
|
|
45
|
+
"""Run one bounded Graphify question and persist only its content-safe hash."""
|
|
46
|
+
path = out / "graphify-context.md"
|
|
47
|
+
digest = question_hash(question)
|
|
48
|
+
try:
|
|
49
|
+
result: Any = ri._run_context(repo, "graphify", "query", [question])
|
|
50
|
+
except Exception as error: # adapter converts expected tool failures to IntelligenceError
|
|
51
|
+
return _fallback(path, digest, _result_error(error))
|
|
52
|
+
if isinstance(result, dict) and result.get("status") == "degraded":
|
|
53
|
+
return _fallback(path, digest, ri._redact(str(result.get("reason") or "Graphify query failed")))
|
|
54
|
+
context = str(result.get("context", "")).strip() if isinstance(result, dict) else ""
|
|
55
|
+
if not context:
|
|
56
|
+
return _fallback(path, digest, "Graphify returned insufficient context")
|
|
57
|
+
status = str(result.get("status", "ready")) if isinstance(result, dict) else "ready"
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
path.write_text(
|
|
60
|
+
"\n".join([
|
|
61
|
+
"# Graphify context",
|
|
62
|
+
"",
|
|
63
|
+
f"status: {status}",
|
|
64
|
+
f"question_hash: {digest}",
|
|
65
|
+
"",
|
|
66
|
+
"Use this bounded architecture context as orientation; verify every claim against the checkout.",
|
|
67
|
+
"",
|
|
68
|
+
"## Architecture context",
|
|
69
|
+
"```text",
|
|
70
|
+
context[:12000],
|
|
71
|
+
"```",
|
|
72
|
+
]) + "\n",
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
)
|
|
75
|
+
return {"status": status, "path": str(path), "question_hash": digest}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deep-review finding merger (bootstrap helper; writes only under --out).
|
|
3
|
+
|
|
4
|
+
Mechanically folds every reviewer/sweep output into the canonical ledger —
|
|
5
|
+
no agents involved. Collects defects, advisories, suppressions, and coverage
|
|
6
|
+
from jobs.json outputs (validating each against the schema), merges duplicates
|
|
7
|
+
by identical fingerprint only, and reconciles against any prior state.json ledger: new /
|
|
8
|
+
duplicate (still open from a prior round) / suppressed (dismissed before;
|
|
9
|
+
never re-raised) — plus the resolved sweep for prior findings the fix
|
|
10
|
+
removed. Emits findings.json, the single input render_review.py consumes.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 ok, 1 missing/invalid outputs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from collections import Counter, defaultdict
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
|
|
25
|
+
|
|
26
|
+
from _common import (
|
|
27
|
+
SEVERITY_RANK,
|
|
28
|
+
fingerprint,
|
|
29
|
+
hunk_text,
|
|
30
|
+
load_jobs,
|
|
31
|
+
read_json,
|
|
32
|
+
repo_root,
|
|
33
|
+
validate_job_output,
|
|
34
|
+
write_json,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UnionFind:
|
|
39
|
+
def __init__(self, values: list[str]) -> None:
|
|
40
|
+
self.parent = {value: value for value in values}
|
|
41
|
+
|
|
42
|
+
def find(self, value: str) -> str:
|
|
43
|
+
parent = self.parent[value]
|
|
44
|
+
if parent != value:
|
|
45
|
+
self.parent[value] = self.find(parent)
|
|
46
|
+
return self.parent[value]
|
|
47
|
+
|
|
48
|
+
def union(self, left: str, right: str) -> None:
|
|
49
|
+
left_root, right_root = self.find(left), self.find(right)
|
|
50
|
+
if left_root != right_root:
|
|
51
|
+
self.parent[right_root] = left_root
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def unique(values: list) -> list:
|
|
55
|
+
seen, result = set(), []
|
|
56
|
+
for value in values:
|
|
57
|
+
if value not in seen:
|
|
58
|
+
seen.add(value)
|
|
59
|
+
result.append(value)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def anchor(finding: dict) -> str:
|
|
64
|
+
end_line = finding.get("end_line") or finding["line"]
|
|
65
|
+
suffix = str(finding["line"]) if end_line == finding["line"] else f"{finding['line']}-{end_line}"
|
|
66
|
+
return f"{finding['file']}:{suffix}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def collect(repo: Path, out: Path) -> dict[str, list[dict]]:
|
|
70
|
+
results: dict[str, list[dict]] = {
|
|
71
|
+
"defects": [], "advisories": [], "suppressions": [],
|
|
72
|
+
"hunk_coverage": [], "rule_coverage": [], "dispositions": [],
|
|
73
|
+
}
|
|
74
|
+
for job in load_jobs(out / "jobs.json"):
|
|
75
|
+
try:
|
|
76
|
+
validate_job_output(repo, out, job)
|
|
77
|
+
except ValueError as error:
|
|
78
|
+
raise RuntimeError(f"reviewer output invalid — {error}") from error
|
|
79
|
+
payload = read_json(repo / job["output"])
|
|
80
|
+
for key, result_kind, prefix in (
|
|
81
|
+
("defects", "defect", "RD"), ("advisories", "advisory", "RA")
|
|
82
|
+
):
|
|
83
|
+
for item in payload[key]:
|
|
84
|
+
decorated = {"result_kind": result_kind, **item}
|
|
85
|
+
results[key].append({
|
|
86
|
+
"raw_id": f"{prefix}{len(results[key]) + 1:04d}",
|
|
87
|
+
"source_job": job["label"],
|
|
88
|
+
"fingerprint": fingerprint(decorated),
|
|
89
|
+
**decorated,
|
|
90
|
+
})
|
|
91
|
+
for item in payload.get("suppressions", []):
|
|
92
|
+
results["suppressions"].append({
|
|
93
|
+
"source_job": job["label"], "lane": job["lane"], **item,
|
|
94
|
+
})
|
|
95
|
+
for item in payload["coverage"]["hunks"]:
|
|
96
|
+
results["hunk_coverage"].append({
|
|
97
|
+
"source_job": job["label"], "lane": job["lane"], **item,
|
|
98
|
+
})
|
|
99
|
+
for item in payload["coverage"].get("rules", []):
|
|
100
|
+
results["rule_coverage"].append({
|
|
101
|
+
"source_job": job["label"], "lane": job["lane"], **item,
|
|
102
|
+
})
|
|
103
|
+
for item in payload.get("prior_findings", []):
|
|
104
|
+
results["dispositions"].append({"source_job": job["label"], **item})
|
|
105
|
+
return results
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def group_duplicates(findings: list[dict]) -> list[list[dict]]:
|
|
109
|
+
"""Union identical fingerprints only; distinct defects never merge."""
|
|
110
|
+
uf = UnionFind([finding["raw_id"] for finding in findings])
|
|
111
|
+
by_fingerprint: dict[str, list[dict]] = {}
|
|
112
|
+
for finding in findings:
|
|
113
|
+
by_fingerprint.setdefault(finding["fingerprint"], []).append(finding)
|
|
114
|
+
for members in by_fingerprint.values():
|
|
115
|
+
for member in members[1:]:
|
|
116
|
+
uf.union(members[0]["raw_id"], member["raw_id"])
|
|
117
|
+
grouped: dict[str, list[dict]] = {}
|
|
118
|
+
for finding in findings:
|
|
119
|
+
grouped.setdefault(uf.find(finding["raw_id"]), []).append(finding)
|
|
120
|
+
return list(grouped.values())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def merge_group(members: list[dict]) -> dict:
|
|
124
|
+
ordered = sorted(
|
|
125
|
+
members, key=lambda f: (-SEVERITY_RANK[f["severity"]], f["line"], f["raw_id"])
|
|
126
|
+
)
|
|
127
|
+
canonical = ordered[0]
|
|
128
|
+
return {
|
|
129
|
+
**canonical,
|
|
130
|
+
"raw_ids": [m["raw_id"] for m in ordered],
|
|
131
|
+
"source_jobs": unique([m["source_job"] for m in ordered]),
|
|
132
|
+
"rule_ids": unique([rule_id for m in ordered for rule_id in (m.get("rule_ids") or [])]),
|
|
133
|
+
"also_applies": unique([
|
|
134
|
+
*(canonical.get("also_applies") or []),
|
|
135
|
+
*[anchor(m) for m in ordered[1:]],
|
|
136
|
+
*[target for m in ordered for target in (m.get("also_applies") or [])],
|
|
137
|
+
]),
|
|
138
|
+
"evidence": unique([e for m in ordered for e in m.get("evidence", [])]),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def raw_ledger_entries(members: list[dict], merged: dict) -> list[dict]:
|
|
143
|
+
entries = []
|
|
144
|
+
for member in members:
|
|
145
|
+
entry = {
|
|
146
|
+
"raw_id": member["raw_id"], "source_job": member["source_job"],
|
|
147
|
+
"fingerprint": member["fingerprint"], "file": member["file"],
|
|
148
|
+
"line": member["line"], "severity": member["severity"],
|
|
149
|
+
"title": member["title"], "result_kind": member["result_kind"],
|
|
150
|
+
}
|
|
151
|
+
if member["raw_id"] != merged["raw_ids"][0]:
|
|
152
|
+
entry["status"] = "merged"
|
|
153
|
+
entry["duplicate_of"] = merged["fingerprint"]
|
|
154
|
+
else:
|
|
155
|
+
entry["status"] = "canonical" if len(members) > 1 else "unique"
|
|
156
|
+
entries.append(entry)
|
|
157
|
+
return entries
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def reconcile(canonical: list[dict], found_fps: set[str], prior_state: dict | None,
|
|
161
|
+
dispositions: list[dict]) -> dict:
|
|
162
|
+
ledger = (prior_state or {}).get("ledger", {})
|
|
163
|
+
for finding in canonical:
|
|
164
|
+
row = ledger.get(finding["fingerprint"])
|
|
165
|
+
if row is None or row.get("status") == "resolved":
|
|
166
|
+
finding["round_status"] = "new"
|
|
167
|
+
elif row["status"] == "open":
|
|
168
|
+
finding["round_status"] = "duplicate"
|
|
169
|
+
finding["first_round"] = row.get("round")
|
|
170
|
+
else: # dismissed — overruled before; never re-raise
|
|
171
|
+
finding["round_status"] = "suppressed"
|
|
172
|
+
finding["suppressed_as"] = row["status"]
|
|
173
|
+
# A prior open finding is resolved only by an explicit reviewer disposition;
|
|
174
|
+
# absence from the new output never means fixed.
|
|
175
|
+
resolved_fps = {d["fingerprint"] for d in dispositions if d["status"] == "resolved"}
|
|
176
|
+
resolved, still_open = [], []
|
|
177
|
+
for fp, row in ledger.items():
|
|
178
|
+
if row.get("status") != "open" or fp in found_fps:
|
|
179
|
+
continue
|
|
180
|
+
(resolved if fp in resolved_fps else still_open).append(fp)
|
|
181
|
+
return {
|
|
182
|
+
"resolved": sorted(resolved),
|
|
183
|
+
"still_open_unreviewed": sorted(still_open),
|
|
184
|
+
"prior_rounds": len((prior_state or {}).get("rounds", [])),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
HUNK_RE = re.compile(r"^(new|old):(\d+)-(\d+)$")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def hunk_lines(file: str, hunk: str) -> Counter:
|
|
192
|
+
match = HUNK_RE.fullmatch(hunk)
|
|
193
|
+
if match is None:
|
|
194
|
+
raise RuntimeError(f"invalid canonical hunk {file}:{hunk}")
|
|
195
|
+
side, start, end = match.group(1), int(match.group(2)), int(match.group(3))
|
|
196
|
+
if end < start:
|
|
197
|
+
raise RuntimeError(f"invalid descending hunk {file}:{hunk}")
|
|
198
|
+
return Counter((file, side, line) for line in range(start, end + 1))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def coverage_ledger(manifest: dict, collected: dict[str, list[dict]]) -> dict:
|
|
202
|
+
expected = Counter()
|
|
203
|
+
for file in manifest["files"]:
|
|
204
|
+
if file["disposition"] != "selected":
|
|
205
|
+
continue
|
|
206
|
+
for hunk in file["hunks"]:
|
|
207
|
+
expected += hunk_lines(file["path"], hunk_text(hunk))
|
|
208
|
+
|
|
209
|
+
lane_stats = {}
|
|
210
|
+
for lane in ("defect",):
|
|
211
|
+
actual = Counter()
|
|
212
|
+
rows = [row for row in collected["hunk_coverage"] if row["lane"] == lane]
|
|
213
|
+
for row in rows:
|
|
214
|
+
actual += hunk_lines(row["file"], row["hunk"])
|
|
215
|
+
missing, extra = expected - actual, actual - expected
|
|
216
|
+
if missing or extra:
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
f"{lane} coverage incomplete: missing_lines={sum(missing.values())} "
|
|
219
|
+
f"duplicated_or_extra_lines={sum(extra.values())}"
|
|
220
|
+
)
|
|
221
|
+
lane_stats[lane] = {
|
|
222
|
+
"rows": len(rows), "covered_lines": sum(actual.values()), "complete": True,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
rule_status = defaultdict(Counter)
|
|
226
|
+
for row in collected["rule_coverage"]:
|
|
227
|
+
rule_status[row["rule_id"]][row["status"]] += 1
|
|
228
|
+
return {
|
|
229
|
+
"hunks": collected["hunk_coverage"],
|
|
230
|
+
"rules": collected["rule_coverage"],
|
|
231
|
+
"summary": {
|
|
232
|
+
"selected_hunk_lines": sum(expected.values()),
|
|
233
|
+
"lanes": lane_stats,
|
|
234
|
+
"rules": {rule: dict(sorted(counts.items())) for rule, counts in sorted(rule_status.items())},
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main() -> int:
|
|
240
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
241
|
+
parser.add_argument("--out", required=True)
|
|
242
|
+
args = parser.parse_args()
|
|
243
|
+
repo = repo_root()
|
|
244
|
+
out = Path(args.out).resolve()
|
|
245
|
+
try:
|
|
246
|
+
manifest = read_json(out / "manifest.json")
|
|
247
|
+
collected = collect(repo, out)
|
|
248
|
+
coverage = coverage_ledger(manifest, collected)
|
|
249
|
+
except RuntimeError as error:
|
|
250
|
+
sys.stderr.write(f"{error}\n")
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
canonical_by_kind: dict[str, list[dict]] = {"defects": [], "advisories": []}
|
|
254
|
+
raw_ledger = []
|
|
255
|
+
for key in ("defects", "advisories"):
|
|
256
|
+
for members in group_duplicates(collected[key]):
|
|
257
|
+
merged = merge_group(members)
|
|
258
|
+
canonical_by_kind[key].append(merged)
|
|
259
|
+
raw_ledger.extend(raw_ledger_entries(members, merged))
|
|
260
|
+
canonical_by_kind[key].sort(key=lambda f: (f["file"], f["line"], f["raw_ids"][0]))
|
|
261
|
+
raw_ledger.sort(key=lambda item: item["raw_id"])
|
|
262
|
+
|
|
263
|
+
canonical_results = [*canonical_by_kind["defects"], *canonical_by_kind["advisories"]]
|
|
264
|
+
|
|
265
|
+
prior_state = read_json(out / "state.json") if (out / "state.json").is_file() else None
|
|
266
|
+
found_fps = {finding["fingerprint"] for finding in canonical_results}
|
|
267
|
+
reconciliation = reconcile(canonical_results, found_fps, prior_state, collected["dispositions"])
|
|
268
|
+
|
|
269
|
+
raw_count = len(collected["defects"]) + len(collected["advisories"])
|
|
270
|
+
canonical_count = len(canonical_results)
|
|
271
|
+
suppression_reasons = Counter(item["reason"] for item in collected["suppressions"])
|
|
272
|
+
review_stats = {
|
|
273
|
+
"candidates": raw_count + len(collected["suppressions"]),
|
|
274
|
+
"reported": raw_count,
|
|
275
|
+
"suppressed": len(collected["suppressions"]),
|
|
276
|
+
"suppression_reasons": dict(sorted(suppression_reasons.items())),
|
|
277
|
+
"raw_defects": len(collected["defects"]),
|
|
278
|
+
"raw_advisories": len(collected["advisories"]),
|
|
279
|
+
"canonical_defects": len(canonical_by_kind["defects"]),
|
|
280
|
+
"canonical_advisories": len(canonical_by_kind["advisories"]),
|
|
281
|
+
"coverage": coverage["summary"],
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
payload = {
|
|
285
|
+
"source_snapshot": manifest.get("worktree_snapshot"),
|
|
286
|
+
"summary": {
|
|
287
|
+
"raw": raw_count,
|
|
288
|
+
"canonical": canonical_count,
|
|
289
|
+
"merged_raw": raw_count - canonical_count,
|
|
290
|
+
"round_status": dict(sorted(Counter(f["round_status"] for f in canonical_results).items())),
|
|
291
|
+
"defect_severity": dict(sorted(Counter(f["severity"] for f in canonical_by_kind["defects"]).items())),
|
|
292
|
+
"advisory_category": dict(sorted(Counter(f["category"] for f in canonical_by_kind["advisories"]).items())),
|
|
293
|
+
},
|
|
294
|
+
"findings": canonical_by_kind["defects"],
|
|
295
|
+
"advisories": canonical_by_kind["advisories"],
|
|
296
|
+
"suppressions": collected["suppressions"],
|
|
297
|
+
"dispositions": collected["dispositions"],
|
|
298
|
+
"coverage": coverage,
|
|
299
|
+
"review_stats": review_stats,
|
|
300
|
+
"raw_ledger": raw_ledger,
|
|
301
|
+
"reconciliation": reconciliation,
|
|
302
|
+
}
|
|
303
|
+
write_json(out / "findings.json", payload)
|
|
304
|
+
write_json(out / "review-stats.json", review_stats)
|
|
305
|
+
print(f"findings ledger -> {out / 'findings.json'}")
|
|
306
|
+
print(f"review stats -> {out / 'review-stats.json'}")
|
|
307
|
+
print(json.dumps(payload["summary"], sort_keys=True))
|
|
308
|
+
print(json.dumps(reconciliation, sort_keys=True))
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
sys.exit(main())
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deep-review HTML report generator (bootstrap helper; writes only under --out).
|
|
3
|
+
|
|
4
|
+
Hydrates assets/REVIEW_UI.html — the fixed, self-contained report UI — with the
|
|
5
|
+
round's artifacts: defects/advisories/suppressions/coverage from findings.json
|
|
6
|
+
and manifest.json (required), plus state.json, walkthrough.md, rules.json,
|
|
7
|
+
review.md and archived rounds when present. Emits
|
|
8
|
+
<out>/review.html, the human-facing view of the review; agents keep consuming
|
|
9
|
+
the JSON artifacts. Cheap and idempotent — re-run it after merge_findings.py
|
|
10
|
+
and after render_review.py so the open dashboard (which auto-reloads) always
|
|
11
|
+
shows the current round. Before render_review.py has run, the verdict renders
|
|
12
|
+
as a neutral "round in progress" state.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 ok, 1 missing/invalid inputs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import webbrowser
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
|
|
28
|
+
|
|
29
|
+
from _common import ASSETS_DIR, read_json, rel, repo_root
|
|
30
|
+
from render_review import line_range, one_line, repair_plan
|
|
31
|
+
|
|
32
|
+
TEMPLATE = ASSETS_DIR / "REVIEW_UI.html"
|
|
33
|
+
PLACEHOLDER = "__DEEP_REVIEW_DATA__"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_optional_json(path: Path):
|
|
37
|
+
return read_json(path) if path.is_file() else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_optional_text(path: Path) -> str | None:
|
|
41
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ai_prompt(finding: dict) -> str | None:
|
|
45
|
+
"""The same prompt render_review.py embeds in review.md, as copyable text."""
|
|
46
|
+
if finding.get("result_kind") != "defect" or finding["severity"] not in {"critical", "major"}:
|
|
47
|
+
return None
|
|
48
|
+
suggestion = one_line(finding.get("suggestion") or "")
|
|
49
|
+
remediation = suggestion or (
|
|
50
|
+
"correct the failure mode described in the finding at the owning layer"
|
|
51
|
+
)
|
|
52
|
+
rule_ids = ", ".join(finding.get("rule_ids") or []) or "review evidence"
|
|
53
|
+
return "\n".join([
|
|
54
|
+
"Verify this finding against the current code and fix it only if still valid.",
|
|
55
|
+
f"In {finding['file']} around lines {line_range(finding)}, {remediation}",
|
|
56
|
+
f"Reference anchor: {finding['file']}:{finding['line']}; rules: {rule_ids}.",
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def verdict_for(state: dict | None, round_n: int, review_md: str | None) -> tuple[str, str | None]:
|
|
61
|
+
"""Verdict from the round's state entry; rationale from review.md's header."""
|
|
62
|
+
verdict = "PENDING"
|
|
63
|
+
for entry in (state or {}).get("rounds", []):
|
|
64
|
+
if entry.get("n") == round_n:
|
|
65
|
+
verdict = entry.get("verdict", "PENDING")
|
|
66
|
+
rationale = None
|
|
67
|
+
if review_md:
|
|
68
|
+
match = re.search(r"^\*\*Verdict: (\w+)\*\* — (.+)$", review_md, re.M)
|
|
69
|
+
if match and match.group(1) == verdict:
|
|
70
|
+
rationale = match.group(2).strip()
|
|
71
|
+
return verdict, rationale
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def round_stats(ledger: dict | None) -> dict | None:
|
|
75
|
+
"""Open-finding counts from a findings.json payload (live or archived)."""
|
|
76
|
+
if not ledger:
|
|
77
|
+
return None
|
|
78
|
+
open_findings = [
|
|
79
|
+
f for f in [*ledger.get("findings", []), *ledger.get("advisories", [])]
|
|
80
|
+
if f.get("round_status") in {"new", "duplicate"}
|
|
81
|
+
]
|
|
82
|
+
defects = [f for f in open_findings if f.get("result_kind") == "defect"]
|
|
83
|
+
return {
|
|
84
|
+
"open": len(open_findings),
|
|
85
|
+
"critical": sum(1 for f in defects if f["severity"] == "critical"),
|
|
86
|
+
"major": sum(1 for f in defects if f["severity"] == "major"),
|
|
87
|
+
"advisories": sum(1 for f in open_findings if f.get("result_kind") == "advisory"),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def rounds_timeline(out: Path, state: dict | None, manifest: dict,
|
|
92
|
+
live_ledger: dict, verdict: str) -> list[dict]:
|
|
93
|
+
current_n = manifest["round"]
|
|
94
|
+
rounds = []
|
|
95
|
+
for entry in (state or {}).get("rounds", []):
|
|
96
|
+
n = entry.get("n")
|
|
97
|
+
ledger = live_ledger if n == current_n else read_optional_json(
|
|
98
|
+
out / "rounds" / f"round-{n}" / "findings.json"
|
|
99
|
+
)
|
|
100
|
+
rounds.append({
|
|
101
|
+
"n": n, "base": entry.get("base"), "head": entry.get("head"),
|
|
102
|
+
"verdict": entry.get("verdict"), "reviewed_at": entry.get("reviewed_at"),
|
|
103
|
+
"current": n == current_n, "stats": round_stats(ledger),
|
|
104
|
+
})
|
|
105
|
+
if not any(r["current"] for r in rounds): # render_review has not run yet
|
|
106
|
+
rounds.append({
|
|
107
|
+
"n": current_n, "base": manifest.get("base"), "head": manifest.get("head"),
|
|
108
|
+
"verdict": verdict, "reviewed_at": None, "current": True,
|
|
109
|
+
"stats": round_stats(live_ledger),
|
|
110
|
+
})
|
|
111
|
+
return sorted(rounds, key=lambda r: r["n"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ledger_rows(state: dict | None, fingerprints: list[str]) -> list[dict]:
|
|
115
|
+
ledger = (state or {}).get("ledger", {})
|
|
116
|
+
rows = []
|
|
117
|
+
for fp in fingerprints:
|
|
118
|
+
row = ledger.get(fp) or {}
|
|
119
|
+
rows.append({
|
|
120
|
+
"fingerprint": fp, "file": row.get("file", "?"),
|
|
121
|
+
"title": row.get("title", fp), "severity": row.get("severity", "minor"),
|
|
122
|
+
"round": row.get("round"), "resolved_in": row.get("resolved_in"),
|
|
123
|
+
})
|
|
124
|
+
return rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def dismissed_rows(state: dict | None) -> list[dict]:
|
|
128
|
+
ledger = (state or {}).get("ledger", {})
|
|
129
|
+
return [
|
|
130
|
+
{"fingerprint": fp, "file": row.get("file", "?"), "title": row.get("title", fp),
|
|
131
|
+
"severity": row.get("severity", "minor"), "round": row.get("round")}
|
|
132
|
+
for fp, row in sorted(ledger.items())
|
|
133
|
+
if row.get("status") == "dismissed"
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_payload(repo: Path, out: Path) -> dict:
|
|
138
|
+
manifest = read_json(out / "manifest.json")
|
|
139
|
+
try:
|
|
140
|
+
ledger = read_json(out / "findings.json")
|
|
141
|
+
except RuntimeError as error:
|
|
142
|
+
raise RuntimeError(f"{error} — run merge_findings.py first") from error
|
|
143
|
+
state = read_optional_json(out / "state.json")
|
|
144
|
+
rules = read_optional_json(out / "rules.json") or {"rules": []}
|
|
145
|
+
rules_by_id = {rule["id"]: rule for rule in rules["rules"]}
|
|
146
|
+
review_md = read_optional_text(out / "review.md")
|
|
147
|
+
verdict, rationale = verdict_for(state, manifest["round"], review_md)
|
|
148
|
+
|
|
149
|
+
findings = []
|
|
150
|
+
for finding in [*ledger.get("findings", []), *ledger.get("advisories", [])]:
|
|
151
|
+
resolved_rules = [
|
|
152
|
+
{"id": rid, "source": rules_by_id[rid]["source"],
|
|
153
|
+
"guideline": rules_by_id[rid]["guideline"]}
|
|
154
|
+
for rid in (finding.get("rule_ids") or []) if rid in rules_by_id
|
|
155
|
+
]
|
|
156
|
+
findings.append({
|
|
157
|
+
**finding,
|
|
158
|
+
"rules": resolved_rules,
|
|
159
|
+
"ai_prompt": ai_prompt(finding),
|
|
160
|
+
"repair_plan": repair_plan(finding),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
reconciliation = ledger.get("reconciliation", {})
|
|
164
|
+
return {
|
|
165
|
+
"schema_version": 2,
|
|
166
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
167
|
+
"target": manifest["target"],
|
|
168
|
+
"pr": manifest.get("pr"),
|
|
169
|
+
"mode": manifest.get("mode"),
|
|
170
|
+
"round": manifest["round"],
|
|
171
|
+
"base": manifest.get("base"),
|
|
172
|
+
"head": manifest.get("head"),
|
|
173
|
+
"counts": manifest.get("counts", {}),
|
|
174
|
+
"out_dir": rel(out, repo),
|
|
175
|
+
"verdict": verdict,
|
|
176
|
+
"verdict_rationale": rationale,
|
|
177
|
+
"summary": ledger.get("summary", {}),
|
|
178
|
+
"findings": findings,
|
|
179
|
+
"suppressions": ledger.get("suppressions", []),
|
|
180
|
+
"coverage": ledger.get("coverage", {}),
|
|
181
|
+
"review_stats": ledger.get("review_stats", {}),
|
|
182
|
+
"resolved": ledger_rows(state, reconciliation.get("resolved", [])),
|
|
183
|
+
"unreviewed_open": ledger_rows(state, reconciliation.get("still_open_unreviewed", [])),
|
|
184
|
+
"dismissed": dismissed_rows(state),
|
|
185
|
+
"rounds": rounds_timeline(out, state, manifest, ledger, verdict),
|
|
186
|
+
"walkthrough": read_optional_text(out / "walkthrough.md"),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def hydrate(payload: dict) -> str:
|
|
191
|
+
template = TEMPLATE.read_text(encoding="utf-8")
|
|
192
|
+
occurrences = template.count(PLACEHOLDER)
|
|
193
|
+
if occurrences != 1:
|
|
194
|
+
raise RuntimeError(
|
|
195
|
+
f"template {TEMPLATE} must contain the data placeholder exactly once "
|
|
196
|
+
f"(found {occurrences})"
|
|
197
|
+
)
|
|
198
|
+
# Escape sequences that would let JSON text break out of the <script> data
|
|
199
|
+
# block: </ (closes the tag) and <!-- (opens an HTML comment-escaped state).
|
|
200
|
+
data = (
|
|
201
|
+
json.dumps(payload, ensure_ascii=False)
|
|
202
|
+
.replace("</", "<\\/")
|
|
203
|
+
.replace("<!--", "<\\u0021--")
|
|
204
|
+
)
|
|
205
|
+
return template.replace(PLACEHOLDER, data, 1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main() -> int:
|
|
209
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
210
|
+
parser.add_argument("--out", required=True)
|
|
211
|
+
parser.add_argument("--open", action="store_true", dest="open_browser",
|
|
212
|
+
help="open the generated report in the default browser")
|
|
213
|
+
args = parser.parse_args()
|
|
214
|
+
try:
|
|
215
|
+
repo = repo_root()
|
|
216
|
+
out = Path(args.out).resolve()
|
|
217
|
+
payload = build_payload(repo, out)
|
|
218
|
+
(out / "review.html").write_text(hydrate(payload), encoding="utf-8")
|
|
219
|
+
except RuntimeError as error:
|
|
220
|
+
sys.stderr.write(f"{error}\n")
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
open_findings = [f for f in payload["findings"]
|
|
224
|
+
if f.get("round_status") in {"new", "duplicate"}]
|
|
225
|
+
defects = [f for f in open_findings if f.get("result_kind") == "defect"]
|
|
226
|
+
advisories = [f for f in open_findings if f.get("result_kind") == "advisory"]
|
|
227
|
+
severities = {s: sum(1 for f in open_findings if f["severity"] == s)
|
|
228
|
+
for s in ("critical", "major", "minor", "trivial")}
|
|
229
|
+
print(f"report -> {out / 'review.html'}")
|
|
230
|
+
print(f"verdict={payload['verdict']} defects={len(defects)} advisories={len(advisories)} "
|
|
231
|
+
f"(critical={severities['critical']} major={severities['major']} "
|
|
232
|
+
f"minor={severities['minor']} trivial={severities['trivial']}) "
|
|
233
|
+
f"resolved={len(payload['resolved'])} rounds={len(payload['rounds'])}")
|
|
234
|
+
if args.open_browser:
|
|
235
|
+
webbrowser.open((out / "review.html").as_uri())
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|