mirror of
https://github.com/juxt/allium.git
synced 2026-09-14 13:56:44 +08:00
32a03809c0
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>
146 lines
4.5 KiB
JavaScript
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);
|
|
}
|