Files
juxt__allium/tests/tier1-language.mjs
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

146 lines
4.5 KiB
JavaScript

// Tier 1 — Language fixtures
//
// Walks tests/fixtures/language/, runs `allium check` against each .allium
// fixture in parallel, and asserts:
//
// - valid/ files exit 0
// - invalid/ files exit non-zero
// - invalid/ files match every regex in their .expected sidecar
//
// Drift handling: fixtures listed in tests/fixtures/language/drift.json
// document a gap between the language reference and the CLI. The runner
// skips the regular invalid-check for these and emits a 'drift:' line
// instead, so the suite stays green while the gap stays visible. If
// the CLI ever starts emitting the expected diagnostic (the drift
// entry's expected_patterns now match), the runner FAILs the fixture
// to prompt removal of the stale drift entry.
//
// Usage:
// node tests/tier1-language.mjs # all fixtures
// node tests/tier1-language.mjs entities # filter by category
// node tests/tier1-language.mjs --quiet # suppress per-fixture pass lines
import path from "path";
import { fileURLToPath } from "url";
import { walk } from "./lib/fixture-walker.mjs";
import { check, isAvailable } from "./lib/allium-cli.mjs";
import { createReporter, summarise } from "./lib/reporter.mjs";
import { printBanner } from "./lib/banner.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.dirname(HERE);
const FIXTURES = path.join(HERE, "fixtures", "language");
export async function run({ filters = [], quiet = false } = {}) {
const reporter = createReporter({ quiet });
reporter.section("Tier 1 — language fixtures");
if (!(await isAvailable())) {
reporter.skip("tier1", "`allium` CLI not on PATH");
return reporter.getCounters();
}
const fixtures = walk(FIXTURES).filter((f) => matches(f, filters));
if (fixtures.length === 0) {
reporter.skip("tier1", `no fixtures matched ${JSON.stringify(filters)}`);
return reporter.getCounters();
}
await Promise.all(
fixtures.map((fixture) => verify(fixture, reporter))
);
return reporter.getCounters();
}
function matches(fixture, filters) {
if (filters.length === 0) return true;
return filters.some(
(f) =>
f === fixture.category ||
f === fixture.expectation ||
f === fixture.version
);
}
async function verify(fixture, reporter) {
const name = path.relative(ROOT, fixture.path);
const startedAt = Date.now();
const result = await check(fixture.path);
const elapsed = Date.now() - startedAt;
const haystack = `${result.stdout}\n${result.stderr}`;
// Drift handling first: if a fixture is in drift.json, the runner
// accepts the actual CLI behaviour (which disagrees with the spec)
// and emits a 'drift:' line. If the CLI ever starts producing the
// diagnostic the .expected sidecar describes, the drift entry is
// stale and the fixture FAILs to prompt its removal.
if (fixture.drift) {
const allPatternsMatch =
fixture.expectedPatterns.length > 0 &&
fixture.expectedPatterns.every((p) => p.test(haystack));
if (allPatternsMatch && !result.ok) {
reporter.fail(
name,
`drift entry is stale: CLI now emits the expected diagnostic — remove this fixture's entry from drift.json`,
elapsed
);
return;
}
reporter.drift(
name,
`${fixture.drift.spec_says ?? "spec/CLI gap"} — CLI: ${
fixture.drift.cli_does ?? "different behaviour"
}`,
elapsed
);
return;
}
if (fixture.expectation === "valid") {
if (result.ok) {
reporter.pass(name, elapsed);
} else {
reporter.fail(
name,
`expected pass, got exit ${result.code}: ${oneLine(result.stderr)}`,
elapsed
);
}
return;
}
// expectation === "invalid"
if (result.ok) {
reporter.fail(name, "expected failure, got exit 0", elapsed);
return;
}
for (const pattern of fixture.expectedPatterns) {
if (!pattern.test(haystack)) {
reporter.fail(
name,
`expected pattern ${pattern} not found in CLI output`,
elapsed
);
return;
}
}
reporter.pass(name, elapsed);
}
function oneLine(s) {
return (s ?? "").split("\n").filter(Boolean).join(" | ").slice(0, 200);
}
// CLI entry — only runs when invoked directly, not when imported.
if (import.meta.url === `file://${process.argv[1]}`) {
printBanner();
const filters = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const quiet = process.argv.includes("--quiet");
const counters = await run({ filters, quiet });
summarise("Tier 1", counters);
process.exit(counters.failed === 0 ? 0 : 1);
}