scan-scripts.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  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. # Legacy dep-management reference (use concatenation to avoid self-detection)
  244. req_marker = 'requirements' + '.txt'
  245. pip_marker = 'pip ' + 'install'
  246. if req_marker in content or pip_marker in content:
  247. findings.append({
  248. 'file': rel_path, 'line': 1,
  249. 'severity': 'high', 'category': 'dependencies',
  250. 'title': f'References {req_marker} or {pip_marker} — use PEP 723 inline deps',
  251. 'detail': '',
  252. 'action': 'Replace with PEP 723 inline dependency block',
  253. })
  254. # Agentic design checks via AST
  255. try:
  256. tree = ast.parse(content)
  257. except SyntaxError:
  258. findings.append({
  259. 'file': rel_path, 'line': 1,
  260. 'severity': 'critical', 'category': 'error-handling',
  261. 'title': 'Python syntax error — script cannot be parsed',
  262. 'detail': '',
  263. 'action': '',
  264. })
  265. return findings
  266. has_argparse = False
  267. has_json_dumps = False
  268. has_sys_exit = False
  269. imports = set()
  270. for node in ast.walk(tree):
  271. # Track imports
  272. if isinstance(node, ast.Import):
  273. for alias in node.names:
  274. imports.add(alias.name)
  275. elif isinstance(node, ast.ImportFrom):
  276. if node.module:
  277. imports.add(node.module)
  278. # input() calls
  279. if isinstance(node, ast.Call):
  280. func = node.func
  281. if isinstance(func, ast.Name) and func.id == 'input':
  282. findings.append({
  283. 'file': rel_path, 'line': node.lineno,
  284. 'severity': 'critical', 'category': 'agentic-design',
  285. 'title': 'input() call found — blocks in non-interactive agent execution',
  286. 'detail': '',
  287. 'action': 'Use argparse with required flags instead of interactive prompts',
  288. })
  289. # json.dumps
  290. if isinstance(func, ast.Attribute) and func.attr == 'dumps':
  291. has_json_dumps = True
  292. # sys.exit
  293. if isinstance(func, ast.Attribute) and func.attr == 'exit':
  294. has_sys_exit = True
  295. if isinstance(func, ast.Name) and func.id == 'exit':
  296. has_sys_exit = True
  297. # argparse
  298. if isinstance(node, ast.Attribute) and node.attr == 'ArgumentParser':
  299. has_argparse = True
  300. if not has_argparse and line_count > 20:
  301. findings.append({
  302. 'file': rel_path, 'line': 1,
  303. 'severity': 'medium', 'category': 'agentic-design',
  304. 'title': 'No argparse found — script lacks --help self-documentation',
  305. 'detail': '',
  306. 'action': 'Add argparse with description and argument help text',
  307. })
  308. if not has_json_dumps and line_count > 20:
  309. findings.append({
  310. 'file': rel_path, 'line': 1,
  311. 'severity': 'medium', 'category': 'agentic-design',
  312. 'title': 'No json.dumps found — output may not be structured JSON',
  313. 'detail': '',
  314. 'action': 'Use json.dumps for structured output parseable by workflows',
  315. })
  316. if not has_sys_exit and line_count > 20:
  317. findings.append({
  318. 'file': rel_path, 'line': 1,
  319. 'severity': 'low', 'category': 'agentic-design',
  320. 'title': 'No sys.exit() calls — may not return meaningful exit codes',
  321. 'detail': '',
  322. 'action': 'Return 0=success, 1=fail, 2=error via sys.exit()',
  323. })
  324. # Over-engineering: simple file ops in Python
  325. simple_op_imports = {'shutil', 'glob', 'fnmatch'}
  326. over_eng = imports & simple_op_imports
  327. if over_eng and line_count < 30:
  328. findings.append({
  329. 'file': rel_path, 'line': 1,
  330. 'severity': 'low', 'category': 'over-engineered',
  331. 'title': f'Short script ({line_count} lines) imports {", ".join(over_eng)} — may be simpler as bash',
  332. 'detail': '',
  333. 'action': 'Consider if cp/mv/find shell commands would suffice',
  334. })
  335. # Very short script
  336. if line_count < 5:
  337. findings.append({
  338. 'file': rel_path, 'line': 1,
  339. 'severity': 'medium', 'category': 'over-engineered',
  340. 'title': f'Script is only {line_count} lines — could be an inline command',
  341. 'detail': '',
  342. 'action': 'Consider inlining this command directly in the prompt',
  343. })
  344. return findings
  345. def scan_shell_script(filepath: Path, rel_path: str) -> list[dict]:
  346. """Check a shell script for standards compliance."""
  347. findings = []
  348. content = filepath.read_text(encoding='utf-8')
  349. lines = content.split('\n')
  350. line_count = len(lines)
  351. # Shebang
  352. if not lines[0].startswith('#!'):
  353. findings.append({
  354. 'file': rel_path, 'line': 1,
  355. 'severity': 'high', 'category': 'portability',
  356. 'title': 'Missing shebang line',
  357. 'detail': '',
  358. 'action': 'Add #!/usr/bin/env bash or #!/usr/bin/env sh',
  359. })
  360. elif '/usr/bin/env' not in lines[0]:
  361. findings.append({
  362. 'file': rel_path, 'line': 1,
  363. 'severity': 'medium', 'category': 'portability',
  364. 'title': f'Shebang uses hardcoded path: {lines[0].strip()}',
  365. 'detail': '',
  366. 'action': 'Use #!/usr/bin/env bash for cross-platform compatibility',
  367. })
  368. # set -e
  369. if 'set -e' not in content and 'set -euo' not in content:
  370. findings.append({
  371. 'file': rel_path, 'line': 1,
  372. 'severity': 'medium', 'category': 'error-handling',
  373. 'title': 'Missing set -e — errors will be silently ignored',
  374. 'detail': '',
  375. 'action': 'Add set -e (or set -euo pipefail) near the top',
  376. })
  377. # Hardcoded interpreter paths
  378. hardcoded_re = re.compile(r'/usr/bin/(python|ruby|node|perl)\b')
  379. for i, line in enumerate(lines, 1):
  380. if hardcoded_re.search(line):
  381. findings.append({
  382. 'file': rel_path, 'line': i,
  383. 'severity': 'medium', 'category': 'portability',
  384. 'title': f'Hardcoded interpreter path: {line.strip()}',
  385. 'detail': '',
  386. 'action': 'Use /usr/bin/env or PATH-based lookup',
  387. })
  388. # GNU-only tools
  389. gnu_re = re.compile(r'\b(gsed|gawk|ggrep|gfind)\b')
  390. for i, line in enumerate(lines, 1):
  391. m = gnu_re.search(line)
  392. if m:
  393. findings.append({
  394. 'file': rel_path, 'line': i,
  395. 'severity': 'medium', 'category': 'portability',
  396. 'title': f'GNU-only tool: {m.group()} — not available on all platforms',
  397. 'detail': '',
  398. 'action': 'Use POSIX-compatible equivalent',
  399. })
  400. # Unquoted variables (basic check)
  401. unquoted_re = re.compile(r'(?<!")\$\w+(?!")')
  402. for i, line in enumerate(lines, 1):
  403. if line.strip().startswith('#'):
  404. continue
  405. for m in unquoted_re.finditer(line):
  406. # Skip inside double-quoted strings (rough heuristic)
  407. before = line[:m.start()]
  408. if before.count('"') % 2 == 1:
  409. continue
  410. findings.append({
  411. 'file': rel_path, 'line': i,
  412. 'severity': 'low', 'category': 'portability',
  413. 'title': f'Potentially unquoted variable: {m.group()} — breaks with spaces in paths',
  414. 'detail': '',
  415. 'action': f'Use "{m.group()}" with double quotes',
  416. })
  417. # npx/uvx without version pinning
  418. no_pin_re = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
  419. for i, line in enumerate(lines, 1):
  420. if line.strip().startswith('#'):
  421. continue
  422. m = no_pin_re.search(line)
  423. if m:
  424. findings.append({
  425. 'file': rel_path, 'line': i,
  426. 'severity': 'medium', 'category': 'dependencies',
  427. 'title': f'{m.group(1)} {m.group(2)} without version pinning',
  428. 'detail': '',
  429. 'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
  430. })
  431. # Very short script
  432. if line_count < 5:
  433. findings.append({
  434. 'file': rel_path, 'line': 1,
  435. 'severity': 'medium', 'category': 'over-engineered',
  436. 'title': f'Script is only {line_count} lines — could be an inline command',
  437. 'detail': '',
  438. 'action': 'Consider inlining this command directly in the prompt',
  439. })
  440. return findings
  441. def scan_node_script(filepath: Path, rel_path: str) -> list[dict]:
  442. """Check a JS/TS script for standards compliance."""
  443. findings = []
  444. content = filepath.read_text(encoding='utf-8')
  445. lines = content.split('\n')
  446. line_count = len(lines)
  447. # npx/uvx without version pinning
  448. no_pin = re.compile(r'\b(npx|uvx)\s+([a-zA-Z][\w-]+)(?!\S*@)')
  449. for i, line in enumerate(lines, 1):
  450. m = no_pin.search(line)
  451. if m:
  452. findings.append({
  453. 'file': rel_path, 'line': i,
  454. 'severity': 'medium', 'category': 'dependencies',
  455. 'title': f'{m.group(1)} {m.group(2)} without version pinning',
  456. 'detail': '',
  457. 'action': f'Pin version: {m.group(1)} {m.group(2)}@<version>',
  458. })
  459. # Very short script
  460. if line_count < 5:
  461. findings.append({
  462. 'file': rel_path, 'line': 1,
  463. 'severity': 'medium', 'category': 'over-engineered',
  464. 'title': f'Script is only {line_count} lines — could be an inline command',
  465. 'detail': '',
  466. 'action': 'Consider inlining this command directly in the prompt',
  467. })
  468. return findings
  469. # =============================================================================
  470. # Main Scanner
  471. # =============================================================================
  472. def scan_skill_scripts(skill_path: Path) -> dict:
  473. """Scan all scripts in a skill directory."""
  474. scripts_dir = skill_path / 'scripts'
  475. all_findings = []
  476. lint_findings = []
  477. script_inventory = {'python': [], 'shell': [], 'node': [], 'other': []}
  478. missing_tests = []
  479. if not scripts_dir.exists():
  480. return {
  481. 'scanner': 'scripts',
  482. 'script': 'scan-scripts.py',
  483. 'version': '2.0.0',
  484. 'skill_path': str(skill_path),
  485. 'timestamp': datetime.now(timezone.utc).isoformat(),
  486. 'status': 'pass',
  487. 'findings': [{
  488. 'file': 'scripts/',
  489. 'severity': 'info',
  490. 'category': 'none',
  491. 'title': 'No scripts/ directory found — nothing to scan',
  492. 'detail': '',
  493. 'action': '',
  494. }],
  495. 'assessments': {
  496. 'lint_summary': {
  497. 'tools_used': [],
  498. 'files_linted': 0,
  499. 'lint_issues': 0,
  500. },
  501. 'script_summary': {
  502. 'total_scripts': 0,
  503. 'by_type': script_inventory,
  504. 'missing_tests': [],
  505. },
  506. },
  507. 'summary': {
  508. 'total_findings': 0,
  509. 'by_severity': {'critical': 0, 'high': 0, 'medium': 0, 'low': 0},
  510. 'assessment': '',
  511. },
  512. }
  513. # Find all script files (exclude tests/ and __pycache__)
  514. script_files = []
  515. for f in sorted(scripts_dir.iterdir()):
  516. if f.is_file() and f.suffix in ('.py', '.sh', '.bash', '.js', '.ts', '.mjs'):
  517. script_files.append(f)
  518. tests_dir = scripts_dir / 'tests'
  519. lint_tools_used = set()
  520. for script_file in script_files:
  521. rel_path = f'scripts/{script_file.name}'
  522. ext = script_file.suffix
  523. if ext == '.py':
  524. script_inventory['python'].append(script_file.name)
  525. findings = scan_python_script(script_file, rel_path)
  526. lf = lint_python_ruff(script_file, rel_path)
  527. lint_findings.extend(lf)
  528. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  529. lint_tools_used.add('ruff')
  530. elif ext in ('.sh', '.bash'):
  531. script_inventory['shell'].append(script_file.name)
  532. findings = scan_shell_script(script_file, rel_path)
  533. lf = lint_shell_shellcheck(script_file, rel_path)
  534. lint_findings.extend(lf)
  535. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  536. lint_tools_used.add('shellcheck')
  537. elif ext in ('.js', '.ts', '.mjs'):
  538. script_inventory['node'].append(script_file.name)
  539. findings = scan_node_script(script_file, rel_path)
  540. lf = lint_node_biome(script_file, rel_path)
  541. lint_findings.extend(lf)
  542. if lf and not any(f['category'] == 'lint-setup' for f in lf):
  543. lint_tools_used.add('biome')
  544. else:
  545. script_inventory['other'].append(script_file.name)
  546. findings = []
  547. # Check for unit tests
  548. if tests_dir.exists():
  549. stem = script_file.stem
  550. test_patterns = [
  551. f'test_{stem}{ext}', f'test-{stem}{ext}',
  552. f'{stem}_test{ext}', f'{stem}-test{ext}',
  553. f'test_{stem}.py', f'test-{stem}.py',
  554. ]
  555. has_test = any((tests_dir / t).exists() for t in test_patterns)
  556. else:
  557. has_test = False
  558. if not has_test:
  559. missing_tests.append(script_file.name)
  560. findings.append({
  561. 'file': rel_path, 'line': 1,
  562. 'severity': 'medium', 'category': 'tests',
  563. 'title': f'No unit test found for {script_file.name}',
  564. 'detail': '',
  565. 'action': f'Create scripts/tests/test-{script_file.stem}{ext} with test cases',
  566. })
  567. all_findings.extend(findings)
  568. # Check if tests/ directory exists at all
  569. if script_files and not tests_dir.exists():
  570. all_findings.append({
  571. 'file': 'scripts/tests/',
  572. 'line': 0,
  573. 'severity': 'high',
  574. 'category': 'tests',
  575. 'title': 'scripts/tests/ directory does not exist — no unit tests',
  576. 'detail': '',
  577. 'action': 'Create scripts/tests/ with test files for each script',
  578. })
  579. # Merge lint findings into all findings
  580. all_findings.extend(lint_findings)
  581. # Build summary
  582. by_severity = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
  583. by_category: dict[str, int] = {}
  584. for f in all_findings:
  585. sev = f['severity']
  586. if sev in by_severity:
  587. by_severity[sev] += 1
  588. cat = f['category']
  589. by_category[cat] = by_category.get(cat, 0) + 1
  590. total_scripts = sum(len(v) for v in script_inventory.values())
  591. status = 'pass'
  592. if by_severity['critical'] > 0:
  593. status = 'fail'
  594. elif by_severity['high'] > 0:
  595. status = 'warning'
  596. elif total_scripts == 0:
  597. status = 'pass'
  598. lint_issue_count = sum(1 for f in lint_findings if f['category'] == 'lint')
  599. return {
  600. 'scanner': 'scripts',
  601. 'script': 'scan-scripts.py',
  602. 'version': '2.0.0',
  603. 'skill_path': str(skill_path),
  604. 'timestamp': datetime.now(timezone.utc).isoformat(),
  605. 'status': status,
  606. 'findings': all_findings,
  607. 'assessments': {
  608. 'lint_summary': {
  609. 'tools_used': sorted(lint_tools_used),
  610. 'files_linted': total_scripts,
  611. 'lint_issues': lint_issue_count,
  612. },
  613. 'script_summary': {
  614. 'total_scripts': total_scripts,
  615. 'by_type': {k: len(v) for k, v in script_inventory.items()},
  616. 'scripts': {k: v for k, v in script_inventory.items() if v},
  617. 'missing_tests': missing_tests,
  618. },
  619. },
  620. 'summary': {
  621. 'total_findings': len(all_findings),
  622. 'by_severity': by_severity,
  623. 'by_category': by_category,
  624. 'assessment': '',
  625. },
  626. }
  627. def main() -> int:
  628. parser = argparse.ArgumentParser(
  629. description='Scan BMad skill scripts for quality, portability, agentic design, and lint issues',
  630. )
  631. parser.add_argument(
  632. 'skill_path',
  633. type=Path,
  634. help='Path to the skill directory to scan',
  635. )
  636. parser.add_argument(
  637. '--output', '-o',
  638. type=Path,
  639. help='Write JSON output to file instead of stdout',
  640. )
  641. args = parser.parse_args()
  642. if not args.skill_path.is_dir():
  643. print(f"Error: {args.skill_path} is not a directory", file=sys.stderr)
  644. return 2
  645. result = scan_skill_scripts(args.skill_path)
  646. output = json.dumps(result, indent=2)
  647. if args.output:
  648. args.output.parent.mkdir(parents=True, exist_ok=True)
  649. args.output.write_text(output)
  650. print(f"Results written to {args.output}", file=sys.stderr)
  651. else:
  652. print(output)
  653. return 0 if result['status'] == 'pass' else 1
  654. if __name__ == '__main__':
  655. sys.exit(main())