Files
Matt Ford 32a03809c0 Add four-tier test suite for the Allium project
Brings up a complete test infrastructure spanning four tiers, each
testing a different layer of the project. Built incrementally over
many smaller commits and squashed here.

Tier 1 — language fixtures (free, fast)
  Per-construct fixtures under tests/fixtures/lang/ that round-trip
  through `allium check`. Covers diagnostic codes the CLI emits, with
  a drift mechanism that keeps the corpus in sync as 3.2.x evolves.

Tier 2 — doc-example validation (free, fast)
  Extracts code blocks from skills/ and reference docs and validates
  each against the language. Inline annotations and an out-of-line
  override file (tests/fixtures/docs/overrides.json) handle the long
  tail of "this example is illustrative, not buildable."

Tier 3 — skill behavioural evals (gated --live, ~$0.05–$0.50/scenario)
  `claude -p` invokes a skill against a fixture workspace; a second
  `claude -p` call scores the result against a rubric (LLM-as-judge).
  Supports both bare-API and OAuth auth modes. Rubrics under
  tests/fixtures/evals/rubrics/.

Tier 4 — end-to-end pipeline (gated --live, ~$1–$10/scenario)
  Multi-step distill→weed→tend→propagate scenarios against checked-in
  fixture projects. Cumulative workspace state at the end of the run
  is compared file-by-file against accepted snapshots.

Tier 4 features developed across this work:

  • Dual snapshot comparison: text + Allium model JSON. Either being
    a match passes.
  • Snapshot-failure judge: LLM classifies diffs as cosmetic /
    structural / semantic and gates pass/fail accordingly. Default
    haiku, ~$0.05 per failed snapshot. Verdict cached per kept run.
  • Kept workspaces (--keep-workspace): preserves the workspace +
    per-step checkpoints + a manifest at tests/.tier4-runs/<scenario>-<ts>/
    so expensive generation output can be inspected, snapshotted,
    and replayed without regenerating.
  • Replay mode (--workspace <path>): re-runs the snapshot comparison
    against a kept workspace at no API cost.
  • Auto-resume (--workspace <path> after scenario steps were added):
    detects checkpoint vs scenario-step count, resumes from the last
    checkpoint as the working state.
  • Variance scenarios (repeat: N): a scenario can opt into running
    N independent times into runs/01..NN/ subdirs under one parent
    kept dir, with a configurable concurrency cap (--concurrency N,
    default 3). Used to investigate distill-output stability.
  • Convergence-report script (scripts/tier4-convergence.mjs): diffs
    a tracked file across consecutive checkpoints; with --judge,
    classifies each transition. Verdicts cached.
  • Variance-report script (scripts/tier4-variance.mjs): line-count
    distribution + pairwise (N choose 2) judge verdicts + clustering
    (cosmetic + structural pairs treated as behaviourally equivalent).
    Verdicts cached. --pair drilldown for one specific pair.

Auth modes: both bare ANTHROPIC_API_KEY and OAuth (--oauth) supported
across Tier 3 and Tier 4. The --json-schema flag was found incompatible
with OAuth/agent mode and dropped; envelope JSON is parsed instead.

UX: pretty output (colors + glyphs) with --plain opt-out, an allium
CLI version banner, in-place heartbeat updates on interactive
terminals, --verbose to stream claude stderr + pass --debug.

Snapshot baselines committed for the three node-todo scenarios
(distill-only, distill-then-tend, full-pipeline) plus the two
variance-investigation scenarios (weed-bounded, weed-convergence).

Documentation: tests/README.md (running, scenario authoring, kept
workspaces, replay, variance), tests/RATIONALE.md (the why behind
the tier split), and four iteration-by-iteration variance reports
in tests/docs/ that drove the prompt changes in the follow-up
commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:33:37 +01:00

