|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Deterministic scorer for the README benchmark rubric. |
| 3 | +
|
| 4 | +The benchmark table in the README scores five dimensions (Functional 30, |
| 5 | +Structure 20, Hygiene 20, Git 15, Quality 15). Four of them are mechanical |
| 6 | +properties of the generated project, so they should not require a human -- |
| 7 | +or trust in one. This script recomputes them from a project directory and |
| 8 | +prints per-check evidence, so anyone can re-run the comparison on any |
| 9 | +agent's output and get the same numbers. |
| 10 | +
|
| 11 | +What it will and will not do: |
| 12 | +
|
| 13 | +- Structure / Hygiene / Git (55 pts) are scored deterministically. Git |
| 14 | + checks need the project's real ``.git`` history; on the checked-in |
| 15 | + artifacts (history stripped when they were copied into examples/) they |
| 16 | + are reported as UNSCORABLE rather than silently zeroed. |
| 17 | +- Functional (30 pts) is scored by actually running ``npm install`` and |
| 18 | + ``npm test``, opt-in via ``--run-tests`` because it executes the |
| 19 | + project's code. |
| 20 | +- Quality (15 pts) is a judgment call. This script does not fake one: it |
| 21 | + reports observations (README, package metadata, custom error types) and |
| 22 | + assigns no points. A deterministic scorer that pretended to measure |
| 23 | + "quality" would just be an opinion with extra steps. |
| 24 | +
|
| 25 | +So a full run reports up to 85 recomputable points and clearly labels the |
| 26 | +remaining 15 as judgment. Scores are only comparable when produced from |
| 27 | +each agent's original output directory, including its ``.git``. |
| 28 | +
|
| 29 | +Usage: |
| 30 | + python score.py <project_dir> [--run-tests] [--json] |
| 31 | +
|
| 32 | +Exit code is 0 unless the directory is missing or not a project. |
| 33 | +""" |
| 34 | + |
| 35 | +from __future__ import annotations |
| 36 | + |
| 37 | +import argparse |
| 38 | +import json |
| 39 | +import os |
| 40 | +import re |
| 41 | +import subprocess |
| 42 | +import sys |
| 43 | +from dataclasses import dataclass, field, asdict |
| 44 | +from pathlib import Path |
| 45 | + |
| 46 | +SOURCE_DIRS = ("src", "lib", "bin") |
| 47 | +TEST_DIRS = ("test", "tests", "__tests__") |
| 48 | +JUNK_NAMES = (".DS_Store",) |
| 49 | +JUNK_DIRS = ("node_modules", "coverage") |
| 50 | +JUNK_GLOBS = ("*.log",) |
| 51 | +# Files the todo-app prompt tends to leave behind: persisted runtime data. |
| 52 | +JUNK_DATA = ("todos.json", "todo.json", "data.json") |
| 53 | + |
| 54 | +TRIVIAL_SUBJECT = re.compile(r"^(wip|fix|update|tmp|temp|changes|stuff|misc)\.?$", re.I) |
| 55 | + |
| 56 | + |
| 57 | +@dataclass |
| 58 | +class Check: |
| 59 | + dimension: str |
| 60 | + name: str |
| 61 | + points: int |
| 62 | + # True = earned, False = not earned, None = unscorable in this context. |
| 63 | + passed: bool | None |
| 64 | + evidence: str |
| 65 | + |
| 66 | + |
| 67 | +@dataclass |
| 68 | +class Report: |
| 69 | + project: str |
| 70 | + checks: list[Check] = field(default_factory=list) |
| 71 | + observations: list[str] = field(default_factory=list) |
| 72 | + |
| 73 | + def add(self, dimension: str, name: str, points: int, passed: bool | None, evidence: str) -> None: |
| 74 | + self.checks.append(Check(dimension, name, points, passed, evidence)) |
| 75 | + |
| 76 | + def earned(self) -> int: |
| 77 | + return sum(c.points for c in self.checks if c.passed is True) |
| 78 | + |
| 79 | + def scoreable(self) -> int: |
| 80 | + return sum(c.points for c in self.checks if c.passed is not None) |
| 81 | + |
| 82 | + |
| 83 | +def _list_files(root: Path) -> list[Path]: |
| 84 | + out: list[Path] = [] |
| 85 | + for dirpath, dirnames, filenames in os.walk(root): |
| 86 | + # Prune everything JUNK_DIRS names, not a hand-picked subset: a |
| 87 | + # checked-in coverage/ must not be counted as project modules by |
| 88 | + # Structure while Hygiene docks it as junk — one directory, one verdict. |
| 89 | + dirnames[:] = [d for d in dirnames if d != ".git" and d not in JUNK_DIRS] |
| 90 | + for f in filenames: |
| 91 | + out.append(Path(dirpath, f).relative_to(root)) |
| 92 | + return out |
| 93 | + |
| 94 | + |
| 95 | +def _git(root: Path, *args: str) -> subprocess.CompletedProcess: |
| 96 | + # core.fsmonitor is cleared so scoring a repo can never execute a |
| 97 | + # repo-configured monitor daemon; a scorer must read the project, not |
| 98 | + # run it (running is what --run-tests opts into). |
| 99 | + cmd = ["git", "-c", "core.fsmonitor=", "-C", str(root), *args] |
| 100 | + try: |
| 101 | + return subprocess.run(cmd, capture_output=True, text=True, timeout=60) |
| 102 | + except (FileNotFoundError, subprocess.TimeoutExpired) as e: |
| 103 | + # No git binary / a hung git is "couldn't look", not a property of |
| 104 | + # the project. Surface it as a failed CompletedProcess so every |
| 105 | + # caller's returncode check routes to UNSCORABLE with evidence. |
| 106 | + return subprocess.CompletedProcess(cmd, returncode=127, stdout="", stderr=str(e)) |
| 107 | + |
| 108 | + |
| 109 | +# ---------------------------------------------------------------- structure |
| 110 | + |
| 111 | +def score_structure(root: Path, report: Report) -> None: |
| 112 | + files = _list_files(root) |
| 113 | + js_at_root = [f for f in files if f.parent == Path(".") and f.suffix in (".js", ".mjs", ".cjs", ".ts")] |
| 114 | + src_dirs = sorted({f.parts[0] for f in files if f.parts[0] in SOURCE_DIRS}) |
| 115 | + test_dirs = sorted({f.parts[0] for f in files if f.parts[0] in TEST_DIRS}) |
| 116 | + |
| 117 | + # Source lives in a dedicated directory, not flat at the project root. |
| 118 | + # A lone root cli.js entry point alongside a src/ dir is conventional and |
| 119 | + # does not count against it; three root modules and no src/ does. |
| 120 | + modular = bool(src_dirs) and len(js_at_root) <= 1 |
| 121 | + report.add( |
| 122 | + "structure", "source in dedicated dir", 7, modular, |
| 123 | + f"source dirs: {src_dirs or 'none'}; root-level modules: {[str(f) for f in js_at_root] or 'none'}", |
| 124 | + ) |
| 125 | + |
| 126 | + report.add( |
| 127 | + "structure", "tests in dedicated dir", 7, bool(test_dirs), |
| 128 | + f"test dirs: {test_dirs or 'none'}", |
| 129 | + ) |
| 130 | + |
| 131 | + modules = [f for f in files if f.suffix in (".js", ".mjs", ".cjs", ".ts") |
| 132 | + and not any(part in TEST_DIRS for part in f.parts)] |
| 133 | + report.add( |
| 134 | + "structure", "more than one source module", 6, len(modules) >= 2, |
| 135 | + f"{len(modules)} source modules", |
| 136 | + ) |
| 137 | + |
| 138 | + |
| 139 | +# ------------------------------------------------------------------ hygiene |
| 140 | + |
| 141 | +def _gitignore_covers(gitignore: Path, target: str) -> bool: |
| 142 | + """True when a non-comment line actually ignores `target`. A substring |
| 143 | + scan would award the points to a commented-out line.""" |
| 144 | + for raw in gitignore.read_text(errors="replace").splitlines(): |
| 145 | + line = raw.strip() |
| 146 | + if not line or line.startswith("#"): |
| 147 | + continue |
| 148 | + # Normalize the common spellings: node_modules, /node_modules, |
| 149 | + # node_modules/, **/node_modules — all ignore the directory. |
| 150 | + normalized = line.strip("/") |
| 151 | + if normalized.startswith("**/"): |
| 152 | + normalized = normalized[3:] |
| 153 | + if normalized == target: |
| 154 | + return True |
| 155 | + return False |
| 156 | + |
| 157 | + |
| 158 | +def score_hygiene(root: Path, report: Report) -> None: |
| 159 | + gitignore = root / ".gitignore" |
| 160 | + report.add("hygiene", ".gitignore exists", 5, gitignore.is_file(), |
| 161 | + "present" if gitignore.is_file() else "absent") |
| 162 | + |
| 163 | + covers = gitignore.is_file() and _gitignore_covers(gitignore, "node_modules") |
| 164 | + report.add( |
| 165 | + "hygiene", ".gitignore covers node_modules", 5, |
| 166 | + covers if gitignore.is_file() else False, |
| 167 | + "listed" if covers else "not listed (or no .gitignore)", |
| 168 | + ) |
| 169 | + |
| 170 | + junk: list[str] = [] |
| 171 | + for d in JUNK_DIRS: |
| 172 | + if (root / d).is_dir(): |
| 173 | + junk.append(d + "/") |
| 174 | + files = _list_files(root) |
| 175 | + for f in files: |
| 176 | + if f.name in JUNK_NAMES or f.name in JUNK_DATA or any(f.match(g) for g in JUNK_GLOBS): |
| 177 | + junk.append(str(f)) |
| 178 | + report.add( |
| 179 | + "hygiene", "no junk artifacts in tree", 5, not junk, |
| 180 | + f"junk found: {junk}" if junk else "clean", |
| 181 | + ) |
| 182 | + |
| 183 | + if (root / ".git").exists(): |
| 184 | + status = _git(root, "status", "--porcelain") |
| 185 | + if status.returncode != 0: |
| 186 | + # git missing/failed: empty stdout must not read as "clean". |
| 187 | + report.add("hygiene", "clean git status", 5, None, |
| 188 | + f"UNSCORABLE: git status failed: {status.stderr.strip()[:200]}") |
| 189 | + else: |
| 190 | + dirty = status.stdout.strip() |
| 191 | + report.add( |
| 192 | + "hygiene", "clean git status", 5, not dirty, |
| 193 | + f"{len(dirty.splitlines())} dirty paths" if dirty else "clean", |
| 194 | + ) |
| 195 | + else: |
| 196 | + report.add("hygiene", "clean git status", 5, None, "UNSCORABLE: no .git in this copy") |
| 197 | + |
| 198 | + |
| 199 | +# ---------------------------------------------------------------------- git |
| 200 | + |
| 201 | +def score_git(root: Path, report: Report) -> None: |
| 202 | + if not (root / ".git").exists(): |
| 203 | + for name, pts in (("history has >= 3 commits", 5), |
| 204 | + ("commit subjects are descriptive", 5), |
| 205 | + ("no duplicated subjects", 5)): |
| 206 | + report.add("git", name, pts, None, |
| 207 | + "UNSCORABLE: no .git in this copy — score from the agent's original output") |
| 208 | + return |
| 209 | + |
| 210 | + log = _git(root, "log", "--format=%s") |
| 211 | + if log.returncode != 0: |
| 212 | + for name, pts in (("history has >= 3 commits", 5), |
| 213 | + ("commit subjects are descriptive", 5), |
| 214 | + ("no duplicated subjects", 5)): |
| 215 | + report.add("git", name, pts, None, f"UNSCORABLE: git log failed: {log.stderr.strip()}") |
| 216 | + return |
| 217 | + |
| 218 | + subjects = [s for s in log.stdout.splitlines() if s.strip()] |
| 219 | + report.add("git", "history has >= 3 commits", 5, len(subjects) >= 3, f"{len(subjects)} commits") |
| 220 | + |
| 221 | + weak = [s for s in subjects if len(s) < 10 or TRIVIAL_SUBJECT.match(s.strip())] |
| 222 | + report.add( |
| 223 | + "git", "commit subjects are descriptive", 5, bool(subjects) and not weak, |
| 224 | + f"weak subjects: {weak}" if weak else "all subjects >= 10 chars and non-trivial", |
| 225 | + ) |
| 226 | + |
| 227 | + dupes = sorted({s for s in subjects if subjects.count(s) > 1}) |
| 228 | + report.add( |
| 229 | + "git", "no duplicated subjects", 5, bool(subjects) and not dupes, |
| 230 | + f"duplicated: {dupes}" if dupes else "all unique", |
| 231 | + ) |
| 232 | + |
| 233 | + |
| 234 | +# --------------------------------------------------------------- functional |
| 235 | + |
| 236 | +def score_functional(root: Path, report: Report, run_tests: bool) -> None: |
| 237 | + if not run_tests: |
| 238 | + report.add("functional", "npm test passes", 30, None, |
| 239 | + "NOT RUN: pass --run-tests to execute the project's own suite") |
| 240 | + return |
| 241 | + if not (root / "package.json").is_file(): |
| 242 | + report.add("functional", "npm test passes", 30, False, "no package.json") |
| 243 | + return |
| 244 | + env = {**os.environ, "CI": "1"} |
| 245 | + try: |
| 246 | + # --ignore-scripts: running the project's suite is the explicit |
| 247 | + # opt-in here; package lifecycle scripts are not part of that deal. |
| 248 | + install = subprocess.run( |
| 249 | + ["npm", "install", "--no-audit", "--no-fund", "--silent", "--ignore-scripts"], |
| 250 | + cwd=root, capture_output=True, text=True, timeout=600, env=env) |
| 251 | + except FileNotFoundError: |
| 252 | + # "unscorable is never zero" applies to the scorer's own toolchain |
| 253 | + # too: a machine without npm couldn't look, the project didn't fail. |
| 254 | + report.add("functional", "npm test passes", 30, None, "UNSCORABLE: npm is not installed") |
| 255 | + return |
| 256 | + except subprocess.TimeoutExpired: |
| 257 | + report.add("functional", "npm test passes", 30, False, "npm install timed out after 600s") |
| 258 | + return |
| 259 | + if install.returncode != 0: |
| 260 | + report.add("functional", "npm test passes", 30, False, |
| 261 | + f"npm install failed: {install.stderr.strip()[:200]}") |
| 262 | + return |
| 263 | + try: |
| 264 | + test = subprocess.run(["npm", "test", "--silent"], |
| 265 | + cwd=root, capture_output=True, text=True, timeout=600, env=env) |
| 266 | + except subprocess.TimeoutExpired: |
| 267 | + # A suite that never finishes IS a property of the project. |
| 268 | + report.add("functional", "npm test passes", 30, False, "npm test timed out after 600s") |
| 269 | + return |
| 270 | + lines = [ln for ln in (test.stdout + test.stderr).splitlines() if ln.strip()] |
| 271 | + tail = lines[-1] if lines else "(no output)" |
| 272 | + report.add("functional", "npm test passes", 30, test.returncode == 0, |
| 273 | + f"exit {test.returncode}: {tail[:200]}") |
| 274 | + |
| 275 | + |
| 276 | +# ------------------------------------------------------ quality observations |
| 277 | + |
| 278 | +def observe_quality(root: Path, report: Report) -> None: |
| 279 | + """Quality (15) is judgment. Report inputs to that judgment; score nothing.""" |
| 280 | + report.observations.append( |
| 281 | + f"README present: {(root / 'README.md').is_file()}" |
| 282 | + ) |
| 283 | + pkg = root / "package.json" |
| 284 | + if pkg.is_file(): |
| 285 | + try: |
| 286 | + meta = json.loads(pkg.read_text()) |
| 287 | + have = [k for k in ("description", "license", "author") if meta.get(k)] |
| 288 | + report.observations.append(f"package.json metadata present: {have or 'none'}") |
| 289 | + except json.JSONDecodeError: |
| 290 | + report.observations.append("package.json: not valid JSON") |
| 291 | + error_types: set[str] = set() |
| 292 | + for f in _list_files(root): |
| 293 | + if f.suffix in (".js", ".mjs", ".cjs", ".ts"): |
| 294 | + text = (root / f).read_text(errors="replace") |
| 295 | + error_types.update(re.findall(r"class\s+(\w*Error)\s+extends", text)) |
| 296 | + report.observations.append(f"custom error types: {sorted(error_types) or 'none'}") |
| 297 | + |
| 298 | + |
| 299 | +# ------------------------------------------------------------------- output |
| 300 | + |
| 301 | +def render(report: Report) -> str: |
| 302 | + lines = [f"project: {report.project}", ""] |
| 303 | + by_dim: dict[str, list[Check]] = {} |
| 304 | + for c in report.checks: |
| 305 | + by_dim.setdefault(c.dimension, []).append(c) |
| 306 | + for dim, checks in by_dim.items(): |
| 307 | + earned = sum(c.points for c in checks if c.passed is True) |
| 308 | + scoreable = sum(c.points for c in checks if c.passed is not None) |
| 309 | + total = sum(c.points for c in checks) |
| 310 | + head = f"{dim} — {earned}/{scoreable} scoreable" |
| 311 | + if scoreable < total: |
| 312 | + head += f" ({total - scoreable} pts unscorable here)" |
| 313 | + lines.append(head) |
| 314 | + for c in checks: |
| 315 | + mark = {True: "PASS", False: "fail", None: " — "}[c.passed] |
| 316 | + lines.append(f" [{mark}] ({c.points:>2}) {c.name}: {c.evidence}") |
| 317 | + lines.append("") |
| 318 | + lines.append(f"deterministic total: {report.earned()}/{report.scoreable()} scoreable points") |
| 319 | + lines.append("quality (15 pts): judgment — observations only:") |
| 320 | + for o in report.observations: |
| 321 | + lines.append(f" - {o}") |
| 322 | + return "\n".join(lines) |
| 323 | + |
| 324 | + |
| 325 | +def score(root: Path, run_tests: bool = False) -> Report: |
| 326 | + report = Report(project=str(root)) |
| 327 | + score_structure(root, report) |
| 328 | + score_hygiene(root, report) |
| 329 | + score_git(root, report) |
| 330 | + score_functional(root, report, run_tests) |
| 331 | + observe_quality(root, report) |
| 332 | + return report |
| 333 | + |
| 334 | + |
| 335 | +def main(argv: list[str] | None = None) -> int: |
| 336 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 337 | + parser.add_argument("project_dir", type=Path) |
| 338 | + parser.add_argument("--run-tests", action="store_true", |
| 339 | + help="execute npm install + npm test for the Functional dimension") |
| 340 | + parser.add_argument("--json", action="store_true", help="emit the report as JSON") |
| 341 | + args = parser.parse_args(argv) |
| 342 | + |
| 343 | + root = args.project_dir |
| 344 | + if not root.is_dir(): |
| 345 | + print(f"error: {root} is not a directory", file=sys.stderr) |
| 346 | + return 2 |
| 347 | + report = score(root, run_tests=args.run_tests) |
| 348 | + if args.json: |
| 349 | + print(json.dumps(asdict(report), indent=2)) |
| 350 | + else: |
| 351 | + print(render(report)) |
| 352 | + return 0 |
| 353 | + |
| 354 | + |
| 355 | +if __name__ == "__main__": |
| 356 | + raise SystemExit(main()) |
0 commit comments