raw
js
#!/usr/bin/env node
/**
 * Spec runner: judges `*.spec.md` assertions against the codebase.
 *
 * Each `*.spec.md` file is split into assertions — one per level-2 heading —
 * and every assertion is handed to an LLM judge that reads whatever the
 * assertion implicates and returns a verdict (pass, fail, or refused) with its
 * reasoning. Orchestration is plain code; only the judging is an LLM.
 *
 * Usage:
 *   node spec.js                          judge every **\/*.spec.md
 *   node spec.js skills/testing           narrow to a path or glob
 *   node spec.js a.spec.md --consensus 3  sample 3 judges per assertion
 *
 * Run `node spec.js --help` for the full list of flags.
 */

import { spawn } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { globSync } from "glob";
import matter from "gray-matter";

// Child claude processes detect CLAUDECODE and refuse to launch as a nested
// session, so clear it before spawning any. Mirrors eval.js.
delete process.env.CLAUDECODE;

const VERDICTS = ["pass", "fail", "refused"];

const VERDICT_LABELS = {
  pass: "PASS",
  fail: "FAIL",
  refused: "REFUSED",
  error: "ERROR",
};

/**
 * Entry point: load the matched specs, judge every assertion, write the report,
 * and exit non-zero if any assertion failed or errored.
 */
async function main() {
  const specs = loadSpecs(specGlobs);
  announcePlan(specs);
  const fileResults = await judgeSpecs(specs);
  const summary = await writeReport(fileResults);
  reportSummary(summary);
  process.exit(summary.failed > 0 || summary.errored > 0 ? 1 : 0);
}

const HELP = `\
Usage: node spec.js [paths-or-globs...] [options]

Judges every \`*.spec.md\` assertion against the current codebase.

Options:
  --consensus N    Judge calls per assertion; majority wins (default: 1)
  --agree K        Votes required to settle a verdict (default: majority of N)
  --concurrency N  Max judge calls in flight at once (default: 8)
  --retries N      Retries on a malformed judge response (default: 2)
  --report PATH    Also write the markdown report to this path
  --json PATH      Write full results as JSON
  --help           Show this help message`;

const NUMBER_FLAGS = {
  "--consensus": "consensus",
  "--agree": "agree",
  "--concurrency": "concurrency",
  "--retries": "retries",
};

const PATH_FLAGS = {
  "--report": "reportPath",
  "--json": "jsonPath",
};

const DEFAULT_OPTIONS = {
  specGlobs: [],
  consensus: 1,
  agree: 0,
  concurrency: 8,
  retries: 2,
  reportPath: "",
  jsonPath: "",
};

/**
 * Turn raw argv into a validated options object. Folds the flags in via
 * `readFlags`, rejects a `--consensus` below 1, and defaults `--agree` to a
 * strict majority of the sample count when the caller left it unset.
 */
function parseArgs(argv) {
  const options = readFlags(argv, DEFAULT_OPTIONS);
  if (options.consensus < 1) {
    console.error("Error: --consensus must be at least 1.");
    process.exit(1);
  }
  if (options.agree) return options;
  return { ...options, agree: strictMajority(options.consensus) };
}

/** Smallest vote count that is a strict majority of `sampleCount`. */
function strictMajority(sampleCount) {
  return Math.floor(sampleCount / 2) + 1;
}

/**
 * Read flags off the front of `argv`, folding each into a fresh options object:
 * a value flag also consumes the token after it, and any bare argument is a
 * spec path/glob. Recurs on what's left rather than advancing a cursor, so
 * nothing is mutated in place.
 */
function readFlags(argv, options) {
  if (argv.length === 0) return options;
  const [arg, ...rest] = argv;
  if (arg === "--help") {
    console.log(HELP);
    process.exit(0);
  }
  if (arg in NUMBER_FLAGS) {
    const [value, ...remaining] = rest;
    return readFlags(remaining, {
      ...options, [NUMBER_FLAGS[arg]]: Number(value),
    });
  }
  if (arg in PATH_FLAGS) {
    const [value, ...remaining] = rest;
    return readFlags(remaining, {
      ...options, [PATH_FLAGS[arg]]: value,
    });
  }
  return readFlags(rest, {
    ...options, specGlobs: [...options.specGlobs, arg],
  });
}

