ma-agents 3.13.2 → 3.14.0
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/lib/agents.js +7 -0
- package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
- package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
- package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
- package/lib/bmad-cache/bmb/package-lock.json +2 -2
- package/lib/bmad-cache/bmb/package.json +1 -1
- package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
- package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
- package/lib/bmad-cache/cache-manifest.json +4 -4
- package/lib/bmad-extension/.claude-plugin/marketplace.json.template +1 -1
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
- package/lib/bmad.js +6 -1
- package/lib/templates/instruction-block-git.template.md +25 -25
- package/lib/templates/instruction-block-onprem.template.md +86 -86
- package/lib/templates/instruction-block-universal.template.md +29 -29
- package/package.json +3 -3
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/.claude-plugin/marketplace.json +0 -109
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module-help.csv +0 -62
- package/lib/.bmad-extension-plugin.build-22612-1782454014750/skills/module.yaml +0 -20
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
- package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
- package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
- package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
- package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""Render the analysis report deterministically from findings JSON.
|
|
6
|
+
|
|
7
|
+
Injects a validated findings JSON object into the report shell's
|
|
8
|
+
report-data island and writes the self-contained HTML atomically.
|
|
9
|
+
With --md, also writes a markdown rendering of the same data as the
|
|
10
|
+
archival artifact.
|
|
11
|
+
|
|
12
|
+
Refuses (non-zero exit, message on stderr) when the JSON does not
|
|
13
|
+
parse, fails shape validation, or still carries the shell's
|
|
14
|
+
placeholder subject — a refused render means fix the findings file
|
|
15
|
+
and re-run, never hand-edit the HTML.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
uv run render_report.py <findings.json> --shell <report-shell.html> \
|
|
19
|
+
-o <out.html> [--md <out.md>]
|
|
20
|
+
|
|
21
|
+
On success prints one JSON line: output paths, grade, and severity
|
|
22
|
+
counts derived from the findings array.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
SEVERITIES = ("critical", "high", "medium", "low")
|
|
35
|
+
GRADES = ("excellent", "good", "fair", "poor")
|
|
36
|
+
PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
|
|
37
|
+
ISLAND_RE = re.compile(
|
|
38
|
+
r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fail(message: str) -> None:
|
|
43
|
+
print(f"render_report: {message}", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate(data: object) -> list[str]:
|
|
48
|
+
"""Return a list of shape errors; empty list means valid."""
|
|
49
|
+
if not isinstance(data, dict):
|
|
50
|
+
return ["top level must be a JSON object"]
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
|
|
53
|
+
subject = data.get("subject")
|
|
54
|
+
if not isinstance(subject, str) or not subject.strip():
|
|
55
|
+
errors.append('"subject" must be a non-empty string')
|
|
56
|
+
elif PLACEHOLDER_SUBJECT in subject:
|
|
57
|
+
errors.append(
|
|
58
|
+
f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
|
|
59
|
+
"this is the unfilled shell sample, not real findings"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
findings = data.get("findings")
|
|
63
|
+
if not isinstance(findings, list):
|
|
64
|
+
errors.append('"findings" must be an array (use [] for a clean pass)')
|
|
65
|
+
else:
|
|
66
|
+
for i, finding in enumerate(findings):
|
|
67
|
+
if not isinstance(finding, dict):
|
|
68
|
+
errors.append(f"findings[{i}] must be an object")
|
|
69
|
+
|
|
70
|
+
grade = data.get("grade")
|
|
71
|
+
if grade is not None and grade not in GRADES:
|
|
72
|
+
errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
|
|
73
|
+
|
|
74
|
+
for key in ("themes", "recommendations"):
|
|
75
|
+
value = data.get(key)
|
|
76
|
+
if value is not None and (
|
|
77
|
+
not isinstance(value, list)
|
|
78
|
+
or any(not isinstance(item, dict) for item in value)
|
|
79
|
+
):
|
|
80
|
+
errors.append(f'"{key}" must be an array of objects')
|
|
81
|
+
|
|
82
|
+
strengths = data.get("strengths")
|
|
83
|
+
if strengths is not None and (
|
|
84
|
+
not isinstance(strengths, list)
|
|
85
|
+
or any(not isinstance(item, str) for item in strengths)
|
|
86
|
+
):
|
|
87
|
+
errors.append('"strengths" must be an array of strings')
|
|
88
|
+
|
|
89
|
+
return errors
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def severity_counts(findings: list[dict]) -> dict[str, int]:
|
|
93
|
+
counts = {sev: 0 for sev in SEVERITIES}
|
|
94
|
+
for finding in findings:
|
|
95
|
+
sev = finding.get("severity")
|
|
96
|
+
counts[sev if sev in counts else "low"] += 1
|
|
97
|
+
return counts
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def inject(shell_html: str, data: dict) -> str:
|
|
101
|
+
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
|
102
|
+
# A "</" sequence inside a JSON string would close the script tag
|
|
103
|
+
# early in the browser; "<\/" is the same string to JSON.parse.
|
|
104
|
+
payload = payload.replace("</", "<\\/")
|
|
105
|
+
|
|
106
|
+
def replace(match: re.Match) -> str:
|
|
107
|
+
return match.group(1) + "\n" + payload + "\n" + match.group(3)
|
|
108
|
+
|
|
109
|
+
new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
|
|
110
|
+
if count != 1:
|
|
111
|
+
fail('shell has no <script id="report-data"> island to fill')
|
|
112
|
+
return new_html
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
fd, tmp = tempfile.mkstemp(
|
|
118
|
+
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
122
|
+
handle.write(text)
|
|
123
|
+
handle.flush()
|
|
124
|
+
os.fsync(handle.fileno())
|
|
125
|
+
os.replace(tmp, path)
|
|
126
|
+
except BaseException:
|
|
127
|
+
try:
|
|
128
|
+
os.unlink(tmp)
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
raise
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _finding_lines(finding: dict, heading_level: str) -> list[str]:
|
|
135
|
+
fid = str(finding.get("id", ""))
|
|
136
|
+
title = str(finding.get("title", "(untitled finding)"))
|
|
137
|
+
lines = [f"{heading_level} {fid} — {title}" if fid else f"{heading_level} {title}", ""]
|
|
138
|
+
for key, label in (
|
|
139
|
+
("lens", "Lens"),
|
|
140
|
+
("location", "Location"),
|
|
141
|
+
("evidence", "Evidence"),
|
|
142
|
+
("recommendation", "Recommendation"),
|
|
143
|
+
("proposed_smallest", "Proposed smallest"),
|
|
144
|
+
("predicted_delta", "Predicted delta"),
|
|
145
|
+
):
|
|
146
|
+
value = finding.get(key)
|
|
147
|
+
if value:
|
|
148
|
+
value = f"`{value}`" if key == "location" else str(value)
|
|
149
|
+
lines.append(f"- {label}: {value}")
|
|
150
|
+
lines.append("")
|
|
151
|
+
return lines
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_md(data: dict) -> str:
|
|
155
|
+
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
|
|
156
|
+
by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
|
|
157
|
+
counts = severity_counts(findings)
|
|
158
|
+
lines: list[str] = []
|
|
159
|
+
|
|
160
|
+
lines.append(f"# Analysis Report: {data.get('subject', '')}")
|
|
161
|
+
lines.append("")
|
|
162
|
+
meta = []
|
|
163
|
+
if data.get("generated"):
|
|
164
|
+
meta.append(f"Generated: {data['generated']}")
|
|
165
|
+
if data.get("schema_version") is not None:
|
|
166
|
+
meta.append(f"Schema: {data['schema_version']}")
|
|
167
|
+
if meta:
|
|
168
|
+
lines.append(" · ".join(meta))
|
|
169
|
+
lines.append("")
|
|
170
|
+
|
|
171
|
+
if data.get("grade"):
|
|
172
|
+
lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
|
|
173
|
+
lines.append("")
|
|
174
|
+
if data.get("verdict"):
|
|
175
|
+
lines.append(f"> {data['verdict']}")
|
|
176
|
+
lines.append("")
|
|
177
|
+
summary = data.get("summary")
|
|
178
|
+
if isinstance(summary, str) and summary:
|
|
179
|
+
lines.append(summary)
|
|
180
|
+
lines.append("")
|
|
181
|
+
|
|
182
|
+
lines.append("| Severity | Count |")
|
|
183
|
+
lines.append("| --- | --- |")
|
|
184
|
+
for sev in SEVERITIES:
|
|
185
|
+
lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
|
|
186
|
+
lines.append("")
|
|
187
|
+
|
|
188
|
+
themes = data.get("themes") or []
|
|
189
|
+
if themes:
|
|
190
|
+
lines.append("## Themes")
|
|
191
|
+
lines.append("")
|
|
192
|
+
for i, theme in enumerate(themes, 1):
|
|
193
|
+
lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
|
|
194
|
+
lines.append("")
|
|
195
|
+
if theme.get("root_cause"):
|
|
196
|
+
lines.append(f"- Root cause: {theme['root_cause']}")
|
|
197
|
+
if theme.get("action"):
|
|
198
|
+
lines.append(f"- Fix: {theme['action']}")
|
|
199
|
+
ids = theme.get("finding_ids") or []
|
|
200
|
+
if ids:
|
|
201
|
+
lines.append("- Findings:")
|
|
202
|
+
for fid in ids:
|
|
203
|
+
finding = by_id.get(str(fid))
|
|
204
|
+
if finding:
|
|
205
|
+
loc = finding.get("location")
|
|
206
|
+
suffix = f" — `{loc}`" if loc else ""
|
|
207
|
+
lines.append(
|
|
208
|
+
f" - `{fid}` {finding.get('title', '')}{suffix}"
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
lines.append(f" - `{fid}`")
|
|
212
|
+
lines.append("")
|
|
213
|
+
|
|
214
|
+
strengths = data.get("strengths") or []
|
|
215
|
+
if strengths:
|
|
216
|
+
lines.append("## Strengths")
|
|
217
|
+
lines.append("")
|
|
218
|
+
for strength in strengths:
|
|
219
|
+
lines.append(f"- {strength}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
recommendations = data.get("recommendations") or []
|
|
223
|
+
if recommendations:
|
|
224
|
+
lines.append("## Recommendations")
|
|
225
|
+
lines.append("")
|
|
226
|
+
for i, rec in enumerate(recommendations, 1):
|
|
227
|
+
rank = rec.get("rank", i)
|
|
228
|
+
resolves = rec.get("resolves")
|
|
229
|
+
if isinstance(resolves, list) and resolves:
|
|
230
|
+
suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
|
|
231
|
+
elif isinstance(resolves, (int, float)):
|
|
232
|
+
suffix = f" (resolves {int(resolves)} findings)"
|
|
233
|
+
else:
|
|
234
|
+
suffix = ""
|
|
235
|
+
lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
# Optional agent blocks: rendered only when present so the same
|
|
239
|
+
# renderer serves both the workflow and agent schemas.
|
|
240
|
+
profile = data.get("agent_profile")
|
|
241
|
+
if isinstance(profile, dict) and any(profile.values()):
|
|
242
|
+
lines.append("## Agent Profile")
|
|
243
|
+
lines.append("")
|
|
244
|
+
for key, label in (
|
|
245
|
+
("name", "Name"),
|
|
246
|
+
("title", "Title"),
|
|
247
|
+
("agent_type", "Type"),
|
|
248
|
+
("mission", "Mission"),
|
|
249
|
+
):
|
|
250
|
+
if profile.get(key):
|
|
251
|
+
lines.append(f"- {label}: {profile[key]}")
|
|
252
|
+
lines.append("")
|
|
253
|
+
|
|
254
|
+
capabilities = data.get("capabilities")
|
|
255
|
+
if isinstance(capabilities, list) and capabilities:
|
|
256
|
+
lines.append("## Capabilities")
|
|
257
|
+
lines.append("")
|
|
258
|
+
for cap in capabilities:
|
|
259
|
+
if not isinstance(cap, dict) or not cap.get("name"):
|
|
260
|
+
continue
|
|
261
|
+
kind = f" ({cap['kind']})" if cap.get("kind") else ""
|
|
262
|
+
note = f" — {cap['note']}" if cap.get("note") else ""
|
|
263
|
+
lines.append(f"- **{cap['name']}**{kind}{note}")
|
|
264
|
+
lines.append("")
|
|
265
|
+
|
|
266
|
+
detailed = data.get("detailed_analysis")
|
|
267
|
+
if isinstance(detailed, dict) and detailed:
|
|
268
|
+
lines.append("## Per-Lens Verdicts")
|
|
269
|
+
lines.append("")
|
|
270
|
+
for lens, verdict in detailed.items():
|
|
271
|
+
if verdict:
|
|
272
|
+
lines.append(f"- **{lens}**: {verdict}")
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
sanctum = data.get("sanctum")
|
|
276
|
+
if isinstance(sanctum, dict) and sanctum.get("present") is not False:
|
|
277
|
+
rows = []
|
|
278
|
+
if sanctum.get("location"):
|
|
279
|
+
rows.append(f"- Location: `{sanctum['location']}`")
|
|
280
|
+
files = sanctum.get("files") or []
|
|
281
|
+
if files:
|
|
282
|
+
rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
|
|
283
|
+
if sanctum.get("note"):
|
|
284
|
+
rows.append(f"- Note: {sanctum['note']}")
|
|
285
|
+
if rows:
|
|
286
|
+
lines.append("## Sanctum (runtime memory)")
|
|
287
|
+
lines.append("")
|
|
288
|
+
lines.extend(rows)
|
|
289
|
+
lines.append("")
|
|
290
|
+
|
|
291
|
+
experience = data.get("experience")
|
|
292
|
+
if isinstance(experience, dict):
|
|
293
|
+
journeys = [
|
|
294
|
+
j for j in experience.get("journeys") or [] if isinstance(j, dict)
|
|
295
|
+
]
|
|
296
|
+
headless = experience.get("headless")
|
|
297
|
+
if journeys or headless:
|
|
298
|
+
lines.append("## Experience")
|
|
299
|
+
lines.append("")
|
|
300
|
+
for journey in journeys:
|
|
301
|
+
steps = f" — {journey['steps']}" if journey.get("steps") else ""
|
|
302
|
+
lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
|
|
303
|
+
if headless:
|
|
304
|
+
lines.append(f"- Headless: {headless}")
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
lines.append("## Findings")
|
|
308
|
+
lines.append("")
|
|
309
|
+
if not findings:
|
|
310
|
+
lines.append("No findings: the scanners returned a clean pass.")
|
|
311
|
+
lines.append("")
|
|
312
|
+
else:
|
|
313
|
+
for sev in SEVERITIES:
|
|
314
|
+
group = [
|
|
315
|
+
f
|
|
316
|
+
for f in findings
|
|
317
|
+
if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
|
|
318
|
+
== sev
|
|
319
|
+
]
|
|
320
|
+
if not group:
|
|
321
|
+
continue
|
|
322
|
+
lines.append(f"### {sev.capitalize()} ({len(group)})")
|
|
323
|
+
lines.append("")
|
|
324
|
+
for finding in group:
|
|
325
|
+
lines.extend(_finding_lines(finding, "####"))
|
|
326
|
+
|
|
327
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def main() -> int:
|
|
331
|
+
parser = argparse.ArgumentParser(
|
|
332
|
+
description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
|
|
333
|
+
)
|
|
334
|
+
parser.add_argument("findings", type=Path, help="path to findings.json")
|
|
335
|
+
parser.add_argument(
|
|
336
|
+
"--shell", type=Path, required=True, help="path to report-shell.html"
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"-o", "--output", type=Path, required=True, help="output HTML path"
|
|
340
|
+
)
|
|
341
|
+
parser.add_argument(
|
|
342
|
+
"--md", type=Path, help="also write a markdown rendering to this path"
|
|
343
|
+
)
|
|
344
|
+
args = parser.parse_args()
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
raw = args.findings.read_text(encoding="utf-8")
|
|
348
|
+
except OSError as err:
|
|
349
|
+
fail(f"cannot read {args.findings}: {err}")
|
|
350
|
+
try:
|
|
351
|
+
data = json.loads(raw)
|
|
352
|
+
except json.JSONDecodeError as err:
|
|
353
|
+
fail(f"{args.findings} is not valid JSON: {err}")
|
|
354
|
+
|
|
355
|
+
errors = validate(data)
|
|
356
|
+
if errors:
|
|
357
|
+
fail(
|
|
358
|
+
f"{args.findings} failed shape validation:\n - "
|
|
359
|
+
+ "\n - ".join(errors)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
shell_html = args.shell.read_text(encoding="utf-8")
|
|
364
|
+
except OSError as err:
|
|
365
|
+
fail(f"cannot read shell {args.shell}: {err}")
|
|
366
|
+
|
|
367
|
+
atomic_write(args.output, inject(shell_html, data))
|
|
368
|
+
if args.md:
|
|
369
|
+
atomic_write(args.md, render_md(data))
|
|
370
|
+
|
|
371
|
+
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
|
|
372
|
+
print(
|
|
373
|
+
json.dumps(
|
|
374
|
+
{
|
|
375
|
+
"html_report": str(args.output),
|
|
376
|
+
"md_report": str(args.md) if args.md else None,
|
|
377
|
+
"grade": data.get("grade"),
|
|
378
|
+
"counts": severity_counts(findings),
|
|
379
|
+
"findings": len(findings),
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
sys.exit(main())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guard against drift between the embedded prompt-quality-canon copies.
|
|
3
|
+
|
|
4
|
+
The canon is embedded in three places — workflow-builder references,
|
|
5
|
+
agent-builder references, and the agent-builder asset emitted into built
|
|
6
|
+
agents — with no in-file sync note, because the loaded files are LLM-facing
|
|
7
|
+
and a maintenance comment there is paid on every load. This test is the
|
|
8
|
+
sync mechanism instead: all three copies must be byte-identical.
|
|
9
|
+
Run with: python3 -m pytest test_canon_sync.py
|
|
10
|
+
(or plain `python3 test_canon_sync.py` for a lightweight self-check).
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SKILLS_DIR = Path(__file__).resolve().parents[3]
|
|
16
|
+
|
|
17
|
+
CANON_COPIES = [
|
|
18
|
+
SKILLS_DIR / "bmad-workflow-builder" / "references" / "prompt-quality-canon.md",
|
|
19
|
+
SKILLS_DIR / "bmad-agent-builder" / "references" / "prompt-quality-canon.md",
|
|
20
|
+
SKILLS_DIR / "bmad-agent-builder" / "assets" / "prompt-quality-canon.md",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_all_copies_exist():
|
|
25
|
+
missing = [str(p) for p in CANON_COPIES if not p.is_file()]
|
|
26
|
+
assert not missing, f"canon copy missing: {missing}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_all_copies_identical():
|
|
30
|
+
contents = {p: p.read_bytes() for p in CANON_COPIES if p.is_file()}
|
|
31
|
+
reference = CANON_COPIES[0]
|
|
32
|
+
diverged = [
|
|
33
|
+
str(p)
|
|
34
|
+
for p, body in contents.items()
|
|
35
|
+
if body != contents.get(reference)
|
|
36
|
+
]
|
|
37
|
+
assert not diverged, (
|
|
38
|
+
"canon copies have drifted from "
|
|
39
|
+
f"{reference}: {diverged} — sync all copies together"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
test_all_copies_exist()
|
|
45
|
+
test_all_copies_identical()
|
|
46
|
+
print(f"ok: {len(CANON_COPIES)} canon copies present and identical")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for count_tokens.py.
|
|
3
|
+
|
|
4
|
+
Covers the output schema, the tiktoken path and the forced-fallback path
|
|
5
|
+
agreeing within tolerance, the CLI over a file and over stdin, and argument
|
|
6
|
+
guards. Run with: python3 -m pytest test_count_tokens.py
|
|
7
|
+
(or plain `python3 test_count_tokens.py` to run a lightweight self-check).
|
|
8
|
+
"""
|
|
9
|
+
import builtins
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
SCRIPT = Path(__file__).resolve().parent.parent / "count_tokens.py"
|
|
17
|
+
|
|
18
|
+
SAMPLE = (
|
|
19
|
+
"The builder is platform-agnostic. Nothing assumes a single runtime, and no "
|
|
20
|
+
"model list is ever hardcoded. Token counts replace line counts as the one "
|
|
21
|
+
"length metric, with a chars-over-four fallback when tiktoken is absent.\n"
|
|
22
|
+
) * 8
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_module():
|
|
26
|
+
spec = importlib.util.spec_from_file_location("count_tokens", SCRIPT)
|
|
27
|
+
mod = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(mod)
|
|
29
|
+
return mod
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_tiktoken_path():
|
|
33
|
+
mod = _load_module()
|
|
34
|
+
try:
|
|
35
|
+
import tiktoken # noqa: F401
|
|
36
|
+
except Exception:
|
|
37
|
+
# No tiktoken in this interpreter; the real path can't be exercised here.
|
|
38
|
+
tokens, method = mod.count_tokens(SAMPLE)
|
|
39
|
+
assert method == "fallback"
|
|
40
|
+
assert tokens == len(SAMPLE) // 4
|
|
41
|
+
return
|
|
42
|
+
tokens, method = mod.count_tokens(SAMPLE)
|
|
43
|
+
assert method == "tiktoken"
|
|
44
|
+
assert isinstance(tokens, int)
|
|
45
|
+
assert tokens > 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_fallback_path_when_import_blocked():
|
|
49
|
+
"""Force the import of tiktoken to fail and confirm the fallback fires."""
|
|
50
|
+
mod = _load_module()
|
|
51
|
+
real_import = builtins.__import__
|
|
52
|
+
|
|
53
|
+
def blocked_import(name, *args, **kwargs):
|
|
54
|
+
if name == "tiktoken" or name.startswith("tiktoken."):
|
|
55
|
+
raise ImportError("blocked for test")
|
|
56
|
+
return real_import(name, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
builtins.__import__ = blocked_import
|
|
59
|
+
try:
|
|
60
|
+
tokens, method = mod.count_tokens(SAMPLE)
|
|
61
|
+
finally:
|
|
62
|
+
builtins.__import__ = real_import
|
|
63
|
+
|
|
64
|
+
assert method == "fallback"
|
|
65
|
+
assert tokens == len(SAMPLE) // 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_paths_agree_within_tolerance():
|
|
69
|
+
"""tiktoken and chars//4 should be in the same order of magnitude.
|
|
70
|
+
|
|
71
|
+
Skipped when tiktoken is not installed (nothing to compare against).
|
|
72
|
+
"""
|
|
73
|
+
mod = _load_module()
|
|
74
|
+
try:
|
|
75
|
+
import tiktoken # noqa: F401
|
|
76
|
+
except Exception:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
real_tokens, real_method = mod.count_tokens(SAMPLE)
|
|
80
|
+
assert real_method == "tiktoken"
|
|
81
|
+
|
|
82
|
+
fallback_tokens = len(SAMPLE) // 4
|
|
83
|
+
|
|
84
|
+
# The chars//4 heuristic is a rough proxy; require it within +/-50% of the
|
|
85
|
+
# real count so the fallback stays a usable budget gate, not a wild guess.
|
|
86
|
+
lower = real_tokens * 0.5
|
|
87
|
+
upper = real_tokens * 1.5
|
|
88
|
+
assert lower <= fallback_tokens <= upper, (
|
|
89
|
+
f"fallback {fallback_tokens} not within 50% of tiktoken {real_tokens}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_cli_file_output_schema(tmp_path):
|
|
94
|
+
f = tmp_path / "sample.md"
|
|
95
|
+
f.write_text(SAMPLE, encoding="utf-8")
|
|
96
|
+
out = subprocess.run(
|
|
97
|
+
[sys.executable, str(SCRIPT), str(f)],
|
|
98
|
+
capture_output=True, text=True, check=True,
|
|
99
|
+
).stdout
|
|
100
|
+
data = json.loads(out)
|
|
101
|
+
assert set(data.keys()) == {"tokens", "method"}
|
|
102
|
+
assert isinstance(data["tokens"], int)
|
|
103
|
+
assert data["method"] in ("tiktoken", "fallback")
|
|
104
|
+
assert data["tokens"] > 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_cli_stdin_output_schema():
|
|
108
|
+
out = subprocess.run(
|
|
109
|
+
[sys.executable, str(SCRIPT), "--stdin"],
|
|
110
|
+
input=SAMPLE, capture_output=True, text=True, check=True,
|
|
111
|
+
).stdout
|
|
112
|
+
data = json.loads(out)
|
|
113
|
+
assert set(data.keys()) == {"tokens", "method"}
|
|
114
|
+
assert isinstance(data["tokens"], int)
|
|
115
|
+
assert data["method"] in ("tiktoken", "fallback")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_cli_file_and_stdin_agree():
|
|
119
|
+
"""The CLI over a file and over stdin produce the same count for same text."""
|
|
120
|
+
import tempfile, os
|
|
121
|
+
fd, name = tempfile.mkstemp(suffix=".md")
|
|
122
|
+
try:
|
|
123
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
124
|
+
fh.write(SAMPLE)
|
|
125
|
+
file_out = json.loads(subprocess.run(
|
|
126
|
+
[sys.executable, str(SCRIPT), name],
|
|
127
|
+
capture_output=True, text=True, check=True,
|
|
128
|
+
).stdout)
|
|
129
|
+
finally:
|
|
130
|
+
os.unlink(name)
|
|
131
|
+
stdin_out = json.loads(subprocess.run(
|
|
132
|
+
[sys.executable, str(SCRIPT), "--stdin"],
|
|
133
|
+
input=SAMPLE, capture_output=True, text=True, check=True,
|
|
134
|
+
).stdout)
|
|
135
|
+
assert file_out == stdin_out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_cli_requires_an_input():
|
|
139
|
+
"""No file and no --stdin is a usage error (exit 2 from argparse)."""
|
|
140
|
+
res = subprocess.run(
|
|
141
|
+
[sys.executable, str(SCRIPT)],
|
|
142
|
+
capture_output=True, text=True,
|
|
143
|
+
)
|
|
144
|
+
assert res.returncode != 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_all():
|
|
148
|
+
import tempfile
|
|
149
|
+
failures = 0
|
|
150
|
+
tests = [
|
|
151
|
+
test_tiktoken_path,
|
|
152
|
+
test_fallback_path_when_import_blocked,
|
|
153
|
+
test_paths_agree_within_tolerance,
|
|
154
|
+
test_cli_stdin_output_schema,
|
|
155
|
+
test_cli_file_and_stdin_agree,
|
|
156
|
+
test_cli_requires_an_input,
|
|
157
|
+
]
|
|
158
|
+
for t in tests:
|
|
159
|
+
try:
|
|
160
|
+
t()
|
|
161
|
+
print(f"PASS {t.__name__}")
|
|
162
|
+
except AssertionError as e:
|
|
163
|
+
failures += 1
|
|
164
|
+
print(f"FAIL {t.__name__}: {e}")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
failures += 1
|
|
167
|
+
print(f"ERROR {t.__name__}: {e}")
|
|
168
|
+
# tmp_path-based test handled separately
|
|
169
|
+
with tempfile.TemporaryDirectory() as d:
|
|
170
|
+
try:
|
|
171
|
+
test_cli_file_output_schema(Path(d))
|
|
172
|
+
print("PASS test_cli_file_output_schema")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
failures += 1
|
|
175
|
+
print(f"FAIL test_cli_file_output_schema: {e}")
|
|
176
|
+
return failures
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(1 if _run_all() else 0)
|