test-soundness-review
SkillAI & modelsRepo-owned test soundness auditor and navigator for citum-core. Reasons from a spec (not the implementation) to classify each test as good, suspicious, broken, or redundant; simultaneously reviews the spec for ambiguity, contradiction, and silence, halting to prompt the user when a spec defect blocks an honest verdict. Records state in the living ledger docs/architecture/TEST_SOUNDNESS_STATUS.md — never ephemeral JSON.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the test-soundness-review skill
What this skill tells your AI
The instructions your AI receives, as published by citum/citum-core in .skills/test-soundness-review/SKILL.md and read by ahel’s review.
Audit test functions against the spec that governs them, and keep a durable
record of the state of test quality across citum-core. The goal is to find
tests that could pass while the implementation still violates the spec — and
tests that exist for their own sake and prove nothing — not to review code
style.
The verdict bar is the shared "What makes a test worth keeping" contract in
docs/guides/CODING_STANDARDS.md. That
section is the source of truth; this skill operationalises it. If this skill and
that contract ever disagree, the contract wins — fix the skill.
Arguments
/test-soundness-review [spec-path] [test-file-or-glob]
Examples:
/test-soundness-review docs/specs/SORTING.md crates/citum-engine/tests/
/test-soundness-review docs/specs/DISAMBIGUATION.md crates/citum-engine/tests/citations.rs
/test-soundness-review # no args — navigate the ledger, propose what's next
Both arguments are optional. With no arguments, run Step 0 only and stop — the skill becomes a way to navigate test-quality state. With one argument, ask for the missing one before proceeding.
Step 0 — Read the ledger first (always)
Read docs/architecture/TEST_SOUNDNESS_STATUS.md
before anything else. It is the index of what has been audited, addressed, and
what remains todo.
- No spec named → summarise the ledger (counts by status, any
needs-reworkrows blocked on a spec decision) and offer the next fewtodorows ranked by risk. Stop there; let the user pick. - Spec named → find its row (or note that you'll create one). If the row is
already
addressed, say so and ask whether to re-audit before spending effort.
The ledger is also where you'll write back at the end (Step 6). Treat it as the entry and exit point of every run.
Step 1 — Read the spec and build the behaviour map
Read <spec-path> in full. Extract:
- Documented behaviours (numbered sections, rules, invariants).
- Acceptance criteria (checklist items).
- Strategy cascades / decision trees.
- Explicit "must", "must not", "is never consulted", "always" statements.
Build a map: spec section → observable behaviour. You'll use it both to judge each test and to drive the spec review in Step 2.
Step 2 — Review the spec itself (first-class, runs alongside test reading)
A test audit is only as trustworthy as the spec it reasons from. As you build the behaviour map, record spec defects in three buckets:
- Ambiguity — a rule that admits two readings, where a test could be "right"
under either. (Most dangerous: it lets a test launder the ambiguity into a
false
good.) - Contradiction — two sections that cannot both hold.
- Silence — a behaviour a test asserts, or a fixture clearly implies, that the spec never states.
For each defect, classify its audit impact:
blocking— you cannot honestly classify a test without the user first deciding what the spec means.advisory— worth fixing, but doesn't change any verdict.
Gating. If any defect is blocking, stop and prompt the user before
finalising verdicts. Quote the spec section, name the affected tests, and give
2–3 concrete resolution options. Do not pick a reading yourself: choosing one
silently is exactly the failure this skill exists to catch, and per the repo
rule you "never make content decisions unilaterally." Advisory defects are
recorded (Step 6) and summarised, but don't halt the run.
Step 3 — Enumerate and read the tests
Enumerate test functions in the glob. Prefer jcodemunch (get_file_outline,
get_symbol) over raw reads — it's the repo's standard and token-efficient.
Fallback:
grep -nE "fn |#\[test\]|#\[rstest\]" <file>
Also collect any announce_behavior(...) text or docstrings — a second source
of "what the author thought this checks".
For each test, read the body: fixture setup, the call under test, and every
assertion. Read shared helpers it calls (e.g. run_test_case_native,
build_author_date_style) — the helper's defaults change what the test proves.
Step 4 — Classify each test
Reason from the spec, not the implementation. The question is always: could this test pass while the implementation still violates the spec section it claims to cover? — and does this test make an independent claim at all?
Verdicts
broken — passes but proves nothing about the spec:
contains()/!contains()on rendered output under ~30 chars (a garbled renderer can still hit a short substring). Note: the repo banscontains()on rendered output outright unless the substring is ≥30 chars and the test name signals it — see CODING_STANDARDS §Test Independence.- No assertion at all (vacuous).
- Asserts an intermediate value but never the final rendered output.
- A stale capture stub left in (
panic!("intentional capture")).
suspicious — may be correct but needs investigation:
- Name /
announce_behaviordescribes behaviour X, but the fixture can never trigger X (e.g. "given-name expansion applied" where every colliding ref has a different year, so no collision group ever forms). - Expected output looks invented rather than captured (round numbers, suspiciously clean delimiters that don't match the renderer).
announce_behaviorclaims "subsequent form" / "repeat citation" but the test issues one batch with noPosition::Subsequent.- Counts occurrences (
matches(...).count()) without verifying location.
redundant — passes, may even be correct, but adds no independent claim
(over-testing; see the shared contract). Recommend delete or merge into the
canonical sibling, not rewrite — fewer sharp tests beat many overlapping ones,
and deletion is an encouraged outcome here:
- Near-duplicate of an existing test on the same fixture + same assertion dimension (no new type, field shape, position, or edge condition).
- Tautological / self-evidently true:
assert!(true), asserting a literal you just constructed, round-tripping a value through no transformation. - Tests language/library behaviour rather than Citum behaviour.
- Coverage theatre — exists only to touch a line, no observable-behaviour claim.
good — exact assert_eq! on full rendered output, fixture genuinely
triggers the documented behaviour, name matches what is tested, and it covers a
dimension no sibling already covers.
Coverage gaps
For each spec section with documented behaviour but no corresponding test,
record a gap with a concrete recommendation (the fixture shape + assertion to
add). Cross-check the spec's acceptance criteria for premature [x]: if a
criterion is marked done but the function it depends on doesn't exist, record it
as a gap and recommend reverting the [x].
Step 5 — Execute follow-on actions: Fix → Trim → Add → Persist
After presenting the per-test table and coverage gaps, proceed immediately to Fix → Trim → Add → Persist in that order. State the phase and what you are about to do before each one; the user can say "stop" or "skip " to redirect. The default is to do everything — do not stop to ask permission unless a decision is genuinely unresolvable (ambiguous spec, missing fixture data, failing gate).
- Fix — realign suspicious tests; rewrite broken assertions to exact
assert_eq!. For each test marked broken or suspicious: if fixing requires pinning an expected string, use the capture-and-pin workflow — add a transienteprintln!+ forcedpanic!("capture"), run just that test, read stderr/stdout, remove the stub, pin the real string. Never invent expected values. Runcargo nextest runafter the Fix batch. - Trim — delete or merge
redundanttests. For each deletion: confirm the surviving sibling still covers the dimension (grep for the spec section; verify at least onegoodtest covers it) before deleting. Runcargo nextest runafter the Trim batch. - Add — add tests for coverage gaps, capturing actual output first (same
capture-and-pin workflow). Prioritise: (1) gaps that would catch a realistic
regression, (2) gaps flagged as a spec-silence needing resolution, (3) the rest.
Run
cargo nextest runafter the Add batch. - Persist — Step 6 (always, even if Fix/Trim/Add were skipped).
Step 6 — Persist state (replaces the old JSON dump)
Upsert the spec's row in docs/architecture/TEST_SOUNDNESS_STATUS.md:
| Spec / Module | Last reviewed | Tests (G/S/B/R) | Open spec issues | Status | Notes |
Last reviewed: today.Tests (G/S/B/R): the four counts.Open spec issues: refs to unresolved spec defects, or—.Status:todo(never audited) ·audited(reviewed, findings open) ·addressed(findings fixed) ·needs-rework(blocked on a spec decision).Notes: one-liner summarising what changed — e.g. "Deleted 3 vacuous tests; added 2 gap tests; clarified 1 spec silence." Use—if nothing was changed.- Bump the ledger's "Last updated" banner.
An agent resuming work greps the ledger for todo / needs-rework.
Commit guidance: describe what changed, not just the meta-status. Split into two commits when both test and doc/spec files change:
test(engine): <what changed>— test files only; subject names the action (e.g. "trim 3 vacuous sort tests", "add citation-sort gap tests").docs(spec): <what changed>— spec, ledger, skill files; subject names the outcome (e.g. "clarify two SORTING spec silences"). Body 3–5 lines max.
Key rules
- Reason from the spec, not the code. If the spec says "year-suffix
collision key uses only
issuedyear", check whether the test would catch a regression tooriginal-date— not whether the code currently does the right thing. - Capture, don't invent. Invented expected strings are the primary source of
suspicious. Always capture real output before pinning. - Redundant is a defect, not a freebie. A test that adds no independent claim costs maintenance and dilutes signal. Trimming is success.
- Blocking spec defects stop the run. Don't guess a reading; prompt the user.
- Pre-commit gate when you touch
.rs:cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo nextest run. Skill/docs-only edits (.md) skip the Rust gate but should pass./scripts/validate-frontmatter.sh --copilot-strict.
Related
- Shared verdict bar:
docs/guides/CODING_STANDARDS.md§ "What makes a test worth keeping" and § "Test Independence". - Coverage / fixture-shape domain knowledge:
test-coverage. - Strategy context:
docs/guides/TEST_STRATEGY.md. - State ledger:
docs/architecture/TEST_SOUNDNESS_STATUS.md.
Signals
- GitHub stars
- 33
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
test-soundness-review- Source
- github.com/citum/citum-core