/**
 * Discover the spec files for `globs` and parse each into assertions, exiting
 * with a message when nothing matches or the matched files hold no assertions.
 */
function loadSpecs(globs) {
  const specPaths = discoverSpecs(globs);
  if (specPaths.length === 0) {
    console.error("Error: no *.spec.md files matched.");
    process.exit(1);
  }
  const specs = specPaths.map((filePath) => ({
    filePath,
    specRelPath: relative(process.cwd(), filePath),
    assertions: parseSpec(filePath),
  }));
  if (countAssertions(specs) === 0) {
    console.error("Error: spec files matched but contain no assertions.");
    process.exit(1);
  }
  return specs;
}

/** Total number of assertions across every spec. */
function countAssertions(specs) {
  return specs.reduce((sum, spec) => sum + spec.assertions.length, 0);
}

/**
 * Print the run plan — file and assertion counts, plus consensus settings — to
 * stderr before judging starts.
 */
function announcePlan(specs) {
  const count = countAssertions(specs);
  console.error(`Specs: ${specs.length} file(s), ${count} assertion(s)`);
  console.error(
    `Consensus: ${consensus} sample(s)` +
    (consensus > 1 ? `, ${agree} to agree` : "") +
    `, concurrency ${concurrency}\n`,
  );
}

// A trailing slash on a directory glob, dropped before the spec suffix.
const TRAILING_SLASH_RE = /\/$/;

/**
 * Expand the caller's paths/globs into spec file paths. A bare directory means
 * "every spec under it"; an explicit file is taken as-is. With no arguments,
 * glob the whole repo. `node_modules` is always excluded.
 */
function discoverSpecs(globs) {
  const patterns = globs.length ? globs : ["**/*.spec.md"];
  const expanded = patterns.flatMap((pattern) => {
    if (pattern.endsWith(".spec.md")) return [pattern];
    return [`${pattern.replace(TRAILING_SLASH_RE, "")}/**/*.spec.md`];
  });
  const matches = globSync(expanded, { ignore: "**/node_modules/**" });
  return [...new Set(matches.map((path) => resolve(path)))].sort();
}

// A level-2 ATX heading (`## …`); capture group 1 is the heading text.
const H2_HEADING_RE = /^##\s+(.*?)\s*$/;

/**
 * Each level-2 heading is an assertion; the prose beneath it (up to the next
 * `##`) is the body the judge uses as context. Content above the first `##`
 * (title, intro) is not an assertion.
 */
function parseSpec(filePath) {
  const { content } = matter(readFileSync(filePath, "utf-8"));
  const assertions = [];
  let current = null;
  for (const line of content.split("\n")) {
    const match = line.match(H2_HEADING_RE);
    if (match) {
      current = { heading: match[1], slug: slugify(match[1]), body: [] };
      assertions.push(current);
      continue;
    }
    if (current) current.body.push(line);
  }
  return assertions.map((assertion) => ({
    heading: assertion.heading,
    slug: assertion.slug,
    body: assertion.body.join("\n").trim(),
  }));
}

// Strip the characters VitePress drops when it slugs a heading, so a slug here
// matches the anchor the rendered page would emit.
const SLUG_STRIP_RE = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
// A run of two or more dashes, collapsed to one so separators don't pile up.
const REPEATED_DASHES_RE = /-{2,}/g;
// Leading or trailing dashes, trimmed so a slug never starts or ends with one.
const EDGE_DASHES_RE = /^-+|-+$/g;

/**
 * Matches VitePress's heading-anchor slugs so a slug here lines up with the
 * anchor the rendered page emits.
 */
