Add Bmad-Method files and some brand-design artifacts!
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
# vendored from bmad-workflow-builder/scripts; canonical source there
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["tiktoken"]
|
||||
# ///
|
||||
"""count_tokens — the single length metric for skill authoring.
|
||||
|
||||
Token counts replace line counts everywhere in the builder and eval-runner.
|
||||
This script reports the token length of a file or of text piped on stdin, using
|
||||
the tiktoken cl100k_base encoding. When tiktoken is not installed it falls back
|
||||
to a character-based estimate (len(text) // 4) and says so, so the script always
|
||||
runs under a bare python3 even with no third-party packages present.
|
||||
|
||||
Usage:
|
||||
count_tokens.py <file> count the tokens in a file
|
||||
count_tokens.py --stdin count the tokens read from stdin
|
||||
|
||||
Output (one line of JSON on stdout):
|
||||
{"tokens": <int>, "method": "tiktoken"} when tiktoken loaded
|
||||
{"tokens": <int>, "method": "fallback"} when it fell back to chars // 4
|
||||
|
||||
Budgets this feeds: SKILL.md ~1500-2500, multi-branch reference ~4500,
|
||||
single-purpose reference ~9000.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
ENCODING = "cl100k_base"
|
||||
|
||||
|
||||
def count_tokens(text: str) -> tuple[int, str]:
|
||||
"""Return (token_count, method).
|
||||
|
||||
Tries tiktoken's cl100k_base encoding first. If tiktoken cannot be imported
|
||||
or initialized, estimates with len(text) // 4 and reports method "fallback".
|
||||
"""
|
||||
try:
|
||||
import tiktoken
|
||||
except Exception:
|
||||
return len(text) // 4, "fallback"
|
||||
try:
|
||||
enc = tiktoken.get_encoding(ENCODING)
|
||||
except Exception:
|
||||
return len(text) // 4, "fallback"
|
||||
return len(enc.encode(text)), "tiktoken"
|
||||
|
||||
|
||||
def read_input(args) -> str:
|
||||
if args.stdin:
|
||||
return sys.stdin.read()
|
||||
with open(args.file, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p.add_argument("file", nargs="?", help="path to the file to count")
|
||||
p.add_argument("--stdin", action="store_true", help="read text from stdin instead of a file")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if not args.stdin and not args.file:
|
||||
p.error("provide a file path or --stdin")
|
||||
if args.stdin and args.file:
|
||||
p.error("provide either a file path or --stdin, not both")
|
||||
|
||||
text = read_input(args)
|
||||
tokens, method = count_tokens(text)
|
||||
print(json.dumps({"tokens": tokens, "method": method}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["tiktoken"]
|
||||
# ///
|
||||
"""prepass — the Analyze pre-pass for the agent builder.
|
||||
|
||||
Reads an agent skill directory and emits one compact JSON object that every
|
||||
lens and the analyze orchestrator consume. The pre-pass does the one thing the
|
||||
lenses should not each redo: it classifies the agent along the three-point
|
||||
gradient (stateless, memory, autonomous), counts tokens for SKILL.md and every
|
||||
in-tree file, and sets the gate that turns the conditional sanctum lens on.
|
||||
|
||||
Detection rests on the sanctum, the built agent's runtime memory at
|
||||
`{project-root}/_bmad/memory/{skillName}/`. An agent that reloads a sanctum on
|
||||
waking is a memory agent; one that also carries live wake behavior (a PULSE
|
||||
file or a pulse/autonomous wake reference with named-task routing) is
|
||||
autonomous; one with no sanctum at all is stateless. This is the BUILT agent's
|
||||
memory, never the builder's process log (.memlog.md), and the two are kept
|
||||
apart here.
|
||||
|
||||
Lengths come from tokens, never line counts. The count uses count_tokens.py
|
||||
(imported as a sibling, then shelled out, then a chars // 4 fallback) so the
|
||||
metric matches the rest of the builder and runs under a bare python3.
|
||||
|
||||
Output contract (one line of JSON on stdout, the pinned prepass shape):
|
||||
{
|
||||
"agent_type": "stateless" | "memory" | "autonomous",
|
||||
"is_memory_agent": bool, # true for memory and autonomous
|
||||
"skill_md_tokens": int,
|
||||
"files": [{"path": str, "tokens": int}, ...]
|
||||
}
|
||||
|
||||
Read-only over the target agent directory. It opens files to count and classify
|
||||
and writes nothing inside the agent tree.
|
||||
|
||||
Usage:
|
||||
prepass.py <agent-dir> classify and count the agent at this directory
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# Directories we never descend into while counting agent files.
|
||||
SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv"}
|
||||
|
||||
# Extensions we treat as countable text. Binary or opaque assets are skipped.
|
||||
TEXT_SUFFIXES = {
|
||||
".md", ".py", ".toml", ".yaml", ".yml", ".json", ".txt",
|
||||
".csv", ".html", ".sh", ".cfg", ".ini",
|
||||
}
|
||||
|
||||
|
||||
# --- token counting ---------------------------------------------------------
|
||||
|
||||
def _count_via_import(text: str):
|
||||
"""Count tokens by importing the sibling count_tokens module."""
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
try:
|
||||
import count_tokens # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
tokens, _method = count_tokens.count_tokens(text)
|
||||
return int(tokens)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _count_via_shell(text: str):
|
||||
"""Count tokens by shelling out to count_tokens.py with text on stdin."""
|
||||
script = SCRIPT_DIR / "count_tokens.py"
|
||||
if not script.exists():
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script), "--stdin"],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return int(json.loads(proc.stdout)["tokens"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
"""Token length of text via count_tokens.py, falling back to chars // 4.
|
||||
|
||||
Prefers importing the vendored count_tokens module, then shelling out to it,
|
||||
then a bare character estimate so the pre-pass always produces a number.
|
||||
"""
|
||||
for counter in (_count_via_import, _count_via_shell):
|
||||
result = counter(text)
|
||||
if result is not None:
|
||||
return result
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
# --- agent classification ---------------------------------------------------
|
||||
|
||||
def iter_files(root: Path):
|
||||
"""Yield countable text files under root, skipping noise directories."""
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
||||
continue
|
||||
if path.suffix.lower() in TEXT_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def has_sanctum(root: Path, skill_text: str) -> bool:
|
||||
"""True when the agent reloads a runtime sanctum on waking (a memory agent).
|
||||
|
||||
The sanctum is the built agent's memory at `_bmad/memory/{skillName}/`. We
|
||||
treat any of these as a sanctum signal: the SKILL referencing that memory
|
||||
path, the Sacred-Truth / waking bootloader language, a wake or init-sanctum
|
||||
scaffolder, or the sanctum template assets (PERSONA / CREED / BOND / MEMORY
|
||||
/ INDEX / CAPABILITIES). This is the built agent's memory, distinct from the
|
||||
builder's .memlog.md, which is never a sanctum signal.
|
||||
"""
|
||||
if re.search(r"_bmad/memory/", skill_text):
|
||||
return True
|
||||
if re.search(r"\bsanctum\b", skill_text, re.IGNORECASE):
|
||||
return True
|
||||
if "Sacred Truth" in skill_text and re.search(r"\b(waking|wake)\b", skill_text, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
for pattern in ("scripts/wake*", "scripts/init-sanctum*"):
|
||||
for script in root.glob(pattern):
|
||||
if script.is_file():
|
||||
return True
|
||||
|
||||
sanctum_seed = re.compile(
|
||||
r"^(PERSONA|CREED|BOND|MEMORY|INDEX|CAPABILITIES)-template\.md$"
|
||||
)
|
||||
assets = root / "assets"
|
||||
if assets.is_dir():
|
||||
for asset in assets.iterdir():
|
||||
if asset.is_file() and sanctum_seed.match(asset.name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_autonomous_wake(root: Path, skill_text: str) -> bool:
|
||||
"""True when a memory agent also carries live autonomous wake behavior.
|
||||
|
||||
Autonomous is memory plus a PULSE-driven wake: a deployed PULSE.md, a
|
||||
pulse/autonomous-wake reference, or SKILL wake routing (named-task pulse
|
||||
routing, a default wake behavior, quiet hours, or a wake frequency).
|
||||
|
||||
The standard memory bootloader already names a Pulse Mode (`--pulse`) path
|
||||
that loads PULSE.md, and ships a PULSE template asset, in every memory
|
||||
agent. Those are seeds, not live wake behavior, so neither the bootloader's
|
||||
Pulse-Mode line nor a PULSE template asset counts here. The wake behavior
|
||||
must be deployed: a real PULSE.md, a wake reference file, or SKILL routing
|
||||
that names tasks or schedules a recurring wake.
|
||||
"""
|
||||
if (root / "PULSE.md").is_file():
|
||||
return True
|
||||
|
||||
refs = root / "references"
|
||||
if refs.is_dir():
|
||||
for ref in refs.iterdir():
|
||||
name = ref.name.lower()
|
||||
if ref.is_file() and ("pulse-wake" in name or "autonomous-wake" in name):
|
||||
return True
|
||||
|
||||
wake_signals = [
|
||||
r"--pulse:\{", # named-task pulse routing
|
||||
r"-p:\{", # short-flag named-task routing
|
||||
r"default pulse wake",
|
||||
r"default wake behavior",
|
||||
r"\bquiet hours\b",
|
||||
r"wake frequency",
|
||||
r"autonomous wake",
|
||||
]
|
||||
for pattern in wake_signals:
|
||||
if re.search(pattern, skill_text, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify(root: Path, skill_text: str) -> str:
|
||||
"""Return the agent_type along the gradient."""
|
||||
if not has_sanctum(root, skill_text):
|
||||
return "stateless"
|
||||
if has_autonomous_wake(root, skill_text):
|
||||
return "autonomous"
|
||||
return "memory"
|
||||
|
||||
|
||||
# --- main -------------------------------------------------------------------
|
||||
|
||||
def build_payload(root: Path) -> dict:
|
||||
skill_path = root / "SKILL.md"
|
||||
skill_text = read_text(skill_path) if skill_path.is_file() else ""
|
||||
|
||||
agent_type = classify(root, skill_text)
|
||||
is_memory_agent = agent_type in ("memory", "autonomous")
|
||||
|
||||
files = []
|
||||
skill_md_tokens = 0
|
||||
for path in iter_files(root):
|
||||
tokens = count_tokens(read_text(path))
|
||||
rel = path.relative_to(root).as_posix()
|
||||
files.append({"path": rel, "tokens": tokens})
|
||||
if path == skill_path:
|
||||
skill_md_tokens = tokens
|
||||
|
||||
return {
|
||||
"agent_type": agent_type,
|
||||
"is_memory_agent": is_memory_agent,
|
||||
"skill_md_tokens": skill_md_tokens,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p.add_argument("agent_dir", help="path to the agent skill directory to analyze")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
root = Path(args.agent_dir).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
p.error(f"not a directory: {root}")
|
||||
|
||||
print(json.dumps(build_payload(root)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Process BMad agent template files.
|
||||
|
||||
Performs deterministic variable substitution and conditional block processing
|
||||
on template files from assets/. Replaces {varName} placeholders with provided
|
||||
values and evaluates {if-X}...{/if-X} conditional blocks, keeping content
|
||||
when the condition is in the --true list and removing the entire block otherwise.
|
||||
|
||||
Any {if-X} or {/if-X} marker still present after processing is a defect (a
|
||||
malformed or mismatched block the emitted agent would ship verbatim): the
|
||||
script exits 3 and names the markers. Remaining {token} placeholders are
|
||||
reported in the --json metadata as tokens_remaining, not failed, because they
|
||||
may be runtime-resolution tokens such as {project-root} or {agent.<name>} —
|
||||
the builder judges that list against the build-time token set.
|
||||
"""
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# ///
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def process_conditionals(text: str, true_conditions: set[str]) -> tuple[str, list[str], list[str]]:
|
||||
"""Process {if-X}...{/if-X} conditional blocks, innermost first.
|
||||
|
||||
Returns (processed_text, conditions_true, conditions_false).
|
||||
"""
|
||||
conditions_true: list[str] = []
|
||||
conditions_false: list[str] = []
|
||||
|
||||
# Process innermost blocks first to handle nesting
|
||||
pattern = re.compile(
|
||||
r'\{if-([a-zA-Z0-9_-]+)\}(.*?)\{/if-\1\}',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
changed = True
|
||||
condition = match.group(1)
|
||||
inner = match.group(2)
|
||||
|
||||
if condition in true_conditions:
|
||||
# Keep the inner content, strip the markers
|
||||
# Remove a leading newline if the opening tag was on its own line
|
||||
replacement = inner
|
||||
if condition not in conditions_true:
|
||||
conditions_true.append(condition)
|
||||
else:
|
||||
# Remove the entire block
|
||||
replacement = ''
|
||||
if condition not in conditions_false:
|
||||
conditions_false.append(condition)
|
||||
|
||||
text = text[:match.start()] + replacement + text[match.end():]
|
||||
|
||||
# Clean up blank lines left by removed blocks: collapse 3+ consecutive
|
||||
# newlines down to 2 (one blank line)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text, conditions_true, conditions_false
|
||||
|
||||
|
||||
def process_variables(text: str, variables: dict[str, str]) -> tuple[str, list[str]]:
|
||||
"""Replace {varName} placeholders with provided values.
|
||||
|
||||
Only replaces variables that are in the provided mapping.
|
||||
Leaves unmatched {variables} untouched (they may be runtime config).
|
||||
|
||||
Returns (processed_text, list_of_substituted_var_names).
|
||||
"""
|
||||
substituted: list[str] = []
|
||||
|
||||
for name, value in variables.items():
|
||||
placeholder = '{' + name + '}'
|
||||
if placeholder in text:
|
||||
text = text.replace(placeholder, value)
|
||||
if name not in substituted:
|
||||
substituted.append(name)
|
||||
|
||||
return text, substituted
|
||||
|
||||
|
||||
def parse_var(s: str) -> tuple[str, str]:
|
||||
"""Parse a key=value string. Raises argparse error on bad format."""
|
||||
if '=' not in s:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Invalid variable format: '{s}' (expected key=value)"
|
||||
)
|
||||
key, _, value = s.partition('=')
|
||||
if not key:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Invalid variable format: '{s}' (empty key)"
|
||||
)
|
||||
return key, value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Process BMad agent template files with variable substitution and conditional blocks.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'template',
|
||||
help='Path to the template file to process',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-o', '--output',
|
||||
help='Write processed output to file (default: stdout)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--var',
|
||||
action='append',
|
||||
default=[],
|
||||
metavar='key=value',
|
||||
help='Variable substitution (repeatable). Example: --var skillName=my-agent',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--true',
|
||||
action='append',
|
||||
default=[],
|
||||
dest='true_conditions',
|
||||
metavar='CONDITION',
|
||||
help='Condition name to treat as true (repeatable). Example: --true pulse --true evolvable',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--json',
|
||||
action='store_true',
|
||||
dest='json_output',
|
||||
help='Output processing metadata as JSON to stderr',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse variables
|
||||
variables: dict[str, str] = {}
|
||||
for v in args.var:
|
||||
try:
|
||||
key, value = parse_var(v)
|
||||
except argparse.ArgumentTypeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
variables[key] = value
|
||||
|
||||
true_conditions = set(args.true_conditions)
|
||||
|
||||
# Read template
|
||||
try:
|
||||
with open(args.template, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Template file not found: {args.template}", file=sys.stderr)
|
||||
return 2
|
||||
except OSError as e:
|
||||
print(f"Error reading template: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Process: conditionals first, then variables
|
||||
content, conds_true, conds_false = process_conditionals(content, true_conditions)
|
||||
content, vars_substituted = process_variables(content, variables)
|
||||
|
||||
# Leftover conditional markers mean a malformed/mismatched block that
|
||||
# would ship verbatim in the emitted agent.
|
||||
leftover_markers = sorted(set(re.findall(r'\{/?if-[a-zA-Z0-9_-]+\}', content)))
|
||||
if leftover_markers:
|
||||
print(
|
||||
f"Error: leftover conditional markers after processing: {', '.join(leftover_markers)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
tokens_remaining = sorted(set(re.findall(r'\{[a-zA-Z][a-zA-Z0-9_.-]*\}', content)))
|
||||
|
||||
# Write output
|
||||
output_file = args.output
|
||||
try:
|
||||
if output_file:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
else:
|
||||
sys.stdout.write(content)
|
||||
except OSError as e:
|
||||
print(f"Error writing output: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# JSON metadata to stderr
|
||||
if args.json_output:
|
||||
metadata = {
|
||||
'processed': True,
|
||||
'output_file': output_file or '<stdout>',
|
||||
'vars_substituted': vars_substituted,
|
||||
'conditions_true': conds_true,
|
||||
'conditions_false': conds_false,
|
||||
'tokens_remaining': tokens_remaining,
|
||||
}
|
||||
print(json.dumps(metadata, indent=2), file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Render the analysis report deterministically from findings JSON.
|
||||
|
||||
Injects a validated findings JSON object into the report shell's
|
||||
report-data island and writes the self-contained HTML atomically.
|
||||
With --md, also writes a markdown rendering of the same data as the
|
||||
archival artifact.
|
||||
|
||||
Refuses (non-zero exit, message on stderr) when the JSON does not
|
||||
parse, fails shape validation, or still carries the shell's
|
||||
placeholder subject — a refused render means fix the findings file
|
||||
and re-run, never hand-edit the HTML.
|
||||
|
||||
Usage:
|
||||
uv run render_report.py <findings.json> --shell <report-shell.html> \
|
||||
-o <out.html> [--md <out.md>]
|
||||
|
||||
On success prints one JSON line: output paths, grade, and severity
|
||||
counts derived from the findings array.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SEVERITIES = ("critical", "high", "medium", "low")
|
||||
GRADES = ("excellent", "good", "fair", "poor")
|
||||
PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
|
||||
ISLAND_RE = re.compile(
|
||||
r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"render_report: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate(data: object) -> list[str]:
|
||||
"""Return a list of shape errors; empty list means valid."""
|
||||
if not isinstance(data, dict):
|
||||
return ["top level must be a JSON object"]
|
||||
errors: list[str] = []
|
||||
|
||||
subject = data.get("subject")
|
||||
if not isinstance(subject, str) or not subject.strip():
|
||||
errors.append('"subject" must be a non-empty string')
|
||||
elif PLACEHOLDER_SUBJECT in subject:
|
||||
errors.append(
|
||||
f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
|
||||
"this is the unfilled shell sample, not real findings"
|
||||
)
|
||||
|
||||
findings = data.get("findings")
|
||||
if not isinstance(findings, list):
|
||||
errors.append('"findings" must be an array (use [] for a clean pass)')
|
||||
else:
|
||||
for i, finding in enumerate(findings):
|
||||
if not isinstance(finding, dict):
|
||||
errors.append(f"findings[{i}] must be an object")
|
||||
|
||||
grade = data.get("grade")
|
||||
if grade is not None and grade not in GRADES:
|
||||
errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
|
||||
|
||||
for key in ("themes", "recommendations"):
|
||||
value = data.get(key)
|
||||
if value is not None and (
|
||||
not isinstance(value, list)
|
||||
or any(not isinstance(item, dict) for item in value)
|
||||
):
|
||||
errors.append(f'"{key}" must be an array of objects')
|
||||
|
||||
strengths = data.get("strengths")
|
||||
if strengths is not None and (
|
||||
not isinstance(strengths, list)
|
||||
or any(not isinstance(item, str) for item in strengths)
|
||||
):
|
||||
errors.append('"strengths" must be an array of strings')
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def severity_counts(findings: list[dict]) -> dict[str, int]:
|
||||
counts = {sev: 0 for sev in SEVERITIES}
|
||||
for finding in findings:
|
||||
sev = finding.get("severity")
|
||||
counts[sev if sev in counts else "low"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def inject(shell_html: str, data: dict) -> str:
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
# A "</" sequence inside a JSON string would close the script tag
|
||||
# early in the browser; "<\/" is the same string to JSON.parse.
|
||||
payload = payload.replace("</", "<\\/")
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
return match.group(1) + "\n" + payload + "\n" + match.group(3)
|
||||
|
||||
new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
|
||||
if count != 1:
|
||||
fail('shell has no <script id="report-data"> island to fill')
|
||||
return new_html
|
||||
|
||||
|
||||
def atomic_write(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _finding_lines(finding: dict, heading_level: str) -> list[str]:
|
||||
fid = str(finding.get("id", ""))
|
||||
title = str(finding.get("title", "(untitled finding)"))
|
||||
lines = [f"{heading_level} {fid} — {title}" if fid else f"{heading_level} {title}", ""]
|
||||
for key, label in (
|
||||
("lens", "Lens"),
|
||||
("location", "Location"),
|
||||
("evidence", "Evidence"),
|
||||
("recommendation", "Recommendation"),
|
||||
("proposed_smallest", "Proposed smallest"),
|
||||
("predicted_delta", "Predicted delta"),
|
||||
):
|
||||
value = finding.get(key)
|
||||
if value:
|
||||
value = f"`{value}`" if key == "location" else str(value)
|
||||
lines.append(f"- {label}: {value}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def render_md(data: dict) -> str:
|
||||
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
|
||||
by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
|
||||
counts = severity_counts(findings)
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append(f"# Analysis Report: {data.get('subject', '')}")
|
||||
lines.append("")
|
||||
meta = []
|
||||
if data.get("generated"):
|
||||
meta.append(f"Generated: {data['generated']}")
|
||||
if data.get("schema_version") is not None:
|
||||
meta.append(f"Schema: {data['schema_version']}")
|
||||
if meta:
|
||||
lines.append(" · ".join(meta))
|
||||
lines.append("")
|
||||
|
||||
if data.get("grade"):
|
||||
lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
|
||||
lines.append("")
|
||||
if data.get("verdict"):
|
||||
lines.append(f"> {data['verdict']}")
|
||||
lines.append("")
|
||||
summary = data.get("summary")
|
||||
if isinstance(summary, str) and summary:
|
||||
lines.append(summary)
|
||||
lines.append("")
|
||||
|
||||
lines.append("| Severity | Count |")
|
||||
lines.append("| --- | --- |")
|
||||
for sev in SEVERITIES:
|
||||
lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
|
||||
lines.append("")
|
||||
|
||||
themes = data.get("themes") or []
|
||||
if themes:
|
||||
lines.append("## Themes")
|
||||
lines.append("")
|
||||
for i, theme in enumerate(themes, 1):
|
||||
lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
|
||||
lines.append("")
|
||||
if theme.get("root_cause"):
|
||||
lines.append(f"- Root cause: {theme['root_cause']}")
|
||||
if theme.get("action"):
|
||||
lines.append(f"- Fix: {theme['action']}")
|
||||
ids = theme.get("finding_ids") or []
|
||||
if ids:
|
||||
lines.append("- Findings:")
|
||||
for fid in ids:
|
||||
finding = by_id.get(str(fid))
|
||||
if finding:
|
||||
loc = finding.get("location")
|
||||
suffix = f" — `{loc}`" if loc else ""
|
||||
lines.append(
|
||||
f" - `{fid}` {finding.get('title', '')}{suffix}"
|
||||
)
|
||||
else:
|
||||
lines.append(f" - `{fid}`")
|
||||
lines.append("")
|
||||
|
||||
strengths = data.get("strengths") or []
|
||||
if strengths:
|
||||
lines.append("## Strengths")
|
||||
lines.append("")
|
||||
for strength in strengths:
|
||||
lines.append(f"- {strength}")
|
||||
lines.append("")
|
||||
|
||||
recommendations = data.get("recommendations") or []
|
||||
if recommendations:
|
||||
lines.append("## Recommendations")
|
||||
lines.append("")
|
||||
for i, rec in enumerate(recommendations, 1):
|
||||
rank = rec.get("rank", i)
|
||||
resolves = rec.get("resolves")
|
||||
if isinstance(resolves, list) and resolves:
|
||||
suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
|
||||
elif isinstance(resolves, (int, float)):
|
||||
suffix = f" (resolves {int(resolves)} findings)"
|
||||
else:
|
||||
suffix = ""
|
||||
lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
|
||||
lines.append("")
|
||||
|
||||
# Optional agent blocks: rendered only when present so the same
|
||||
# renderer serves both the workflow and agent schemas.
|
||||
profile = data.get("agent_profile")
|
||||
if isinstance(profile, dict) and any(profile.values()):
|
||||
lines.append("## Agent Profile")
|
||||
lines.append("")
|
||||
for key, label in (
|
||||
("name", "Name"),
|
||||
("title", "Title"),
|
||||
("agent_type", "Type"),
|
||||
("mission", "Mission"),
|
||||
):
|
||||
if profile.get(key):
|
||||
lines.append(f"- {label}: {profile[key]}")
|
||||
lines.append("")
|
||||
|
||||
capabilities = data.get("capabilities")
|
||||
if isinstance(capabilities, list) and capabilities:
|
||||
lines.append("## Capabilities")
|
||||
lines.append("")
|
||||
for cap in capabilities:
|
||||
if not isinstance(cap, dict) or not cap.get("name"):
|
||||
continue
|
||||
kind = f" ({cap['kind']})" if cap.get("kind") else ""
|
||||
note = f" — {cap['note']}" if cap.get("note") else ""
|
||||
lines.append(f"- **{cap['name']}**{kind}{note}")
|
||||
lines.append("")
|
||||
|
||||
detailed = data.get("detailed_analysis")
|
||||
if isinstance(detailed, dict) and detailed:
|
||||
lines.append("## Per-Lens Verdicts")
|
||||
lines.append("")
|
||||
for lens, verdict in detailed.items():
|
||||
if verdict:
|
||||
lines.append(f"- **{lens}**: {verdict}")
|
||||
lines.append("")
|
||||
|
||||
sanctum = data.get("sanctum")
|
||||
if isinstance(sanctum, dict) and sanctum.get("present") is not False:
|
||||
rows = []
|
||||
if sanctum.get("location"):
|
||||
rows.append(f"- Location: `{sanctum['location']}`")
|
||||
files = sanctum.get("files") or []
|
||||
if files:
|
||||
rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
|
||||
if sanctum.get("note"):
|
||||
rows.append(f"- Note: {sanctum['note']}")
|
||||
if rows:
|
||||
lines.append("## Sanctum (runtime memory)")
|
||||
lines.append("")
|
||||
lines.extend(rows)
|
||||
lines.append("")
|
||||
|
||||
experience = data.get("experience")
|
||||
if isinstance(experience, dict):
|
||||
journeys = [
|
||||
j for j in experience.get("journeys") or [] if isinstance(j, dict)
|
||||
]
|
||||
headless = experience.get("headless")
|
||||
if journeys or headless:
|
||||
lines.append("## Experience")
|
||||
lines.append("")
|
||||
for journey in journeys:
|
||||
steps = f" — {journey['steps']}" if journey.get("steps") else ""
|
||||
lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
|
||||
if headless:
|
||||
lines.append(f"- Headless: {headless}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Findings")
|
||||
lines.append("")
|
||||
if not findings:
|
||||
lines.append("No findings: the scanners returned a clean pass.")
|
||||
lines.append("")
|
||||
else:
|
||||
for sev in SEVERITIES:
|
||||
group = [
|
||||
f
|
||||
for f in findings
|
||||
if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
|
||||
== sev
|
||||
]
|
||||
if not group:
|
||||
continue
|
||||
lines.append(f"### {sev.capitalize()} ({len(group)})")
|
||||
lines.append("")
|
||||
for finding in group:
|
||||
lines.extend(_finding_lines(finding, "####"))
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
|
||||
)
|
||||
parser.add_argument("findings", type=Path, help="path to findings.json")
|
||||
parser.add_argument(
|
||||
"--shell", type=Path, required=True, help="path to report-shell.html"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", type=Path, required=True, help="output HTML path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--md", type=Path, help="also write a markdown rendering to this path"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
raw = args.findings.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
fail(f"cannot read {args.findings}: {err}")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as err:
|
||||
fail(f"{args.findings} is not valid JSON: {err}")
|
||||
|
||||
errors = validate(data)
|
||||
if errors:
|
||||
fail(
|
||||
f"{args.findings} failed shape validation:\n - "
|
||||
+ "\n - ".join(errors)
|
||||
)
|
||||
|
||||
try:
|
||||
shell_html = args.shell.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
fail(f"cannot read shell {args.shell}: {err}")
|
||||
|
||||
atomic_write(args.output, inject(shell_html, data))
|
||||
if args.md:
|
||||
atomic_write(args.md, render_md(data))
|
||||
|
||||
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"html_report": str(args.output),
|
||||
"md_report": str(args.md) if args.md else None,
|
||||
"grade": data.get("grade"),
|
||||
"counts": severity_counts(findings),
|
||||
"findings": len(findings),
|
||||
}
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic path standards scanner for BMad skills.
|
||||
|
||||
Validates all .md and .json files against BMad path conventions:
|
||||
1. {project-root} for any project-scope path (not just _bmad)
|
||||
2. Bare _bmad references must have {project-root} prefix
|
||||
3. Config variables used directly — no double-prefix with {project-root}
|
||||
4. ./ only for same-folder references — never ./subdir/ cross-directory
|
||||
5. No ../ parent directory references
|
||||
6. No absolute paths
|
||||
7. Memory paths must use {project-root}/_bmad/memory/{skillName}/
|
||||
8. Frontmatter allows only name and description
|
||||
9. No .md files at skill root except SKILL.md
|
||||
"""
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# ///
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Patterns to detect
|
||||
# Double-prefix: {project-root}/{config-variable} — config vars already contain project-root
|
||||
DOUBLE_PREFIX_RE = re.compile(r'\{project-root\}/\{[^}]+\}')
|
||||
# Bare _bmad without {project-root} prefix — match _bmad at word boundary
|
||||
# but not when preceded by {project-root}/
|
||||
BARE_BMAD_RE = re.compile(r'(?<!\{project-root\}/)_bmad[/\s]')
|
||||
# Absolute paths
|
||||
ABSOLUTE_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(/(?:Users|home|opt|var|tmp|etc|usr)/\S+)', re.MULTILINE)
|
||||
HOME_PATH_RE = re.compile(r'(?:^|[\s"`\'(])(~/\S+)', re.MULTILINE)
|
||||
# Parent directory reference (still invalid)
|
||||
RELATIVE_DOT_RE = re.compile(r'(?:^|[\s"`\'(])(\.\./\S+)', re.MULTILINE)
|
||||
# Cross-directory ./ — ./subdir/ is wrong because ./ means same folder only
|
||||
CROSS_DIR_DOT_SLASH_RE = re.compile(r'(?:^|[\s"`\'(])\./(?:references|scripts|assets)/\S+', re.MULTILINE)
|
||||
|
||||
# Memory path pattern: should use {project-root}/_bmad/memory/
|
||||
MEMORY_PATH_RE = re.compile(r'_bmad/memory/\S+')
|
||||
VALID_MEMORY_PATH_RE = re.compile(r'\{project-root\}/_bmad/memory/[\w-]+/')
|
||||
|
||||
# Fenced code block detection (to skip examples showing wrong patterns)
|
||||
FENCE_RE = re.compile(r'^```', re.MULTILINE)
|
||||
|
||||
# Valid frontmatter keys
|
||||
VALID_FRONTMATTER_KEYS = {'name', 'description'}
|
||||
|
||||
|
||||
def is_in_fenced_block(content: str, pos: int) -> bool:
|
||||
"""Check if a position is inside a fenced code block."""
|
||||
fences = [m.start() for m in FENCE_RE.finditer(content[:pos])]
|
||||
# Odd number of fences before pos means we're inside a block
|
||||
return len(fences) % 2 == 1
|
||||
|
||||
|
||||
def get_line_number(content: str, pos: int) -> int:
|
||||
"""Get 1-based line number for a position in content."""
|
||||
return content[:pos].count('\n') + 1
|
||||
|
||||
|
||||
def check_frontmatter(content: str, filepath: Path) -> list[dict]:
|
||||
"""Validate SKILL.md frontmatter contains only allowed keys."""
|
||||
findings = []
|
||||
if filepath.name != 'SKILL.md':
|
||||
return findings
|
||||
|
||||
if not content.startswith('---'):
|
||||
findings.append({
|
||||
'file': filepath.name,
|
||||
'line': 1,
|
||||
'severity': 'critical',
|
||||
'category': 'frontmatter',
|
||||
'title': 'SKILL.md missing frontmatter block',
|
||||
'detail': 'SKILL.md must start with --- frontmatter containing name and description',
|
||||
'action': 'Add frontmatter with name and description fields',
|
||||
})
|
||||
return findings
|
||||
|
||||
# Find closing ---
|
||||
end = content.find('\n---', 3)
|
||||
if end == -1:
|
||||
findings.append({
|
||||
'file': filepath.name,
|
||||
'line': 1,
|
||||
'severity': 'critical',
|
||||
'category': 'frontmatter',
|
||||
'title': 'SKILL.md frontmatter block not closed',
|
||||
'detail': 'Missing closing --- for frontmatter',
|
||||
'action': 'Add closing --- after frontmatter fields',
|
||||
})
|
||||
return findings
|
||||
|
||||
frontmatter = content[4:end]
|
||||
for i, line in enumerate(frontmatter.split('\n'), start=2):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if ':' in line:
|
||||
key = line.split(':', 1)[0].strip()
|
||||
if key not in VALID_FRONTMATTER_KEYS:
|
||||
findings.append({
|
||||
'file': filepath.name,
|
||||
'line': i,
|
||||
'severity': 'high',
|
||||
'category': 'frontmatter',
|
||||
'title': f'Invalid frontmatter key: {key}',
|
||||
'detail': f'Only {", ".join(sorted(VALID_FRONTMATTER_KEYS))} are allowed in frontmatter',
|
||||
'action': f'Remove {key} from frontmatter — use as content field in SKILL.md body instead',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_root_md_files(skill_path: Path) -> list[dict]:
|
||||
"""Check that no .md files exist at skill root except SKILL.md."""
|
||||
findings = []
|
||||
for md_file in skill_path.glob('*.md'):
|
||||
if md_file.name != 'SKILL.md':
|
||||
findings.append({
|
||||
'file': md_file.name,
|
||||
'line': 0,
|
||||
'severity': 'high',
|
||||
'category': 'structure',
|
||||
'title': f'Prompt file at skill root: {md_file.name}',
|
||||
'detail': 'All progressive disclosure content must be in ./references/ — only SKILL.md belongs at root',
|
||||
'action': f'Move {md_file.name} to references/{md_file.name}',
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def scan_file(filepath: Path, skip_fenced: bool = True) -> list[dict]:
|
||||
"""Scan a single file for path standard violations."""
|
||||
findings = []
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
rel_path = filepath.name
|
||||
|
||||
checks = [
|
||||
(DOUBLE_PREFIX_RE, 'double-prefix', 'critical',
|
||||
'Double-prefix: {project-root}/{variable} — config variables already contain {project-root} at runtime'),
|
||||
(ABSOLUTE_PATH_RE, 'absolute-path', 'high',
|
||||
'Absolute path found — not portable across machines'),
|
||||
(HOME_PATH_RE, 'absolute-path', 'high',
|
||||
'Home directory path (~/) found — environment-specific'),
|
||||
(RELATIVE_DOT_RE, 'relative-prefix', 'high',
|
||||
'Parent directory reference (../) found — fragile, breaks with reorganization'),
|
||||
(CROSS_DIR_DOT_SLASH_RE, 'cross-dir-dot-slash', 'high',
|
||||
'Cross-directory ./ reference — ./ means same folder only; use bare skill-root relative path (e.g., references/foo.md not ./references/foo.md)'),
|
||||
]
|
||||
|
||||
for pattern, category, severity, message in checks:
|
||||
for match in pattern.finditer(content):
|
||||
pos = match.start()
|
||||
if skip_fenced and is_in_fenced_block(content, pos):
|
||||
continue
|
||||
line_num = get_line_number(content, pos)
|
||||
line_content = content.split('\n')[line_num - 1].strip()
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': line_num,
|
||||
'severity': severity,
|
||||
'category': category,
|
||||
'title': message,
|
||||
'detail': line_content[:120],
|
||||
'action': '',
|
||||
})
|
||||
|
||||
# Bare _bmad check — more nuanced, need to avoid false positives
|
||||
# inside {project-root}/_bmad which is correct
|
||||
for match in BARE_BMAD_RE.finditer(content):
|
||||
pos = match.start()
|
||||
if skip_fenced and is_in_fenced_block(content, pos):
|
||||
continue
|
||||
start = max(0, pos - 30)
|
||||
before = content[start:pos]
|
||||
if '{project-root}/' in before:
|
||||
continue
|
||||
line_num = get_line_number(content, pos)
|
||||
line_content = content.split('\n')[line_num - 1].strip()
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': line_num,
|
||||
'severity': 'high',
|
||||
'category': 'bare-bmad',
|
||||
'title': 'Bare _bmad reference without {project-root} prefix',
|
||||
'detail': line_content[:120],
|
||||
'action': '',
|
||||
})
|
||||
|
||||
# Memory path check — memory paths should use {project-root}/_bmad/memory/{skillName}/
|
||||
for match in MEMORY_PATH_RE.finditer(content):
|
||||
pos = match.start()
|
||||
if skip_fenced and is_in_fenced_block(content, pos):
|
||||
continue
|
||||
start = max(0, pos - 20)
|
||||
before = content[start:pos]
|
||||
if '{project-root}/' not in before:
|
||||
line_num = get_line_number(content, pos)
|
||||
line_content = content.split('\n')[line_num - 1].strip()
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': line_num,
|
||||
'severity': 'high',
|
||||
'category': 'memory-path',
|
||||
'title': 'Memory path missing {project-root} prefix — use {project-root}/_bmad/memory/',
|
||||
'detail': line_content[:120],
|
||||
'action': '',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def scan_skill(skill_path: Path, skip_fenced: bool = True) -> dict:
|
||||
"""Scan all .md and .json files in a skill directory."""
|
||||
all_findings = []
|
||||
|
||||
# Check for .md files at root that aren't SKILL.md
|
||||
all_findings.extend(check_root_md_files(skill_path))
|
||||
|
||||
# Check SKILL.md frontmatter
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
if skill_md.exists():
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
all_findings.extend(check_frontmatter(content, skill_md))
|
||||
|
||||
# Find all .md and .json files
|
||||
md_files = sorted(list(skill_path.rglob('*.md')) + list(skill_path.rglob('*.json')))
|
||||
if not md_files:
|
||||
print(f"Warning: No .md or .json files found in {skill_path}", file=sys.stderr)
|
||||
|
||||
files_scanned = []
|
||||
for md_file in md_files:
|
||||
rel = md_file.relative_to(skill_path)
|
||||
files_scanned.append(str(rel))
|
||||
file_findings = scan_file(md_file, skip_fenced)
|
||||
for f in file_findings:
|
||||
f['file'] = str(rel)
|
||||
all_findings.extend(file_findings)
|
||||
|
||||
# Build summary
|
||||
by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
||||
by_category = {
|
||||
'double_prefix': 0,
|
||||
'bare_bmad': 0,
|
||||
'absolute_path': 0,
|
||||
'relative_prefix': 0,
|
||||
'cross_dir_dot_slash': 0,
|
||||
'memory_path': 0,
|
||||
'frontmatter': 0,
|
||||
'structure': 0,
|
||||
}
|
||||
|
||||
for f in all_findings:
|
||||
sev = f['severity']
|
||||
if sev in by_severity:
|
||||
by_severity[sev] += 1
|
||||
cat = f['category'].replace('-', '_')
|
||||
if cat in by_category:
|
||||
by_category[cat] += 1
|
||||
|
||||
return {
|
||||
'scanner': 'path-standards',
|
||||
'script': 'scan-path-standards.py',
|
||||
'version': '3.0.0',
|
||||
'skill_path': str(skill_path),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'files_scanned': files_scanned,
|
||||
'status': 'pass' if not all_findings else 'fail',
|
||||
'findings': all_findings,
|
||||
'assessments': {},
|
||||
'summary': {
|
||||
'total_findings': len(all_findings),
|
||||
'by_severity': by_severity,
|
||||
'by_category': by_category,
|
||||
'assessment': 'Path standards scan complete',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Scan BMad skill for path standard violations',
|
||||
)
|
||||
parser.add_argument(
|
||||
'skill_path',
|
||||
type=Path,
|
||||
help='Path to the skill directory to scan',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output', '-o',
|
||||
type=Path,
|
||||
help='Write JSON output to file instead of stdout',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--include-fenced',
|
||||
action='store_true',
|
||||
help='Also check inside fenced code blocks (by default they are skipped)',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.skill_path.is_dir():
|
||||
print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = scan_skill(args.skill_path, skip_fenced=not args.include_fenced)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(output)
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
return 0 if result['status'] == 'pass' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic scripts scanner for BMad skills.
|
||||
|
||||
Validates scripts in a skill's scripts/ folder for:
|
||||
- PEP 723 inline dependencies (Python)
|
||||
- Shebang, set -e, portability (Shell)
|
||||
- Version pinning for npx/uvx
|
||||
- Agentic design: no input(), has argparse/--help, JSON output, exit codes
|
||||
- Unit test existence
|
||||
- Over-engineering signals (line count, simple-op imports)
|
||||
- External lint: ruff (Python), shellcheck (Bash), biome (JS/TS)
|
||||
"""
|
||||
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# ///
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# External Linter Integration
|
||||
# =============================================================================
|
||||
|
||||
def _run_command(cmd: list[str], timeout: int = 30) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except FileNotFoundError:
|
||||
return -1, '', f'Command not found: {cmd[0]}'
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, '', f'Command timed out after {timeout}s: {" ".join(cmd)}'
|
||||
|
||||
|
||||
def _find_uv() -> str | None:
|
||||
"""Find uv binary on PATH."""
|
||||
return shutil.which('uv')
|
||||
|
||||
|
||||
def _find_npx() -> str | None:
|
||||
"""Find npx binary on PATH."""
|
||||
return shutil.which('npx')
|
||||
|
||||
|
||||
def lint_python_ruff(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Run ruff on a Python file via uv. Returns lint findings."""
|
||||
uv = _find_uv()
|
||||
if not uv:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': 'uv not found on PATH — cannot run ruff for Python linting',
|
||||
'detail': '',
|
||||
'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
|
||||
}]
|
||||
|
||||
rc, stdout, stderr = _run_command([
|
||||
uv, 'run', 'ruff', 'check', '--output-format', 'json', str(filepath),
|
||||
])
|
||||
|
||||
if rc == -1:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': f'Failed to run ruff via uv: {stderr.strip()}',
|
||||
'detail': '',
|
||||
'action': 'Ensure uv can install and run ruff: uv run ruff --version',
|
||||
}]
|
||||
|
||||
if rc == -2:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'ruff timed out on {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
# ruff outputs JSON array on stdout (even on rc=1 when issues found)
|
||||
findings = []
|
||||
try:
|
||||
issues = json.loads(stdout) if stdout.strip() else []
|
||||
except json.JSONDecodeError:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'Failed to parse ruff output for {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
for issue in issues:
|
||||
fix_msg = issue.get('fix', {}).get('message', '') if issue.get('fix') else ''
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': issue.get('location', {}).get('row', 0),
|
||||
'severity': 'high',
|
||||
'category': 'lint',
|
||||
'title': f'[{issue.get("code", "?")}] {issue.get("message", "")}',
|
||||
'detail': '',
|
||||
'action': fix_msg or f'See https://docs.astral.sh/ruff/rules/{issue.get("code", "")}',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def lint_shell_shellcheck(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Run shellcheck on a shell script via uv. Returns lint findings."""
|
||||
uv = _find_uv()
|
||||
if not uv:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': 'uv not found on PATH — cannot run shellcheck for shell linting',
|
||||
'detail': '',
|
||||
'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
|
||||
}]
|
||||
|
||||
rc, stdout, stderr = _run_command([
|
||||
uv, 'run', '--with', 'shellcheck-py',
|
||||
'shellcheck', '--format', 'json', str(filepath),
|
||||
])
|
||||
|
||||
if rc == -1:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': f'Failed to run shellcheck via uv: {stderr.strip()}',
|
||||
'detail': '',
|
||||
'action': 'Ensure uv can install shellcheck-py: uv run --with shellcheck-py shellcheck --version',
|
||||
}]
|
||||
|
||||
if rc == -2:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'shellcheck timed out on {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
findings = []
|
||||
# shellcheck outputs JSON on stdout (rc=1 when issues found)
|
||||
raw = stdout.strip() or stderr.strip()
|
||||
try:
|
||||
issues = json.loads(raw) if raw else []
|
||||
except json.JSONDecodeError:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'Failed to parse shellcheck output for {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
# Map shellcheck levels to our severity
|
||||
level_map = {'error': 'high', 'warning': 'high', 'info': 'high', 'style': 'medium'}
|
||||
|
||||
for issue in issues:
|
||||
sc_code = issue.get('code', '')
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': issue.get('line', 0),
|
||||
'severity': level_map.get(issue.get('level', ''), 'high'),
|
||||
'category': 'lint',
|
||||
'title': f'[SC{sc_code}] {issue.get("message", "")}',
|
||||
'detail': '',
|
||||
'action': f'See https://www.shellcheck.net/wiki/SC{sc_code}',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def lint_node_biome(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Run biome on a JS/TS file via npx. Returns lint findings."""
|
||||
npx = _find_npx()
|
||||
if not npx:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': 'npx not found on PATH — cannot run biome for JS/TS linting',
|
||||
'detail': '',
|
||||
'action': 'Install Node.js 20+: https://nodejs.org/',
|
||||
}]
|
||||
|
||||
rc, stdout, stderr = _run_command([
|
||||
npx, '--yes', '@biomejs/biome', 'lint', '--reporter', 'json', str(filepath),
|
||||
], timeout=60)
|
||||
|
||||
if rc == -1:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'high', 'category': 'lint-setup',
|
||||
'title': f'Failed to run biome via npx: {stderr.strip()}',
|
||||
'detail': '',
|
||||
'action': 'Ensure npx can run biome: npx @biomejs/biome --version',
|
||||
}]
|
||||
|
||||
if rc == -2:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'biome timed out on {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
findings = []
|
||||
# biome outputs JSON on stdout
|
||||
raw = stdout.strip()
|
||||
try:
|
||||
result = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
return [{
|
||||
'file': rel_path, 'line': 0,
|
||||
'severity': 'medium', 'category': 'lint',
|
||||
'title': f'Failed to parse biome output for {rel_path}',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}]
|
||||
|
||||
for diag in result.get('diagnostics', []):
|
||||
loc = diag.get('location', {})
|
||||
start = loc.get('start', {})
|
||||
findings.append({
|
||||
'file': rel_path,
|
||||
'line': start.get('line', 0),
|
||||
'severity': 'high',
|
||||
'category': 'lint',
|
||||
'title': f'[{diag.get("category", "?")}] {diag.get("message", "")}',
|
||||
'detail': '',
|
||||
'action': diag.get('advices', [{}])[0].get('message', '') if diag.get('advices') else '',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BMad Pattern Checks (Existing)
|
||||
# =============================================================================
|
||||
|
||||
def scan_python_script(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Check a Python script for standards compliance."""
|
||||
findings = []
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
lines = content.split('\n')
|
||||
line_count = len(lines)
|
||||
|
||||
# PEP 723 check
|
||||
if '# /// script' not in content:
|
||||
# Only flag if the script has imports (not a trivial script)
|
||||
if 'import ' in content:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'dependencies',
|
||||
'title': 'No PEP 723 inline dependency block (# /// script)',
|
||||
'detail': '',
|
||||
'action': 'Add PEP 723 block with requires-python and dependencies',
|
||||
})
|
||||
else:
|
||||
# Check requires-python is present
|
||||
if 'requires-python' not in content:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'low', 'category': 'dependencies',
|
||||
'title': 'PEP 723 block exists but missing requires-python constraint',
|
||||
'detail': '',
|
||||
'action': 'Add requires-python = ">=3.9" or appropriate version',
|
||||
})
|
||||
|
||||
# Legacy dep-management reference (use concatenation to avoid self-detection)
|
||||
req_marker = 'requirements' + '.txt'
|
||||
pip_marker = 'pip ' + 'install'
|
||||
if req_marker in content or pip_marker in content:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'high', 'category': 'dependencies',
|
||||
'title': f'References {req_marker} or {pip_marker} — use PEP 723 inline deps',
|
||||
'detail': '',
|
||||
'action': 'Replace with PEP 723 inline dependency block',
|
||||
})
|
||||
|
||||
# Agentic design checks via AST
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'critical', 'category': 'error-handling',
|
||||
'title': 'Python syntax error — script cannot be parsed',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
})
|
||||
return findings
|
||||
|
||||
has_argparse = False
|
||||
has_json_dumps = False
|
||||
has_sys_exit = False
|
||||
imports = set()
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# Track imports
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imports.add(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
imports.add(node.module)
|
||||
|
||||
# input() calls
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == 'input':
|
||||
findings.append({
|
||||
'file': rel_path, 'line': node.lineno,
|
||||
'severity': 'critical', 'category': 'agentic-design',
|
||||
'title': 'input() call found — blocks in non-interactive agent execution',
|
||||
'detail': '',
|
||||
'action': 'Use argparse with required flags instead of interactive prompts',
|
||||
})
|
||||
# json.dumps
|
||||
if isinstance(func, ast.Attribute) and func.attr == 'dumps':
|
||||
has_json_dumps = True
|
||||
# sys.exit
|
||||
if isinstance(func, ast.Attribute) and func.attr == 'exit':
|
||||
has_sys_exit = True
|
||||
if isinstance(func, ast.Name) and func.id == 'exit':
|
||||
has_sys_exit = True
|
||||
|
||||
# argparse
|
||||
if isinstance(node, ast.Attribute) and node.attr == 'ArgumentParser':
|
||||
has_argparse = True
|
||||
|
||||
if not has_argparse and line_count > 20:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'agentic-design',
|
||||
'title': 'No argparse found — script lacks --help self-documentation',
|
||||
'detail': '',
|
||||
'action': 'Add argparse with description and argument help text',
|
||||
})
|
||||
|
||||
if not has_json_dumps and line_count > 20:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'agentic-design',
|
||||
'title': 'No json.dumps found — output may not be structured JSON',
|
||||
'detail': '',
|
||||
'action': 'Use json.dumps for structured output parseable by workflows',
|
||||
})
|
||||
|
||||
if not has_sys_exit and line_count > 20:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'low', 'category': 'agentic-design',
|
||||
'title': 'No sys.exit() calls — may not return meaningful exit codes',
|
||||
'detail': '',
|
||||
'action': 'Return 0=success, 1=fail, 2=error via sys.exit()',
|
||||
})
|
||||
|
||||
# Over-engineering: simple file ops in Python
|
||||
simple_op_imports = {'shutil', 'glob', 'fnmatch'}
|
||||
over_eng = imports & simple_op_imports
|
||||
if over_eng and line_count < 30:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'low', 'category': 'over-engineered',
|
||||
'title': f'Short script ({line_count} lines) imports {", ".join(over_eng)} — may be simpler as bash',
|
||||
'detail': '',
|
||||
'action': 'Consider if cp/mv/find shell commands would suffice',
|
||||
})
|
||||
|
||||
# Very short script
|
||||
if line_count < 5:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'over-engineered',
|
||||
'title': f'Script is only {line_count} lines — could be an inline command',
|
||||
'detail': '',
|
||||
'action': 'Consider inlining this command directly in the prompt',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def scan_shell_script(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Check a shell script for standards compliance."""
|
||||
findings = []
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
lines = content.split('\n')
|
||||
line_count = len(lines)
|
||||
|
||||
# Shebang
|
||||
if not lines[0].startswith('#!'):
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'high', 'category': 'portability',
|
||||
'title': 'Missing shebang line',
|
||||
'detail': '',
|
||||
'action': 'Add #!/usr/bin/env bash or #!/usr/bin/env sh',
|
||||
})
|
||||
elif '/usr/bin/env' not in lines[0]:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'portability',
|
||||
'title': f'Shebang uses hardcoded path: {lines[0].strip()}',
|
||||
'detail': '',
|
||||
'action': 'Use #!/usr/bin/env bash for cross-platform compatibility',
|
||||
})
|
||||
|
||||
# set -e
|
||||
if 'set -e' not in content and 'set -euo' not in content:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'error-handling',
|
||||
'title': 'Missing set -e — errors will be silently ignored',
|
||||
'detail': '',
|
||||
'action': 'Add set -e (or set -euo pipefail) near the top',
|
||||
})
|
||||
|
||||
# Hardcoded interpreter paths
|
||||
hardcoded_re = re.compile(r'/usr/bin/(python|ruby|node|perl)\b')
|
||||
for i, line in enumerate(lines, 1):
|
||||
if hardcoded_re.search(line):
|
||||
findings.append({
|
||||
'file': rel_path, 'line': i,
|
||||
'severity': 'medium', 'category': 'portability',
|
||||
'title': f'Hardcoded interpreter path: {line.strip()}',
|
||||
'detail': '',
|
||||
'action': 'Use /usr/bin/env or PATH-based lookup',
|
||||
})
|
||||
|
||||
# GNU-only tools
|
||||
gnu_re = re.compile(r'\b(gsed|gawk|ggrep|gfind)\b')
|
||||
for i, line in enumerate(lines, 1):
|
||||
m = gnu_re.search(line)
|
||||
if m:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': i,
|
||||
'severity': 'medium', 'category': 'portability',
|
||||
'title': f'GNU-only tool: {m.group()} — not available on all platforms',
|
||||
'detail': '',
|
||||
'action': 'Use POSIX-compatible equivalent',
|
||||
})
|
||||
|
||||
# Unquoted variables (basic check)
|
||||
unquoted_re = re.compile(r'(?<!")\$\w+(?!")')
|
||||
for i, line in enumerate(lines, 1):
|
||||
if line.strip().startswith('#'):
|
||||
continue
|
||||
for m in unquoted_re.finditer(line):
|
||||
# Skip inside double-quoted strings (rough heuristic)
|
||||
before = line[:m.start()]
|
||||
if before.count('"') % 2 == 1:
|
||||
continue
|
||||
findings.append({
|
||||
'file': rel_path, 'line': i,
|
||||
'severity': 'low', 'category': 'portability',
|
||||
'title': f'Potentially unquoted variable: {m.group()} — breaks with spaces in paths',
|
||||
'detail': '',
|
||||
'action': f'Use "{m.group()}" with double quotes',
|
||||
})
|
||||
|
||||
# npx/uvx without version pinning
|
||||
no_pin_re = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
|
||||
for i, line in enumerate(lines, 1):
|
||||
if line.strip().startswith('#'):
|
||||
continue
|
||||
m = no_pin_re.search(line)
|
||||
if m:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': i,
|
||||
'severity': 'medium', 'category': 'dependencies',
|
||||
'title': f'{m.group(1)} {m.group(2)} without version pinning',
|
||||
'detail': '',
|
||||
'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
|
||||
})
|
||||
|
||||
# Very short script
|
||||
if line_count < 5:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'over-engineered',
|
||||
'title': f'Script is only {line_count} lines — could be an inline command',
|
||||
'detail': '',
|
||||
'action': 'Consider inlining this command directly in the prompt',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def scan_node_script(filepath: Path, rel_path: str) -> list[dict]:
|
||||
"""Check a JS/TS script for standards compliance."""
|
||||
findings = []
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
lines = content.split('\n')
|
||||
line_count = len(lines)
|
||||
|
||||
# npx/uvx without version pinning
|
||||
no_pin = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
|
||||
for i, line in enumerate(lines, 1):
|
||||
m = no_pin.search(line)
|
||||
if m:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': i,
|
||||
'severity': 'medium', 'category': 'dependencies',
|
||||
'title': f'{m.group(1)} {m.group(2)} without version pinning',
|
||||
'detail': '',
|
||||
'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
|
||||
})
|
||||
|
||||
# Very short script
|
||||
if line_count < 5:
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'over-engineered',
|
||||
'title': f'Script is only {line_count} lines — could be an inline command',
|
||||
'detail': '',
|
||||
'action': 'Consider inlining this command directly in the prompt',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Scanner
|
||||
# =============================================================================
|
||||
|
||||
def scan_skill_scripts(skill_path: Path) -> dict:
|
||||
"""Scan all scripts in a skill directory."""
|
||||
scripts_dir = skill_path / 'scripts'
|
||||
all_findings = []
|
||||
lint_findings = []
|
||||
script_inventory = {'python': [], 'shell': [], 'node': [], 'other': []}
|
||||
missing_tests = []
|
||||
|
||||
if not scripts_dir.exists():
|
||||
return {
|
||||
'scanner': 'scripts',
|
||||
'script': 'scan-scripts.py',
|
||||
'version': '2.0.0',
|
||||
'skill_path': str(skill_path),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'status': 'pass',
|
||||
'findings': [{
|
||||
'file': 'scripts/',
|
||||
'severity': 'info',
|
||||
'category': 'none',
|
||||
'title': 'No scripts/ directory found — nothing to scan',
|
||||
'detail': '',
|
||||
'action': '',
|
||||
}],
|
||||
'assessments': {
|
||||
'lint_summary': {
|
||||
'tools_used': [],
|
||||
'files_linted': 0,
|
||||
'lint_issues': 0,
|
||||
},
|
||||
'script_summary': {
|
||||
'total_scripts': 0,
|
||||
'by_type': script_inventory,
|
||||
'missing_tests': [],
|
||||
},
|
||||
},
|
||||
'summary': {
|
||||
'total_findings': 0,
|
||||
'by_severity': {'critical': 0, 'high': 0, 'medium': 0, 'low': 0},
|
||||
'assessment': '',
|
||||
},
|
||||
}
|
||||
|
||||
# Find all script files (exclude tests/ and __pycache__)
|
||||
script_files = []
|
||||
for f in sorted(scripts_dir.iterdir()):
|
||||
if f.is_file() and f.suffix in ('.py', '.sh', '.bash', '.js', '.ts', '.mjs'):
|
||||
script_files.append(f)
|
||||
|
||||
tests_dir = scripts_dir / 'tests'
|
||||
lint_tools_used = set()
|
||||
|
||||
for script_file in script_files:
|
||||
rel_path = f'scripts/{script_file.name}'
|
||||
ext = script_file.suffix
|
||||
|
||||
if ext == '.py':
|
||||
script_inventory['python'].append(script_file.name)
|
||||
findings = scan_python_script(script_file, rel_path)
|
||||
lf = lint_python_ruff(script_file, rel_path)
|
||||
lint_findings.extend(lf)
|
||||
if lf and not any(f['category'] == 'lint-setup' for f in lf):
|
||||
lint_tools_used.add('ruff')
|
||||
elif ext in ('.sh', '.bash'):
|
||||
script_inventory['shell'].append(script_file.name)
|
||||
findings = scan_shell_script(script_file, rel_path)
|
||||
lf = lint_shell_shellcheck(script_file, rel_path)
|
||||
lint_findings.extend(lf)
|
||||
if lf and not any(f['category'] == 'lint-setup' for f in lf):
|
||||
lint_tools_used.add('shellcheck')
|
||||
elif ext in ('.js', '.ts', '.mjs'):
|
||||
script_inventory['node'].append(script_file.name)
|
||||
findings = scan_node_script(script_file, rel_path)
|
||||
lf = lint_node_biome(script_file, rel_path)
|
||||
lint_findings.extend(lf)
|
||||
if lf and not any(f['category'] == 'lint-setup' for f in lf):
|
||||
lint_tools_used.add('biome')
|
||||
else:
|
||||
script_inventory['other'].append(script_file.name)
|
||||
findings = []
|
||||
|
||||
# Check for unit tests
|
||||
if tests_dir.exists():
|
||||
stem = script_file.stem
|
||||
test_patterns = [
|
||||
f'test_{stem}{ext}', f'test-{stem}{ext}',
|
||||
f'{stem}_test{ext}', f'{stem}-test{ext}',
|
||||
f'test_{stem}.py', f'test-{stem}.py',
|
||||
]
|
||||
has_test = any((tests_dir / t).exists() for t in test_patterns)
|
||||
else:
|
||||
has_test = False
|
||||
|
||||
if not has_test:
|
||||
missing_tests.append(script_file.name)
|
||||
findings.append({
|
||||
'file': rel_path, 'line': 1,
|
||||
'severity': 'medium', 'category': 'tests',
|
||||
'title': f'No unit test found for {script_file.name}',
|
||||
'detail': '',
|
||||
'action': f'Create scripts/tests/test-{script_file.stem}{ext} with test cases',
|
||||
})
|
||||
|
||||
all_findings.extend(findings)
|
||||
|
||||
# Check if tests/ directory exists at all
|
||||
if script_files and not tests_dir.exists():
|
||||
all_findings.append({
|
||||
'file': 'scripts/tests/',
|
||||
'line': 0,
|
||||
'severity': 'high',
|
||||
'category': 'tests',
|
||||
'title': 'scripts/tests/ directory does not exist — no unit tests',
|
||||
'detail': '',
|
||||
'action': 'Create scripts/tests/ with test files for each script',
|
||||
})
|
||||
|
||||
# Merge lint findings into all findings
|
||||
all_findings.extend(lint_findings)
|
||||
|
||||
# Build summary
|
||||
by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
||||
by_category: dict[str, int] = {}
|
||||
for f in all_findings:
|
||||
sev = f['severity']
|
||||
if sev in by_severity:
|
||||
by_severity[sev] += 1
|
||||
cat = f['category']
|
||||
by_category[cat] = by_category.get(cat, 0) + 1
|
||||
|
||||
total_scripts = sum(len(v) for v in script_inventory.values())
|
||||
status = 'pass'
|
||||
if by_severity['critical'] > 0:
|
||||
status = 'fail'
|
||||
elif by_severity['high'] > 0:
|
||||
status = 'warning'
|
||||
elif total_scripts == 0:
|
||||
status = 'pass'
|
||||
|
||||
lint_issue_count = sum(1 for f in lint_findings if f['category'] == 'lint')
|
||||
|
||||
return {
|
||||
'scanner': 'scripts',
|
||||
'script': 'scan-scripts.py',
|
||||
'version': '2.0.0',
|
||||
'skill_path': str(skill_path),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'status': status,
|
||||
'findings': all_findings,
|
||||
'assessments': {
|
||||
'lint_summary': {
|
||||
'tools_used': sorted(lint_tools_used),
|
||||
'files_linted': total_scripts,
|
||||
'lint_issues': lint_issue_count,
|
||||
},
|
||||
'script_summary': {
|
||||
'total_scripts': total_scripts,
|
||||
'by_type': {k: len(v) for k, v in script_inventory.items()},
|
||||
'scripts': {k: v for k, v in script_inventory.items() if v},
|
||||
'missing_tests': missing_tests,
|
||||
},
|
||||
},
|
||||
'summary': {
|
||||
'total_findings': len(all_findings),
|
||||
'by_severity': by_severity,
|
||||
'by_category': by_category,
|
||||
'assessment': '',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Scan BMad skill scripts for quality, portability, agentic design, and lint issues',
|
||||
)
|
||||
parser.add_argument(
|
||||
'skill_path',
|
||||
type=Path,
|
||||
help='Path to the skill directory to scan',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output', '-o',
|
||||
type=Path,
|
||||
help='Write JSON output to file instead of stdout',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.skill_path.is_dir():
|
||||
print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = scan_skill_scripts(args.skill_path)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(output)
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
return 0 if result['status'] == 'pass' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user