"""Batch-evaluate every local plugin and write per-plugin JSON + a summary report. Runs `plugin-eval score` (via the library, not the CLI subprocess) on every plugin directory under `plugins/`. External git-subdir plugins are skipped since their source does not exist locally. Outputs: reports/.json — raw result per plugin reports/summary.md — aggregated markdown report reports/summary.json — machine-readable aggregate Intended for CI usage but works locally too: uv run python scripts/eval_all.py --depth quick uv run python scripts/eval_all.py --depth standard --output-dir /tmp/reports """ from __future__ import annotations import argparse import json import sys import time from dataclasses import asdict, dataclass from pathlib import Path from plugin_eval.engine import EvalEngine from plugin_eval.models import Depth, EvalConfig, PluginEvalResult REPO_ROOT = Path(__file__).resolve().parents[3] PLUGINS_DIR = REPO_ROOT / "plugins" DEPTH_MAP = { "quick": Depth.QUICK, "standard": Depth.STANDARD, "deep": Depth.DEEP, "thorough": Depth.THOROUGH, } @dataclass class PluginRow: name: str score: float | None badge: str | None confidence: str | None ci_lower: float | None ci_upper: float | None anti_patterns: list[str] weakest_dimensions: list[tuple[str, float]] duration_ms: int | None errored: bool error: str | None = None def discover_plugins() -> list[Path]: return sorted( p for p in PLUGINS_DIR.iterdir() if p.is_dir() and (p / ".claude-plugin" / "plugin.json").exists() ) def row_from_result(name: str, result: PluginEvalResult, duration_ms: int) -> PluginRow: comp = result.composite if comp is None: return PluginRow( name=name, score=None, badge=None, confidence=None, ci_lower=None, ci_upper=None, anti_patterns=[], weakest_dimensions=[], duration_ms=duration_ms, errored=True, error="No composite score produced", ) # Collect unique anti-pattern flags across layers seen: set[str] = set() anti_patterns: list[str] = [] for layer in result.layers: for ap in getattr(layer, "anti_patterns", []) or []: flag = getattr(ap, "flag", None) or str(ap) if flag and flag not in seen: seen.add(flag) anti_patterns.append(flag) # Weakest 3 dimensions (by weighted_score) dims = sorted( comp.dimensions, key=lambda d: (d.weighted_score if d.weight > 0 else 1.0), )[:3] weakest = [(d.name, d.score) for d in dims if d.weight > 0] badge_val = comp.badge.value if hasattr(comp.badge, "value") else str(comp.badge) return PluginRow( name=name, score=comp.score, badge=badge_val, confidence=comp.confidence_label, ci_lower=comp.ci_lower, ci_upper=comp.ci_upper, anti_patterns=anti_patterns, weakest_dimensions=weakest, duration_ms=duration_ms, errored=False, ) def evaluate_one( plugin_dir: Path, config: EvalConfig, output_dir: Path ) -> PluginRow: start = time.monotonic() name = plugin_dir.name engine = EvalEngine(config) try: result = engine.evaluate_plugin(plugin_dir) except Exception as exc: return PluginRow( name=name, score=None, badge=None, confidence=None, ci_lower=None, ci_upper=None, anti_patterns=[], weakest_dimensions=[], duration_ms=int((time.monotonic() - start) * 1000), errored=True, error=f"{type(exc).__name__}: {exc}", ) duration_ms = int((time.monotonic() - start) * 1000) (output_dir / f"{name}.json").write_text(result.model_dump_json(indent=2)) return row_from_result(name, result, duration_ms) def format_score(v: float | None) -> str: """Composite scores are 0-100.""" return f"{v:.1f}" if v is not None else "—" def format_ci(lo: float | None, hi: float | None) -> str: if lo is None or hi is None: return "—" return f"[{lo:.1f}–{hi:.1f}]" def format_dim_score(v: float) -> str: """Dimension scores are 0-1, expressed as 0-100 for readability.""" return f"{v * 100:.0f}" def build_summary_md(rows: list[PluginRow], depth: str, started_at: str) -> str: total = len(rows) errored = sum(1 for r in rows if r.errored) scored = [r for r in rows if not r.errored and r.score is not None] scored.sort(key=lambda r: r.score or 0.0) badges: dict[str, int] = {} for r in scored: key = r.badge or "none" badges[key] = badges.get(key, 0) + 1 mean_score = ( sum((r.score or 0.0) for r in scored) / len(scored) if scored else 0.0 ) lines: list[str] = [] lines.append(f"# Plugin Eval Report — depth: `{depth}`") lines.append("") lines.append(f"_Generated: {started_at}_") lines.append("") lines.append("## Summary") lines.append("") lines.append(f"- Plugins evaluated: **{total}** ({errored} errored)") lines.append(f"- Mean score: **{mean_score:.1f}** / 100") badge_line = ", ".join(f"{k}: {v}" for k, v in badges.items() if v > 0) lines.append(f"- Badges: {badge_line or 'none'}") lines.append("") # Highlight anything scoring below 60 or with anti-patterns concerning = [ r for r in scored if (r.score or 0.0) < 60.0 or r.anti_patterns ] if concerning: lines.append(f"## Issues requiring attention ({len(concerning)})") lines.append("") lines.append("| Plugin | Score | Badge | Anti-patterns | Weakest dimensions |") lines.append("|---|---|---|---|---|") for r in concerning: ap = ", ".join(r.anti_patterns) if r.anti_patterns else "—" weak = ( ", ".join(f"{n} ({format_dim_score(s)})" for n, s in r.weakest_dimensions) or "—" ) lines.append( f"| `{r.name}` | {format_score(r.score)} | {r.badge or '—'} | {ap} | {weak} |" ) lines.append("") if errored: lines.append(f"## Errors ({errored})") lines.append("") lines.append("| Plugin | Error |") lines.append("|---|---|") for r in rows: if r.errored: lines.append(f"| `{r.name}` | {r.error or '—'} |") lines.append("") # Full ranked table lines.append("## All plugins (ranked by score ascending)") lines.append("") lines.append("| Plugin | Score | 95% CI | Badge | Confidence | Duration |") lines.append("|---|---|---|---|---|---|") for r in sorted(rows, key=lambda r: (r.score or 0.0)): dur = f"{(r.duration_ms or 0) / 1000:.1f}s" if r.duration_ms else "—" lines.append( f"| `{r.name}` | {format_score(r.score)} | " f"{format_ci(r.ci_lower, r.ci_upper)} | " f"{r.badge or '—'} | {r.confidence or '—'} | {dur} |" ) lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "--depth", default="quick", choices=list(DEPTH_MAP.keys()) ) parser.add_argument("--output-dir", default="eval-reports") parser.add_argument( "--concurrency", type=int, default=4, help="Max concurrent LLM calls for Layer 2/3", ) parser.add_argument( "--threshold", type=float, default=None, help="Exit 1 if mean score below this (0-100)", ) parser.add_argument( "--only-changed", default=None, help="Comma-separated plugin names to limit evaluation to", ) args = parser.parse_args() output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) plugins = discover_plugins() if args.only_changed: wanted = {n.strip() for n in args.only_changed.split(",") if n.strip()} plugins = [p for p in plugins if p.name in wanted] config = EvalConfig( depth=DEPTH_MAP[args.depth], concurrency=args.concurrency, ) started_at = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) print( f"[eval_all] evaluating {len(plugins)} plugins at depth={args.depth} " f"concurrency={args.concurrency}", file=sys.stderr, ) rows: list[PluginRow] = [] for i, plugin_dir in enumerate(plugins, 1): print( f"[eval_all] ({i}/{len(plugins)}) {plugin_dir.name}…", file=sys.stderr, ) row = evaluate_one(plugin_dir, config, output_dir) rows.append(row) summary_md = build_summary_md(rows, args.depth, started_at) (output_dir / "summary.md").write_text(summary_md) (output_dir / "summary.json").write_text( json.dumps([asdict(r) for r in rows], indent=2) ) # Echo to stdout so CI can redirect to $GITHUB_STEP_SUMMARY sys.stdout.write(summary_md) scored = [r for r in rows if not r.errored and r.score is not None] if args.threshold is not None and scored: mean = sum(r.score or 0.0 for r in scored) / len(scored) if mean < args.threshold: print( f"[eval_all] mean {mean:.1f} below threshold {args.threshold}", file=sys.stderr, ) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())