function slugify(heading) {
  return heading
    .replace(/`/g, "")
    .normalize("NFKD")
    .replace(SLUG_STRIP_RE, "-")
    .replace(REPEATED_DASHES_RE, "-")
    .replace(EDGE_DASHES_RE, "")
    .toLowerCase();
}

/**
 * Judge every assertion across all specs, capping concurrent judge calls, then
 * regroup the verdicts back under their originating spec file.
 */
async function judgeSpecs(specs) {
  const jobs = specs.flatMap((spec) =>
    spec.assertions.map((assertion) => async () => {
      console.error(`  judging: ${spec.specRelPath} › ${assertion.heading}`);
      const result = await judgeAssertion(assertion, spec.specRelPath);
      const label = VERDICT_LABELS[result.verdict];
      console.error(`  ${label}: ${assertion.heading}`);
      return { specFilePath: spec.filePath, result };
    }),
  );
  const judged = await pool(jobs, concurrency);
  return regroupBySpec(specs, judged);
}

/** Regroup judged assertions back under their spec file, preserving order. */
function regroupBySpec(specs, judged) {
  return specs.map((spec) => ({
    specRelPath: spec.specRelPath,
    assertions: judged
      .filter((job) => job.specFilePath === spec.filePath)
      .map((job) => job.result),
  }));
}

/**
 * Sample `consensus` times and settle on the verdict with the most votes. With
 * consensus > 1, a winner that fails to reach the `agree` threshold is reported
 * as "refused" — the judges could not agree, so the assertion is not
 * confidently settled. Ties break toward the outcome that demands attention
 * (fail, then refused, then pass).
 */
async function judgeAssertion(assertion, specRelPath) {
  // A sample that still fails after its retries is an operational error, not a
  // verdict. Drop it and keep the samples that did return; only when every
  // sample fails does the assertion become an "error" — that way one flaky
  // judge call never aborts the whole run or discards the other verdicts.
  const samples = [];
  let remaining = consensus;
  while (remaining-- > 0) {
    try {
      samples.push(await judgeWithRetry(assertion, specRelPath));
    } catch (error) {
      console.error(`  error ${assertion.slug}: ${error.message}`);
    }
  }
  if (samples.length === 0) {
    return {
      ...assertion,
      verdict: "error",
      votes: { pass: 0, fail: 0, refused: 0 },
      samples: [],
      reasoning: "The judge failed to return a valid verdict after retries.",
    };
  }
  const votes = { pass: 0, fail: 0, refused: 0 };
  for (const sample of samples) votes[sample.verdict]++;
  const winner = leadingVerdict(votes);
  const settled = consensus > 1 && votes[winner] < agree ? "refused" : winner;
  // Surface a representative rationale: the first sample that matched the
  // settled verdict, else the first sample overall.
  const match = samples.find((sample) => sample.verdict === settled);
  return {
    ...assertion,
    verdict: settled,
    votes,
    samples,
    reasoning: (match ?? samples[0]).reasoning,
  };
}

/**
 * Verdict with the most votes, breaking ties toward the outcome that demands
 * attention: fail, then refused, then pass.
 */
function leadingVerdict(votes) {
  const order = ["fail", "refused", "pass"];
  let winner = order[0];
  for (const candidate of order) {
    if (votes[candidate] > votes[winner]) winner = candidate;
  }
  return winner;
}

/**
 * Retry on a malformed judge response, which is a transient defect rather than
 * a verdict.
 */
async function judgeWithRetry(assertion, specRelPath) {
  let lastError;
  let attemptsLeft = retries + 1;
  while (attemptsLeft-- > 0) {
    try {
      return await judgeOnce(assertion, specRelPath);
    } catch (error) {
      lastError = error;
      console.error(`  retry ${assertion.slug}: ${error.message}`);
    }
  }
  throw lastError;
}

// A JSON object anywhere in the judge's response (it may wrap the object in
// prose or fences despite instructions to the contrary).
const JSON_OBJECT_RE = /\{[\s\S]*\}/;

/**
 * Run one judge call for an assertion and parse its verdict. Throws when the
 * response carries no JSON object or an unrecognized verdict, so the caller can
 * retry the malformed sample.
 */
async function judgeOnce(assertion, specRelPath) {
  // The prompt is passed first, before the flags: `--allowedTools` is variadic
  // (`<tools...>`) and greedily consumes every trailing argument, so a prompt
  // placed after it would be swallowed as another tool name.
  const stdout = await exec("claude", [
    judgePrompt(assertion, specRelPath),
    "-p",
    "--output-format", "json",
    "--permission-mode", "bypassPermissions",
    "--allowedTools", "Read", "Grep", "Glob",
  ]);
  const text = parseResult(stdout);
  const jsonMatch = text.match(JSON_OBJECT_RE);
  if (!jsonMatch) {
    throw new Error(`no JSON object in judge response: ${text.slice(0, 200)}`);
  }
  const parsed = JSON.parse(jsonMatch[0]);
  if (!VERDICTS.includes(parsed.verdict)) {
    throw new Error(`malformed verdict: ${JSON.stringify(parsed.verdict)}`);
  }
  return { reasoning: parsed.reasoning ?? "", verdict: parsed.verdict };
}

const MODULE_DIR = dirname(fileURLToPath(import.meta.url));

/**
 * The `file()` pattern from skills/nodejs/files/file, copied in and adapted for
 * ESM: read a path relative to this module with a default UTF-8 encoding that
 * callers can override, so it resolves the same no matter where the process is
 * started. The published `@chriscalo/file` resolves its caller through the V8
 * stack as a `file://` URL and never converts it back, so it throws from an ESM
 * module like this one; anchoring on `import.meta.url` is the ESM-correct form.
 */
function file(filePath, options = {}) {
  return readFileSync(resolve(MODULE_DIR, filePath), {
    encoding: "utf-8",
    ...options,
  });
}

// The judge prompt lives in its own file so it reads and edits as prose, not as
// a string literal buried in code. `{{name}}` tokens are filled per assertion.
const JUDGE_PROMPT_TEMPLATE = file("./spec-judge.prompt.md").trimEnd();

/** Fill the judge prompt template with one assertion's fields. */
function judgePrompt(assertion, specRelPath) {
  return fillTemplate(JUDGE_PROMPT_TEMPLATE, {
    specRelPath,
    heading: assertion.heading,
    body: assertion.body || "(no elaboration provided)",
  });
}

const TEMPLATE_TOKEN_RE = /\{\{(\w+)\}\}/g;

/**
 * Replace every `{{name}}` token with `values[name]` in a single pass, so a
 * value that itself contains `{{...}}` is never re-scanned. An unknown token is
 * left intact rather than silently dropped — that surfaces a template typo.
 */
function fillTemplate(template, values) {
  return template.replace(TEMPLATE_TOKEN_RE, (token, name) =>
    name in values ? values[name] : token,
  );
}

/**
 * Spawn a command, collect stdout, and resolve when it exits. detached: true so
 * child claude processes survive this runner's process-group management when it
 * runs inside Claude Code. Mirrors eval.js.
 */
function exec(cmd, cmdArgs, opts = {}) {
  return new Promise((resolveExec, reject) => {
    const child = spawn(cmd, cmdArgs, {
      stdio: ["ignore", "pipe", "pipe"],
      detached: true,
      ...opts,
    });
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (chunk) => (stdout += chunk));
    child.stderr.on("data", (chunk) => (stderr += chunk));
    child.on("error", reject);
    child.on("exit", (code, signal) => {
      if (signal) return reject(new Error(`${cmd} killed by ${signal}`));
      if (code !== 0) {
        return reject(
          new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`),
        );
      }
      resolveExec(stdout);
    });
  });
}

/**
 * Unwrap the JSON result from `claude -p --output-format json`, falling back to
 * the raw stdout when it is not the expected envelope.
 */
function parseResult(stdout) {
  try {
    const parsed = JSON.parse(stdout);
    return parsed.result || stdout;
  } catch {
    return stdout;
  }
}

/**
 * Run `tasks` (thunks returning promises) at most `limit` at a time so a large
 * run does not spawn hundreds of claude processes at once.
 */
async function pool(tasks, limit) {
  const results = new Array(tasks.length);
  let next = 0;
  async function worker() {
    while (next < tasks.length) {
      const index = next++;
      results[index] = await tasks[index]();
    }
  }
  const workers = Array.from(
    { length: Math.min(limit, tasks.length) },
    () => worker(),
  );
  await Promise.all(workers);
  return results;
}

/**
 * Render the markdown report, print it to stdout, optionally persist it and a
 * JSON dump, and return the verdict tally for the exit-code decision.
 */
async function writeReport(fileResults) {
  const meta = await gitMeta();
  const timestamp = new Date().toISOString();
  const { report, passed, failed, refused, errored } = generateReport(
    fileResults,
    meta,
    timestamp,
  );
  process.stdout.write(report);
  if (reportPath) {
    writeFileSync(resolve(reportPath), report);
    console.error(`\nReport: ${reportPath}`);
  }
  if (jsonPath) {
    const jsonOutput = {
      timestamp,
      commit: meta.commitHash,
      consensus,
      agree,
      summary: { passed, failed, refused, errored },
      specs: fileResults,
    };
    writeFileSync(
      resolve(jsonPath),
      JSON.stringify(jsonOutput, null, 2) + "\n",
    );
    console.error(`JSON: ${jsonPath}`);
  }
  return { passed, failed, refused, errored };
}

/**
 * Commit hash and dirty-tree state for the report header. Unknown outside a git
 * repo rather than fatal — the report still stands on its own.
 */
async function gitMeta() {
  let commitHash = "unknown";
  let uncommitted = "unknown";
  try {
    commitHash = (await exec("git", ["rev-parse", "--short", "HEAD"])).trim();
  } catch { /* not a git repo */ }
  try {
    const status = (await exec("git", ["status", "--porcelain"])).trim();
    const changed = status ? status.split("\n").length : 0;
    uncommitted = changed ? `${changed} files changed` : "none";
  } catch { /* not a git repo */ }
  return { commitHash, uncommitted };
}

/** Build the markdown report and the verdict tally from the judged results. */
function generateReport(fileResults, meta, timestamp) {
  const lines = [
    `# Spec Run — ${timestamp}`,
    "",
    `**Commit:** ${meta.commitHash}`,
    `**Uncommitted changes:** ${meta.uncommitted}`,
    `**Consensus:** ${consensus} sample(s) per assertion` +
      (consensus > 1 ? `, ${agree} to agree` : ""),
  ];
  for (const { specRelPath, assertions } of fileResults) {
    lines.push("", "---", "", `## ${specRelPath}`, "");
    for (const assertion of assertions) {
      const label = VERDICT_LABELS[assertion.verdict];
      lines.push(`### ${label} — ${assertion.heading}`, "");
      if (consensus > 1) {
        lines.push(
          `Votes: ${assertion.votes.pass} pass, ` +
          `${assertion.votes.fail} fail, ${assertion.votes.refused} refused`,
          "",
        );
      }
      lines.push(assertion.reasoning || "(no reasoning returned)", "");
    }
  }
  const tally = tallyVerdicts(fileResults);
  lines.push(...summaryLines(tally));
  return { report: lines.join("\n") + "\n", ...tally };
}

/** Count assertions in each verdict bucket across every spec. */
function tallyVerdicts(fileResults) {
  const all = fileResults.flatMap((file) => file.assertions);
  function count(verdict) {
    return all.filter((assertion) => assertion.verdict === verdict).length;
  }
  return {
    total: all.length,
    passed: count("pass"),
    failed: count("fail"),
    refused: count("refused"),
    errored: count("error"),
  };
}

/** Render the summary table and one-line tally that close the report. */
function summaryLines({ total, passed, failed, refused, errored }) {
  const lines = ["", "---", "", "## Summary", ""];
  lines.push("| Bucket  | Count |");
  lines.push("| ------- | ----- |");
  lines.push(`| Passed  | ${passed} |`);
  lines.push(`| Failed  | ${failed} |`);
  lines.push(`| Refused | ${refused} |`);
  if (errored) lines.push(`| Errored | ${errored} |`);
  lines.push(
    "",
    `${total} assertions: ${passed} passed, ${failed} failed, ` +
    `${refused} refused` + (errored ? `, ${errored} errored` : ""),
  );
  return lines;
}

/** Print the one-line verdict tally to stderr after the report is written. */
function reportSummary({ passed, failed, refused, errored }) {
  console.error(
    `\n${passed} passed, ${failed} failed, ${refused} refused` +
    (errored ? `, ${errored} errored` : "") + ".",
  );
}

const {
  specGlobs, consensus, agree, concurrency, retries, reportPath, jsonPath,
} = parseArgs(process.argv.slice(2));

await main();