197 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
//
// Top-level test orchestrator. Forwards to per-tier runners and existing
// scripts; sums their counters; exits non-zero on any failure.
//
// Usage:
// node scripts/test.mjs # all offline tiers
// node scripts/test.mjs --live # include API-using tiers when wired up
// node scripts/test.mjs tier1 # one group
// node scripts/test.mjs tier1 artifact # selected groups
// node scripts/test.mjs tier1 entities # group plus per-tier filter
//
// Groups currently wired:
// tier1 — language fixtures (offline, requires `allium` on PATH)
// artifact — forwards to scripts/test-skills.mjs (offline)
// hook — forwards to hooks/allium-check.test.mjs (offline)
//
// Tiers 2, 3 and 4 are planned but not yet implemented; they will be
// added as their runners land.
import { execFileSync } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
import { run as tier1Run } from "../tests/tier1-language.mjs";
import { run as tier2Run } from "../tests/tier2-docs.mjs";
import { run as tier3Run } from "../tests/tier3-evals.mjs";
import { run as tier4Run } from "../tests/tier4-e2e.mjs";
import { summarise } from "../tests/lib/reporter.mjs";
import { printBanner } from "../tests/lib/banner.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.dirname(HERE);
const argv = process.argv.slice(2);
const live = argv.includes("--live");
const flags = new Set(argv.filter((a) => a.startsWith("--")));
// Some flags take a value (e.g. `--workspace /path`). Skip the value
// when bucketing positional args so it doesn't get misread as a
// scenario filter or group name.
const VALUE_FLAGS = ["--workspace", "--repeats", "--concurrency"];
const valueIndices = new Set();
for (const f of VALUE_FLAGS) {
const i = argv.indexOf(f);
if (i >= 0 && i < argv.length - 1) valueIndices.add(i + 1);
}
const positional = argv.filter(
(a, i) => !a.startsWith("--") && !valueIndices.has(i)
);
function takeFlagValue(flag) {
const i = argv.indexOf(flag);
if (i < 0 || i === argv.length - 1) return null;
const v = argv[i + 1];
if (v.startsWith("--")) return null;
return v;
}
const KNOWN_GROUPS = ["tier1", "tier2", "tier3", "tier4", "artifact", "hook"];
const requestedGroups = positional.filter((a) => KNOWN_GROUPS.includes(a));
const filters = positional.filter((a) => !KNOWN_GROUPS.includes(a));
function shouldRun(group) {
return requestedGroups.length === 0 || requestedGroups.includes(group);
}
const totals = {
passed: 0,
failed: 0,
skipped: 0,
drifted: 0,
failures: [],
elapsedMs: 0, // wall-clock for the orchestrator, set just before summarise
};
function add(counters) {
totals.passed += counters.passed;
totals.failed += counters.failed;
totals.skipped += counters.skipped;
totals.drifted += counters.drifted ?? 0;
totals.failures.push(...(counters.failures ?? []));
}
const orchestratorStartedAt = Date.now();
printBanner();
// ---------------------------------------------------------------------------
// tier1 — language fixtures
// ---------------------------------------------------------------------------
if (shouldRun("tier1")) {
add(await tier1Run({ filters, quiet: flags.has("--quiet") }));
}
// ---------------------------------------------------------------------------
// tier2 — doc-example validation
// ---------------------------------------------------------------------------
if (shouldRun("tier2")) {
add(await tier2Run({ filters, quiet: flags.has("--quiet") }));
}
// ---------------------------------------------------------------------------
// tier3 — skill behavioural evals (gated behind --live; costs API spend)
// ---------------------------------------------------------------------------
if (shouldRun("tier3")) {
add(
await tier3Run({
filters,
live,
quiet: flags.has("--quiet"),
verbose: flags.has("--verbose"),
})
);
}
// ---------------------------------------------------------------------------
// tier4 — end-to-end pipeline (gated behind --live; multi-step, expensive)
// ---------------------------------------------------------------------------
if (shouldRun("tier4")) {
const repeatsArg = takeFlagValue("--repeats");
const concurrencyArg = takeFlagValue("--concurrency");
add(
await tier4Run({
filters,
live,
quiet: flags.has("--quiet"),
updateSnapshots: flags.has("--update-snapshots"),
verbose: flags.has("--verbose"),
keepWorkspace: flags.has("--keep-workspace"),
workspaceOverride: takeFlagValue("--workspace"),
judge: !flags.has("--no-judge"),
showDiff: flags.has("--diff"),
repeats: repeatsArg ? Math.max(1, parseInt(repeatsArg, 10)) : null,
concurrency: concurrencyArg ? Math.max(1, parseInt(concurrencyArg, 10)) : 3,
})
);
}
// ---------------------------------------------------------------------------
// artifact — existing skill-structure tests
// ---------------------------------------------------------------------------
if (shouldRun("artifact")) {
console.log("\nArtifact — scripts/test-skills.mjs");
const args = [path.join(ROOT, "scripts", "test-skills.mjs")];
if (live) args.push("--live");
add(forwardCounters("artifact", args));
}
// ---------------------------------------------------------------------------
// hook — existing hook tests
// ---------------------------------------------------------------------------
if (shouldRun("hook")) {
console.log("\nHook — hooks/allium-check.test.mjs");
add(forwardCounters("hook", [path.join(ROOT, "hooks", "allium-check.test.mjs")]));
}
totals.elapsedMs = Date.now() - orchestratorStartedAt;
summarise("Total", totals);
process.exit(totals.failed === 0 ? 0 : 1);
// ---------------------------------------------------------------------------
// Forwarding helper. Existing scripts use their own pass/fail counters and
// don't expose a programmatic API; we run them as subprocesses and infer
// counters from their final exit code. A non-zero exit becomes one failure.
//
// Reports elapsed wall-clock for the subprocess (the inner test counts
// aren't visible here, but the time spent running them is).
// ---------------------------------------------------------------------------
function forwardCounters(label, args) {
const startedAt = Date.now();
try {
execFileSync("node", args, { stdio: "inherit" });
const elapsedMs = Date.now() - startedAt;
console.log(` ${label} forwarded subprocess: ${formatDurationLocal(elapsedMs)}`);
return { passed: 1, failed: 0, skipped: 0, failures: [] };
} catch (e) {
const elapsedMs = Date.now() - startedAt;
console.log(` ${label} forwarded subprocess: ${formatDurationLocal(elapsedMs)}`);
return {
passed: 0,
failed: 1,
skipped: 0,
failures: [`${label} subprocess exited ${e.status ?? "non-zero"}`],
};
}
}
// Compact local copy of formatDuration so the orchestrator stays
// independent of the reporter's import surface for this small thing.
function formatDurationLocal(ms) {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return ms < 10000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`;
const totalSec = Math.round(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return s === 0 ? `${m}m` : `${m}m ${s}s`;
}