scan-scripts.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. #!/usr/bin/env python3
  2. """Deterministic scripts scanner for BMad skills.
  3. Validates scripts in a skill's scripts/ folder for:
  4. - PEP 723 inline dependencies (Python)
  5. - Shebang, set -e, portability (Shell)
  6. - Version pinning for npx/uvx
  7. - Agentic design: no input(), has argparse/--help, JSON output, exit codes
  8. - Unit test existence
  9. - Over-engineering signals (line count, simple-op imports)
  10. - External lint: ruff (Python), shellcheck (Bash), biome (JS/TS)
  11. """
  12. # /// script
  13. # requires-python = ">=3.9"
  14. # ///
  15. from __future__ import annotations
  16. import argparse
  17. import ast
  18. import json
  19. import re
  20. import shutil
  21. import subprocess
  22. import sys
  23. from datetime import datetime, timezone
  24. from pathlib import Path
  25. # =============================================================================
  26. # External Linter Integration
  27. # =============================================================================
  28. def _run_command(cmd: list[str], timeout: int = 30) -> tuple[int, str, str]:
  29. """Run a command and return (returncode, stdout, stderr)."""
  30. try:
  31. result = subprocess.run(
  32. cmd, capture_output=True, text=True, timeout=timeout,
  33. )
  34. return result.returncode, result.stdout, result.stderr
  35. except FileNotFoundError:
  36. return -1, '', f'Command not found: {cmd[0]}'
  37. except subprocess.TimeoutExpired:
  38. return -2, '', f'Command timed out after {timeout}s: {" ".join(cmd)}'
  39. def _find_uv() -> str | None:
  40. """Find uv binary on PATH."""
  41. return shutil.which('uv')
  42. def _find_npx() -> str | None:
  43. """Find npx binary on PATH."""
  44. return shutil.which('npx')
  45. def lint_python_ruff(filepath: Path, rel_path: str) -> list[dict]:
  46. """Run ruff on a Python file via uv. Returns lint findings."""
  47. uv = _find_uv()
  48. if not uv:
  49. return [{
  50. 'file': rel_path, 'line': 0,
  51. 'severity': 'high', 'category': 'lint-setup',
  52. 'title': 'uv not found on PATH — cannot run ruff for Python linting',
  53. 'detail': '',
  54. 'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
  55. }]
  56. rc, stdout, stderr = _run_command([
  57. uv, 'run', 'ruff', 'check', '--output-format', 'json', str(filepath),
  58. ])
  59. if rc == -1:
  60. return [{
  61. 'file': rel_path, 'line': 0,
  62. 'severity': 'high', 'category': 'lint-setup',
  63. 'title': f'Failed to run ruff via uv: {stderr.strip()}',
  64. 'detail': '',
  65. 'action': 'Ensure uv can install and run ruff: uv run ruff --version',
  66. }]
  67. if rc == -2:
  68. return [{
  69. 'file': rel_path, 'line': 0,
  70. 'severity': 'medium', 'category': 'lint',
  71. 'title': f'ruff timed out on {rel_path}',
  72. 'detail': '',
  73. 'action': '',
  74. }]
  75. # ruff outputs JSON array on stdout (even on rc=1 when issues found)
  76. findings = []
  77. try:
  78. issues = json.loads(stdout) if stdout.strip() else []
  79. except json.JSONDecodeError:
  80. return [{
  81. 'file': rel_path, 'line': 0,
  82. 'severity': 'medium', 'category': 'lint',
  83. 'title': f'Failed to parse ruff output for {rel_path}',
  84. 'detail': '',
  85. 'action': '',
  86. }]
  87. for issue in issues:
  88. fix_msg = issue.get('fix', {}).get('message', '') if issue.get('fix') else ''
  89. findings.append({
  90. 'file': rel_path,
  91. 'line': issue.get('location', {}).get('row', 0),
  92. 'severity': 'high',
  93. 'category': 'lint',
  94. 'title': f'[{issue.get("code", "?")}] {issue.get("message", "")}',
  95. 'detail': '',
  96. 'action': fix_msg or f'See https://docs.astral.sh/ruff/rules/{issue.get("code", "")}',
  97. })
  98. return findings
  99. def lint_shell_shellcheck(filepath: Path, rel_path: str) -> list[dict]:
  100. """Run shellcheck on a shell script via uv. Returns lint findings."""
  101. uv = _find_uv()
  102. if not uv:
  103. return [{
  104. 'file': rel_path, 'line': 0,
  105. 'severity': 'high', 'category': 'lint-setup',
  106. 'title': 'uv not found on PATH — cannot run shellcheck for shell linting',
  107. 'detail': '',
  108. 'action': 'Install uv: https://docs.astral.sh/uv/getting-started/installation/',
  109. }]
  110. rc, stdout, stderr = _run_command([
  111. uv, 'run', '--with', 'shellcheck-py',
  112. 'shellcheck', '--format', 'json', str(filepath),
  113. ])
  114. if rc == -1:
  115. return [{
  116. 'file': rel_path, 'line': 0,
  117. 'severity': 'high', 'category': 'lint-setup',
  118. 'title': f'Failed to run shellcheck via uv: {stderr.strip()}',
  119. 'detail': '',
  120. 'action': 'Ensure uv can install shellcheck-py: uv run --with shellcheck-py shellcheck --version',
  121. }]
  122. if rc == -2:
  123. return [{
  124. 'file': rel_path, 'line': 0,
  125. 'severity': 'medium', 'category': 'lint',
  126. 'title': f'shellcheck timed out on {rel_path}',
  127. 'detail': '',
  128. 'action': '',
  129. }]
  130. findings = []
  131. # shellcheck outputs JSON on stdout (rc=1 when issues found)
  132. raw = stdout.strip() or stderr.strip()
  133. try:
  134. issues = json.loads(raw) if raw else []
  135. except json.JSONDecodeError:
  136. return [{
  137. 'file': rel_path, 'line': 0,
  138. 'severity': 'medium', 'category': 'lint',
  139. 'title': f'Failed to parse shellcheck output for {rel_path}',
  140. 'detail': '',
  141. 'action': '',
  142. }]
  143. # Map shellcheck levels to our severity
  144. level_map = {'error': 'high', 'warning': 'high', 'info': 'high', 'style': 'medium'}
  145. for issue in issues:
  146. sc_code = issue.get('code', '')
  147. findings.append({
  148. 'file': rel_path,
  149. 'line': issue.get('line', 0),
  150. 'severity': level_map.get(issue.get('level', ''), 'high'),
  151. 'category': 'lint',
  152. 'title': f'[SC{sc_code}] {issue.get("message", "")}',
  153. 'detail': '',
  154. 'action': f'See https://www.shellcheck.net/wiki/SC{sc_code}',
  155. })
  156. return findings
  157. def lint_node_biome(filepath: Path, rel_path: str) -> list[dict]:
  158. """Run biome on a JS/TS file via npx. Returns lint findings."""
  159. npx = _find_npx()
  160. if not npx:
  161. return [{
  162. 'file': rel_path, 'line': 0,
  163. 'severity': 'high', 'category': 'lint-setup',
  164. 'title': 'npx not found on PATH — cannot run biome for JS/TS linting',
  165. 'detail': '',
  166. 'action': 'Install Node.js 20+: https://nodejs.org/',
  167. }]
  168. rc, stdout, stderr = _run_command([
  169. npx, '--yes', '@biomejs/biome', 'lint', '--reporter', 'json', str(filepath),
  170. ], timeout=60)
  171. if rc == -1:
  172. return [{
  173. 'file': rel_path, 'line': 0,
  174. 'severity': 'high', 'category': 'lint-setup',
  175. 'title': f'Failed to run biome via npx: {stderr.strip()}',
  176. 'detail': '',
  177. 'action': 'Ensure npx can run biome: npx @biomejs/biome --version',
  178. }]
  179. if rc == -2:
  180. return [{
  181. 'file': rel_path, 'line': 0,
  182. 'severity': 'medium', 'category': 'lint',
  183. 'title': f'biome timed out on {rel_path}',
  184. 'detail': '',
  185. 'action': '',
  186. }]
  187. findings = []
  188. # biome outputs JSON on stdout
  189. raw = stdout.strip()
  190. try:
  191. result = json.loads(raw) if raw else {}
  192. except json.JSONDecodeError:
  193. return [{
  194. 'file': rel_path, 'line': 0,
  195. 'severity': 'medium', 'category': 'lint',
  196. 'title': f'Failed to parse biome output for {rel_path}',
  197. 'detail': '',
  198. 'action': '',
  199. }]
  200. for diag in result.get('diagnostics', []):
  201. loc = diag.get('location', {})
  202. start = loc.get('start', {})
  203. findings.append({
  204. 'file': rel_path,
  205. 'line': start.get('line', 0),
  206. 'severity': 'high',
  207. 'category': 'lint',
  208. 'title': f'[{diag.get("category", "?")}] {diag.get("message", "")}',
  209. 'detail': '',
  210. 'action': diag.get('advices', [{}])[0].get('message', '') if diag.get('advices') else '',
  211. })
  212. return findings
  213. # =============================================================================
  214. # BMad Pattern Checks (Existing)
  215. # =============================================================================
  216. def scan_python_script(filepath: Path, rel_path: str) -> list[dict]:
  217. """Check a Python script for standards compliance."""
  218. findings = []
  219. content = filepath.read_text(encoding='utf-8')
  220. lines = content.split('\n')
  221. line_count = len(lines)
  222. # PEP 723 check
  223. if '# /// script' not in content:
  224. # Only flag if the script has imports (not a trivial script)
  225. if 'import ' in content:
  226. findings.append({
  227. 'file': rel_path, 'line': 1,
  228. 'severity': 'medium', 'category': 'dependencies',
  229. 'title': 'No PEP 723 inline dependency block (# /// script)',
  230. 'detail': '',
  231. 'action': 'Add PEP 723 block with requires-python and dependencies',
  232. })
  233. else:
  234. # Check requires-python is present
  235. if 'requires-python' not in content:
  236. findings.append({
  237. 'file': rel_path, 'line': 1,
  238. 'severity': 'low', 'category': 'dependencies',
  239. 'title': 'PEP 723 block exists but missing requires-python constraint',
  240. 'detail': '',
  241. 'action': 'Add requires-python = ">=3.9" or appropriate version',
  242. })
  243. # requirements.txt reference
  244. if 'requirements.txt' in content or 'pip install' in content:
  245. findings.append({
  246. 'file': rel_path, 'line': 1,
  247. 'severity': 'high', 'category': 'dependencies',
  248. 'title': 'References requirements.txt or pip install — use PEP 723 inline deps',
  249. 'detail': '',
  250. 'action': 'Replace with PEP 723 inline dependency block',
  251. })
  252. # Agentic design checks via AST
  253. try:
  254. tree = ast.parse(content)
  255. except SyntaxError:
  256. findings.append({
  257. 'file': rel_path, 'line': 1,
  258. 'severity': 'critical', 'category': 'error-handling',
  259. 'title': 'Python syntax error — script cannot be parsed',
  260. 'detail': '',
  261. 'action': '',
  262. })
  263. return findings
  264. has_argparse = False
  265. has_json_dumps = False
  266. has_sys_exit = False
  267. imports = set()
  268. for node in ast.walk(tree):
  269. # Track imports
  270. if isinstance(node, ast.Import):
  271. for alias in node.names:
  272. imports.add(alias.name)
  273. elif isinstance(node, ast.ImportFrom):
  274. if node.module:
  275. imports.add(node.module)
  276. # input() calls
  277. if isinstance(node, ast.Call):
  278. func = node.func
  279. if isinstance(func, ast.Name) and func.id == 'input':
  280. findings.append({
  281. 'file': rel_path, 'line': node.lineno,
  282. 'severity': 'critical', 'category': 'agentic-design',
  283. 'title': 'input() call found — blocks in non-interactive agent execution',
  284. 'detail': '',
  285. 'action': 'Use argparse with required flags instead of interactive prompts',
  286. })
  287. # json.dumps
  288. if isinstance(func, ast.Attribute) and func.attr == 'dumps':
  289. has_json_dumps = True
  290. # sys.exit
  291. if isinstance(func, ast.Attribute) and func.attr == 'exit':
  292. has_sys_exit = True
  293. if isinstance(func, ast.Name) and func.id == 'exit':
  294. has_sys_exit = True
  295. # argparse
  296. if isinstance(node, ast.Attribute) and node.attr == 'ArgumentParser':
  297. has_argparse = True
  298. if not has_argparse and line_count > 20:
  299. findings.append({
  300. 'file': rel_path, 'line': 1,
  301. 'severity': 'medium', 'category': 'agentic-design',
  302. 'title': 'No argparse found — script lacks --help self-documentation',
  303. 'detail': '',
  304. 'action': 'Add argparse with description and argument help text',
  305. })
  306. if not has_json_dumps and line_count > 20:
  307. findings.append({
  308. 'file': rel_path, 'line': 1,
  309. 'severity': 'medium', 'category': 'agentic-design',
  310. 'title': 'No json.dumps found — output may not be structured JSON',
  311. 'detail': '',
  312. 'action': 'Use json.dumps for structured output parseable by workflows',
  313. })
  314. if not has_sys_exit and line_count > 20:
  315. findings.append({
  316. 'file': rel_path, 'line': 1,
  317. 'severity': 'low', 'category': 'agentic-design',
  318. 'title': 'No sys.exit() calls — may not return meaningful exit codes',
  319. 'detail': '',
  320. 'action': 'Return 0=success, 1=fail, 2=error via sys.exit()',
  321. })
  322. # Over-engineering: simple file ops in Python
  323. simple_op_imports = {'shutil', 'glob', 'fnmatch'}
  324. over_eng = imports & simple_op_imports
  325. if over_eng and line_count < 30:
  326. findings.append({
  327. 'file': rel_path, 'line': 1,
  328. 'severity': 'low', 'category': 'over-engineered',
  329. 'title': f'Short script ({line_count} lines) imports {", ".join(over_eng)} — may be simpler as bash',
  330. 'detail': '',
  331. 'action': 'Consider if cp/mv/find shell commands would suffice',
  332. })
  333. # Very short script
  334. if line_count < 5:
  335. findings.append({
  336. 'file': rel_path, 'line': 1,
  337. 'severity': 'medium', 'category': 'over-engineered',
  338. 'title': f'Script is only {line_count} lines — could be an inline command',
  339. 'detail': '',
  340. 'action': 'Consider inlining this command directly in the prompt',
  341. })
  342. return findings
  343. def scan_shell_script(filepath: Path, rel_path: str) -> list[dict]:
  344. """Check a shell script for standards compliance."""
  345. findings = []
  346. content = filepath.read_text(encoding='utf-8')
  347. lines = content.split('\n')
  348. line_count = len(lines)
  349. # Shebang
  350. if not lines[0].startswith('#!'):
  351. findings.append({
  352. 'file': rel_path, 'line': 1,
  353. 'severity': 'high', 'category': 'portability',
  354. 'title': 'Missing shebang line',
  355. 'detail': '',
  356. 'action': 'Add #!/usr/bin/env bash or #!/usr/bin/env sh',
  357. })
  358. elif '/usr/bin/env' not in lines[0]:
  359. findings.append({
  360. 'file': rel_path, 'line': 1,
  361. 'severity': 'medium', 'category': 'portability',
  362. 'title': f'Shebang uses hardcoded path: {lines[0].strip()}',
  363. 'detail': '',
  364. 'action': 'Use #!/usr/bin/env bash for cross-platform compatibility',
  365. })
  366. # set -e
  367. if 'set -e' not in content and 'set -euo' not in content:
  368. findings.append({
  369. 'file': rel_path, 'line': 1,
  370. 'severity': 'medium', 'category': 'error-handling',
  371. 'title': 'Missing set -e — errors will be silently ignored',
  372. 'detail': '',
  373. 'action': 'Add set -e (or set -euo pipefail) near the top',
  374. })
  375. # Hardcoded interpreter paths
  376. hardcoded_re = re.compile(r'/usr/bin/(python|ruby|node|perl)\b')
  377. for i, line in enumerate(lines, 1):
  378. if hardcoded_re.search(line):
  379. findings.append({
  380. 'file': rel_path, 'line': i,
  381. 'severity': 'medium', 'category': 'portability',
  382. 'title': f'Hardcoded interpreter path: {line.strip()}',
  383. 'detail': '',
  384. 'action': 'Use /usr/bin/env or PATH-based lookup',
  385. })
  386. # GNU-only tools
  387. gnu_re = re.compile(r'\b(gsed|gawk|ggrep|gfind)\b')
  388. for i, line in enumerate(lines, 1):
  389. m = gnu_re.search(line)
  390. if m:
  391. findings.append({
  392. 'file': rel_path, 'line': i,
  393. 'severity': 'medium', 'category': 'portability',
  394. 'title': f'GNU-only tool: {m.group()} — not available on all platforms',
  395. 'detail': '',
  396. 'action': 'Use POSIX-compatible equivalent',
  397. })
  398. # Unquoted variables (basic check)
  399. unquoted_re = re.compile(r'(?<!")\$\w+(?!")')
  400. for i, line in enumerate(lines, 1):
  401. if line.strip().startswith('#'):
  402. continue
  403. for m in unquoted_re.finditer(line):
  404. # Skip inside double-quoted strings (rough heuristic)
  405. before = line[:m.start()]
  406. if before.count('"') % 2 == 1:
  407. continue
  408. findings.append({
  409. 'file': rel_path, 'line': i,
  410. 'severity': 'low', 'category': 'portability',
  411. 'title': f'Potentially unquoted variable: {m.group()} — breaks with spaces in paths',
  412. 'detail': '',
  413. 'action': f'Use "{m.group()}" with double quotes',
  414. })
  415. # npx/uvx without version pinning
  416. no_pin_re = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
  417. for i, line in enumerate(lines, 1):
  418. if line.strip().startswith('#'):
  419. continue
  420. m = no_pin_re.search(line)
  421. if m:
  422. findings.append({
  423. 'file': rel_path, 'line': i,
  424. 'severity': 'medium', 'category': 'dependencies',
  425. 'title': f'{m.group(1)} {m.group(2)} without version pinning',
  426. 'detail': '',
  427. 'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
  428. })
  429. # Very short script
  430. if line_count < 5:
  431. findings.append({
  432. 'file': rel_path, 'line': 1,
  433. 'severity': 'medium', 'category': 'over-engineered',
  434. 'title': f'Script is only {line_count} lines — could be an inline command',
  435. 'detail': '',
  436. 'action': 'Consider inlining this command directly in the prompt',
  437. })
  438. return findings
  439. def scan_node_script(filepath: Path, rel_path: str) -> list[dict]:
  440. """Check a JS/TS script for standards compliance."""
  441. findings = []
  442. content = filepath.read_text(encoding='utf-8')
  443. lines = content.split('\n')
  444. line_count = len(lines)
  445. # npx/uvx without version pinning
  446. no_pin = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
  447. for i, line in enumerate(lines, 1):
  448. m = no_pin.search(line)
  449. if m:
  450. findings.append({
  451. 'file': rel_path, 'line': i,
  452. 'severity': 'medium', 'category': 'dependencies',
  453. 'title': f'{m.group(1)} {m.group(2)} without version pinning',
  454. 'detail': '',
  455. 'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
  456. })
  457. # Very short script
  458. if line_count < 5:
  459. findings.append({
  460. 'file': rel_path, 'line': 1,
  461. 'severity': 'medium', 'category': 'over-engineered',
  462. 'title': f'Script is only {line_count} lines — could be an inline command',
  463. 'detail': '',
  464. 'action': 'Consider inlining this command directly in the prompt',
  465. })
  466. return findings
  467. # =============================================================================
  468. # Main Scanner
  469. # =============================================================================
  470. def scan_skill_scripts(skill_path: Path) -> dict:
  471. """Scan all scripts in a skill directory."""
  472. scripts_dir = skill_path / 'scripts'
  473. all_findings = []
  474. lint_findings = []
  475. script_inventory = {'python': [], 'shell': [], 'node': [], 'other': []}
  476. missing_tests = []
  477. if not scripts_dir.exists():
  478. return {
  479. 'scanner': 'scripts',
  480. 'script': 'scan-scripts.py',
  481. 'version': '2.0.0',
  482. 'skill_path': str(skill_path),
  483. 'timestamp': datetime.now(timezone.utc).isoformat(),
  484. 'status': 'pass',
  485. 'findings': [{
  486. 'file': 'scripts/',
  487. 'severity': 'info',
  488. 'category': 'none',
  489. 'title': 'No scripts/ directory found — nothing to scan',
  490. 'detail': '',
  491. 'action': '',
  492. }],
  493. 'assessments': {
  494. 'lint_summary': {
  495. 'tools_used': [],
  496. 'files_linted': 0,
  497. 'lint_issues': 0,
  498. },
  499. 'script_summary': {
  500. 'total_scripts': 0,
  501. 'by_type': script_inventory,
  502. 'missing_tests': [],
  503. },
  504. },
  505. 'summary': {
  506. 'total_findings': 0,
  507. 'by_severity': {'critical': 0, 'high': 0, 'medium': 0, 'low': 0},
  508. 'assessment': '',
  509. },
  510. }
  511. # Find all script files (exclude tests/ and __pycache__)
  512. script_files = []
  513. for f in sorted(scripts_dir.iterdir()):
  514. if f.is_file() and f.suffix in ('.py', '.sh', '.bash', '.js', '.ts', '.mjs'):
  515. script_files.append(f)
  516. tests_dir = scripts_dir / 'tests'
  517. lint_tools_used = set()
  518. for script_file in script_files:
  519. rel_path = f'scripts/{script_file.name}'
  520. ext = script_file.suffix
  521. if ext == '.py':
  522. script_inventory['python'].append(script_file.name)
  523. findings = scan_python_script(script_file, rel_path)
  524. lf = lint_python_ruff(script_file, rel_path)
  525. lint_findings.extend(lf)
  526. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  527. lint_tools_used.add('ruff')
  528. elif ext in ('.sh', '.bash'):
  529. script_inventory['shell'].append(script_file.name)
  530. findings = scan_shell_script(script_file, rel_path)
  531. lf = lint_shell_shellcheck(script_file, rel_path)
  532. lint_findings.extend(lf)
  533. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  534. lint_tools_used.add('shellcheck')
  535. elif ext in ('.js', '.ts', '.mjs'):
  536. script_inventory['node'].append(script_file.name)
  537. findings = scan_node_script(script_file, rel_path)
  538. lf = lint_node_biome(script_file, rel_path)
  539. lint_findings.extend(lf)
  540. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  541. lint_tools_used.add('biome')
  542. else:
  543. script_inventory['other'].append(script_file.name)
  544. findings = []
  545. # Check for unit tests
  546. if tests_dir.exists():
  547. stem = script_file.stem
  548. test_patterns = [
  549. f'test_{stem}{ext}', f'test-{stem}{ext}',
  550. f'{stem}_test{ext}', f'{stem}-test{ext}',
  551. f'test_{stem}.py', f'test-{stem}.py',
  552. ]
  553. has_test = any((tests_dir / t).exists() for t in test_patterns)
  554. else:
  555. has_test = False
  556. if not has_test:
  557. missing_tests.append(script_file.name)
  558. findings.append({
  559. 'file': rel_path, 'line': 1,
  560. 'severity': 'medium', 'category': 'tests',
  561. 'title': f'No unit test found for {script_file.name}',
  562. 'detail': '',
  563. 'action': f'Create scripts/tests/test-{script_file.stem}{ext} with test cases',
  564. })
  565. all_findings.extend(findings)
  566. # Check if tests/ directory exists at all
  567. if script_files and not tests_dir.exists():
  568. all_findings.append({
  569. 'file': 'scripts/tests/',
  570. 'line': 0,
  571. 'severity': 'high',
  572. 'category': 'tests',
  573. 'title': 'scripts/tests/ directory does not exist — no unit tests',
  574. 'detail': '',
  575. 'action': 'Create scripts/tests/ with test files for each script',
  576. })
  577. # Merge lint findings into all findings
  578. all_findings.extend(lint_findings)
  579. # Build summary
  580. by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
  581. by_category: dict[str, int] = {}
  582. for f in all_findings:
  583. sev = f['severity']
  584. if sev in by_severity:
  585. by_severity[sev] += 1
  586. cat = f['category']
  587. by_category[cat] = by_category.get(cat, 0) + 1
  588. total_scripts = sum(len(v) for v in script_inventory.values())
  589. status = 'pass'
  590. if by_severity['critical'] > 0:
  591. status = 'fail'
  592. elif by_severity['high'] > 0:
  593. status = 'warning'
  594. elif total_scripts == 0:
  595. status = 'pass'
  596. lint_issue_count = sum(1 for f in lint_findings if f['category'] == 'lint')
  597. return {
  598. 'scanner': 'scripts',
  599. 'script': 'scan-scripts.py',
  600. 'version': '2.0.0',
  601. 'skill_path': str(skill_path),
  602. 'timestamp': datetime.now(timezone.utc).isoformat(),
  603. 'status': status,
  604. 'findings': all_findings,
  605. 'assessments': {
  606. 'lint_summary': {
  607. 'tools_used': sorted(lint_tools_used),
  608. 'files_linted': total_scripts,
  609. 'lint_issues': lint_issue_count,
  610. },
  611. 'script_summary': {
  612. 'total_scripts': total_scripts,
  613. 'by_type': {k: len(v) for k, v in script_inventory.items()},
  614. 'scripts': {k: v for k, v in script_inventory.items() if v},
  615. 'missing_tests': missing_tests,
  616. },
  617. },
  618. 'summary': {
  619. 'total_findings': len(all_findings),
  620. 'by_severity': by_severity,
  621. 'by_category': by_category,
  622. 'assessment': '',
  623. },
  624. }
  625. def main() -> int:
  626. parser = argparse.ArgumentParser(
  627. description='Scan BMad skill scripts for quality, portability, agentic design, and lint issues',
  628. )
  629. parser.add_argument(
  630. 'skill_path',
  631. type=Path,
  632. help='Path to the skill directory to scan',
  633. )
  634. parser.add_argument(
  635. '--output', '-o',
  636. type=Path,
  637. help='Write JSON output to file instead of stdout',
  638. )
  639. args = parser.parse_args()
  640. if not args.skill_path.is_dir():
  641. print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
  642. return 2
  643. result = scan_skill_scripts(args.skill_path)
  644. output = json.dumps(result, indent=2)
  645. if args.output:
  646. args.output.parent.mkdir(parents=True, exist_ok=True)
  647. args.output.write_text(output)
  648. print(f"Results written to {args.output}", file=sys.stderr)
  649. else:
  650. print(output)
  651. return 0 if result['status'] == 'pass' else 1
  652. if __name__ == '__main__':
  653. sys.exit(main())