| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747 |
- #!/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())
|