Test Craft
LLM-judgment critique of test quality across vitest / jest / mocha / playwright / pytest. Fourth member of the craft-pipeline initiative. Per-it/test block critique with best-effort source pairing for contract-vs-implementation rubrics. Tests are often the worst-written code in a codebase precisely because the rule-based floor (coverage threshold) is so easy to clear. Emits 3-axis findings (tier × impact × confidence per ADR 0019).
When to Use
- During PR review on code that adds or changes tests
- After ramping up coverage to audit whether new tests actually add signal
- When onboarding a contributor — audit tests they introduced
- Periodically to catch accumulated low-signal tests + redundant fixtures
- NOT for coverage analysis (use
vitest --coverage or harness-tdd — that's the floor)
- NOT for autofix / rewriting (v2's
align-test may add safe renames)
- NOT for
.test-d.ts type tests in v1 (v1.x — different rubric vocabulary)
- NOT for fixture / helper / mock files in v1 (v1.x)
- NOT for snapshot tests in v1 (different correctness criteria; v1.x)
- NOT for languages beyond TS/JS + Python (pytest) in v1 (v1.x)
Capability Roles
- Defines (Service Definition): the shared craft critique contract (
packages/cli/src/shared/craft/) — LlmProvider + finding/axes schema + run store — shared across all *-craft skills. This skill implements, and does not own, that contract.
- Provides (Provider): this skill — a test quality critique implemented over the shared contract (
packages/cli/src/test-craft/).
- Consumes (Consumer):
craft-fleet (the craft-pipeline elevation sweep) and the harness natural-language router, which invoke every *-craft provider uniformly through the shared critique/finding shape
Process
Phase 1: DISCOVER — Test files + framework
Read project configuration. Check harness.config.json for:
craft.test.enabled — gate (default true)
craft.test.maxFiles (default 100), craft.test.maxTestsPerFile (default 20)
craft.test.frameworks — restrict to subset (default: all five)
craft.test.sourcePair — toggle source-pairing (default true)
Glob test files under project root: **/*.{test,spec}.{ts,tsx,js,jsx,mjs,cjs,mts,cts} plus pytest naming (test_*.py / *_test.py). Skip node_modules, dist, build, coverage, __pycache__, venv, vendor, dotdirs. The extension list is defined once in extract/test-file-exts.ts and shared by discovery and extraction — a private second copy is what made *.test.mjs suites invisible (#1347).
Detect framework per file via import signatures (order matters; most-specific first):
@playwright/test → playwright
@jest/globals → jest
vitest → vitest
import 'mocha' → mocha
.py files → pytest (extraction is light-parse, not TS AST)
- Fallback: vitest (most common; jest-with-globals projects still extract correctly because the AST shape matches)
Phase 2: EXTRACT — Per-test AST walk
Single TS Compiler API walk per file. For each CallExpression:
- describe — push name onto nesting stack, recurse, pop
- it / test — extract
testName (first string-literal arg), capture current nesting, capture callback body text (truncated to 1500 chars in prompt)
- Modifiers handled:
.skip, .only, .todo (todo excluded from critique — no body to critique)
- Non-string-literal test names (computed / templates) skip silently
Phase 3: PAIR — Best-effort source resolution
For each test file, try in order:
- Sibling —
foo.test.ts → foo.ts (same dir)
- Co-located in src —
tests/foo.test.ts → ../src/foo.ts
- Monorepo-style —
tests/foo.test.ts → ../../src/foo.ts
When source resolves, content (truncated to 2000 chars) is added to the LLM prompt under Source under test:. This enables TEST-R007 (contract-not-implementation) to actually compare assertions against the function's public surface. When no source resolves, test-file-only rubrics still fire.
Phase 4: CRITIQUE — Per (test, rubric) loop
8 seed rubrics:
| Rubric |
Description |
TEST-R001 contract-not-narrative-name |
Test name describes the contract ("returns null when empty"), not narrative |
TEST-R002 meaningful-assertion |
Assertion proves something the implementation could plausibly violate |
TEST-R003 arrange-act-assert |
Three visually distinct phases, not interleaved |
TEST-R004 fixture-earns-setup-cost |
Heavy beforeEach must be justified by what's asserted |
TEST-R005 single-responsibility |
One assertion target per it; multiple unrelated assertions split poorly |
TEST-R006 deleting-loses-something |
Would removing this test lose specific coverage, or is it redundant? |
TEST-R007 contract-not-implementation |
Tests the documented behaviour, not the internal structure |
TEST-R008 explicit-failure-mode |
Failure message narrates what went wrong without reading the test |
For each (test, rubric) pair:
- Build prompt with rubric + test (name + nesting + body) + optional source + framework label.
- LLM returns fenced JSON:
null (rubric doesn't apply / test is fine) OR { tier, impact, confidence, message }.
- On non-null: emit
TestFinding with cite.rubricId for ADR 0020 traceability.
Phase 5: REPORT — Aggregate + cost telemetry
Emit TestCraftOutput:
{
findings: TestFinding[];
summary: {
phaseRun: ['critique'];
durationMs: number;
llmCalls: { provider, model, count, costUsd };
catalog: { rubricsApplied: string[] };
// critiqueErrors > 0 or testsTruncated > 0 means the run is partly
// unmeasured: findings are a floor, not a total.
counts: { filesScanned, testsExtracted, testsSkippedOrTodo, sourcePaired,
critiqueErrors, testsTruncated };
frameworksDetected: Record<TestFramework, number>;
runId: string;
}
}
Harness Integration
harness test-craft — CLI entry. --files / --frameworks / --max-files / --max-tests-per-file / --no-source-pair / --json / --verbose.
mcp__harness__test_craft — MCP tool. Same input/output. Consumed by agents.
- Cross-cutting API:
critiqueTestsInFile(file, opts) exported. Works on a single test file without project walk; honours framework filter and source-pairing toggle.
- Shared craft infrastructure: imports
LlmProvider + 3-axis types + derivePriority from packages/cli/src/shared/craft/.
Success Criteria
See docs/changes/craft-pipeline/test-craft/proposal.md for the full 36 success criteria. Highlights:
- 8 seed rubrics ship in
catalog/rubrics/<id>.ts (file-per-rubric, matches naming/spec/copy-craft)
- 3-axis output preserved (tier × impact × confidence)
cite.rubricId populated on every finding (ADR 0020)
- Framework detection:
@playwright/test / @jest/globals / vitest / mocha import signatures; vitest fallback
- Per-test extraction handles
.skip / .only / .todo with metadata; describe-chain nesting captured
- Source pairing best-effort with silent skip when no match
- Plugin slash-commands pre-generated (avoids CI drift failure pattern)
Rationalizations to Reject
These are common rationalizations that sound reasonable but lead to incorrect results. When you catch yourself thinking any of these, stop and follow the documented process instead.
| Rationalization |
Why It Is Wrong |
| "This test is green and coverage went up, so it's a good test." |
Coverage is the floor (vitest --coverage). Test-craft asks whether the assertion proves something the implementation could plausibly violate (TEST-R002). A green tautology adds zero signal. |
"expect(user).toBeDefined() after createUser(...) confirms it works, so R002 passes." |
The return is defined by the signature — the assertion cannot fail. That is the tautology TEST-R002 targets. Assert the contract (user.name), not mere existence. |
| "The test asserts the mock returned exactly what I stubbed, so behaviour is verified." |
Asserting the mock echoes your own setup, not the unit's behaviour. TEST-R007 wants assertions against the source's public contract, not the test's own scaffolding. |
| "Source pairing didn't resolve, so I'll still critique TEST-R007 from the test body." |
Without resolved source, contract-not-implementation has nothing to compare against and should skip. Inventing a contract from the test body inverts the rubric it is meant to enforce. |
| "The test name reads like a full sentence, so TEST-R001 passes." |
Contract-not-narrative-name wants the contract ("returns null when empty"), not readable narrative ("works correctly"). A grammatical sentence that names no contract is the failure mode. |
Examples
Example: Narrative test name
Input: src/parse.test.ts:
describe('parseTokens', () => {
it('works correctly', () => {
expect(parseTokens('a,b,c')).toEqual(['a', 'b', 'c']);
});
});
Output (mock LLM):
src/parse.test.ts
TEST-R001 [polish/medium/high] vitest:2
parseTokens > works correctly
"works correctly" narrates the test without naming the contract. Try
"splits comma-separated input into trimmed segments" or "preserves
order across the split".
Example: Tautological assertion
Input:
it('returns a user', () => {
const user = createUser({ name: 'a' });
expect(user).toBeDefined();
});
Output:
TEST-R002 [foundational/medium/high] vitest:8
createUser > returns a user
`toBeDefined()` after `createUser(...)` cannot fail — the return is
always defined by the function's signature. Assert the contract:
`expect(user.name).toBe('a')` or `expect(user).toMatchObject({...})`.
Example: Heavy fixture, trivial assertion
Input:
it('handles input', () => {
// 47 lines of beforeEach setup that mocks 6 collaborators
const result = handler.handle({});
expect(result).toBeTruthy();
});
Output:
TEST-R004 [polish/large/medium] vitest:142
handler > handles input
47-line beforeEach + 6 mocks produce a result that's asserted with
`toBeTruthy`. The fixture's complexity exceeds the test's signal —
either drop the fixture and use a simpler stub, or assert the
specific outcome that justified the setup (e.g., which collaborator
was called with what arguments).
Gates
- No autofix. v2's
align-test.
- No coverage analysis. That's the floor (
vitest --coverage).
- No
.test-d.ts type tests (v1.x).
- No fixture / helper / mock file critique (v1.x).
- No snapshot rubrics (v1.x).
- No languages beyond TS/JS + Python (pytest) (v1.x).
- No B' bootstrap. Same posture as naming/spec/copy-craft.
- No graph persistence. Phase 1 MVP.
Escalation
- When the run refuses with "cannot run against the in-session provider": test-craft has no two-step collect/finalize flow, so the in-session provider (which defers every prompt to the calling agent) cannot answer a single rubric. Configure a real backend via
agent.backends + HARNESS_CRAFT_LLM, or set HARNESS_CRAFT_LLM=mock for tests. It refuses rather than critiquing nothing and reporting zero findings (#1346).
- When the summary reports
critiqueErrors or testsTruncated above zero: the run is partly unmeasured — findings are a floor, not a total.
- When LLM cost is too high: drop
maxTestsPerFile (default 20) or scope to specific frameworks with --frameworks vitest. Disable source-pairing with --no-source-pair to halve prompt size on every test.
- When intentionally narrative test names get flagged (e.g., learning tests, examples): scope via
--files to exclude. v1.x adds // test-craft:skip annotation.
- When source-pairing finds the wrong source for ambiguous names: the LLM gets misleading context for
TEST-R007. Use --no-source-pair to fall back to test-file-only rubrics, or v1.x's harness.config.json test→source mapping.
- When jest-with-globals tests aren't detected: the framework defaults to vitest (AST is compatible); critique still runs. If you need jest tagging specifically, add
import { describe, it } from '@jest/globals' to make the signature explicit.
- When
it.each / test.each blocks get critiqued as a single test: v1 captures the each invocation as one item with a generic name pattern. v1.x adds per-iteration critique.
Status
v1 — in implementation. See:
- Spec:
docs/changes/craft-pipeline/test-craft/proposal.md
- Roadmap entry: part of the
craft-pipeline initiative
- Sibling craft skills:
naming-craft, spec-craft, copy-craft
- Shared infrastructure:
packages/cli/src/shared/craft/
- Future:
align-test (FIX side, v2), docs-craft, code-craft
1---2name: test-craft3description: Test Craft4---5# Test Craft67> LLM-judgment critique of test quality across vitest / jest / mocha / playwright / pytest. Fourth member of the craft-pipeline initiative. Per-`it`/`test` block critique with best-effort source pairing for contract-vs-implementation rubrics. Tests are often the worst-written code in a codebase precisely because the rule-based floor (coverage threshold) is so easy to clear. Emits 3-axis findings (tier × impact × confidence per ADR 0019).89## When to Use1011- During PR review on code that adds or changes tests12- After ramping up coverage to audit whether new tests actually add signal13- When onboarding a contributor — audit tests they introduced14- Periodically to catch accumulated low-signal tests + redundant fixtures15- NOT for coverage analysis (use `vitest --coverage` or harness-tdd — that's the floor)16- NOT for autofix / rewriting (v2's `align-test` may add safe renames)17- NOT for `.test-d.ts` type tests in v1 (v1.x — different rubric vocabulary)18- NOT for fixture / helper / mock files in v1 (v1.x)19- NOT for snapshot tests in v1 (different correctness criteria; v1.x)20- NOT for languages beyond TS/JS + Python (pytest) in v1 (v1.x)2122## Capability Roles2324<!-- Capability seam: this skill participates in a real extension point whose three roles are named and concrete. A seam with only one role filled is accidental single-implementation lock-in. See harness-skill-authoring Phase 1C. -->2526- **Defines (Service Definition):** the shared craft critique contract (`packages/cli/src/shared/craft/`) — `LlmProvider` + finding/axes schema + run store — shared across all `*-craft` skills. This skill implements, and does not own, that contract.27- **Provides (Provider):** **this skill** — a test quality critique implemented over the shared contract (`packages/cli/src/test-craft/`).28- **Consumes (Consumer):** `craft-fleet` (the craft-pipeline elevation sweep) and the `harness` natural-language router, which invoke every `*-craft` provider uniformly through the shared critique/finding shape2930## Process3132### Phase 1: DISCOVER — Test files + framework33341. **Read project configuration.** Check `harness.config.json` for:35 - `craft.test.enabled` — gate (default `true`)36 - `craft.test.maxFiles` (default 100), `craft.test.maxTestsPerFile` (default 20)37 - `craft.test.frameworks` — restrict to subset (default: all five)38 - `craft.test.sourcePair` — toggle source-pairing (default true)39402. **Glob test files** under project root: `**/*.{test,spec}.{ts,tsx,js,jsx,mjs,cjs,mts,cts}` plus pytest naming (`test_*.py` / `*_test.py`). Skip `node_modules`, `dist`, `build`, `coverage`, `__pycache__`, `venv`, `vendor`, dotdirs. The extension list is defined once in `extract/test-file-exts.ts` and shared by discovery and extraction — a private second copy is what made `*.test.mjs` suites invisible (#1347).41423. **Detect framework per file** via import signatures (order matters; most-specific first):43 - `@playwright/test` → playwright44 - `@jest/globals` → jest45 - `vitest` → vitest46 - `import 'mocha'` → mocha47 - `.py` files → pytest (extraction is light-parse, not TS AST)48 - Fallback: vitest (most common; jest-with-globals projects still extract correctly because the AST shape matches)4950### Phase 2: EXTRACT — Per-test AST walk5152Single TS Compiler API walk per file. For each `CallExpression`:5354- **describe** — push name onto nesting stack, recurse, pop55- **it / test** — extract `testName` (first string-literal arg), capture current nesting, capture callback body text (truncated to 1500 chars in prompt)56- Modifiers handled: `.skip`, `.only`, `.todo` (todo excluded from critique — no body to critique)57- Non-string-literal test names (computed / templates) skip silently5859### Phase 3: PAIR — Best-effort source resolution6061For each test file, try in order:62631. **Sibling** — `foo.test.ts` → `foo.ts` (same dir)642. **Co-located in src** — `tests/foo.test.ts` → `../src/foo.ts`653. **Monorepo-style** — `tests/foo.test.ts` → `../../src/foo.ts`6667When source resolves, content (truncated to 2000 chars) is added to the LLM prompt under `Source under test:`. This enables `TEST-R007` (contract-not-implementation) to actually compare assertions against the function's public surface. When no source resolves, test-file-only rubrics still fire.6869### Phase 4: CRITIQUE — Per (test, rubric) loop70718 seed rubrics:7273| Rubric | Description |74| --------------------------------------- | --------------------------------------------------------------------------- |75| `TEST-R001` contract-not-narrative-name | Test name describes the contract ("returns null when empty"), not narrative |76| `TEST-R002` meaningful-assertion | Assertion proves something the implementation could plausibly violate |77| `TEST-R003` arrange-act-assert | Three visually distinct phases, not interleaved |78| `TEST-R004` fixture-earns-setup-cost | Heavy beforeEach must be justified by what's asserted |79| `TEST-R005` single-responsibility | One assertion target per `it`; multiple unrelated assertions split poorly |80| `TEST-R006` deleting-loses-something | Would removing this test lose specific coverage, or is it redundant? |81| `TEST-R007` contract-not-implementation | Tests the documented behaviour, not the internal structure |82| `TEST-R008` explicit-failure-mode | Failure message narrates what went wrong without reading the test |8384For each (test, rubric) pair:85861. Build prompt with rubric + test (name + nesting + body) + optional source + framework label.872. LLM returns fenced JSON: `null` (rubric doesn't apply / test is fine) OR `{ tier, impact, confidence, message }`.883. On non-null: emit `TestFinding` with `cite.rubricId` for ADR 0020 traceability.8990### Phase 5: REPORT — Aggregate + cost telemetry9192Emit `TestCraftOutput`:9394```ts95{96 findings: TestFinding[];97 summary: {98 phaseRun: ['critique'];99 durationMs: number;100 llmCalls: { provider, model, count, costUsd };101 catalog: { rubricsApplied: string[] };102 // critiqueErrors > 0 or testsTruncated > 0 means the run is partly103 // unmeasured: findings are a floor, not a total.104 counts: { filesScanned, testsExtracted, testsSkippedOrTodo, sourcePaired,105 critiqueErrors, testsTruncated };106 frameworksDetected: Record<TestFramework, number>;107 runId: string;108 }109}110```111112## Harness Integration113114- **`harness test-craft`** — CLI entry. `--files` / `--frameworks` / `--max-files` / `--max-tests-per-file` / `--no-source-pair` / `--json` / `--verbose`.115- **`mcp__harness__test_craft`** — MCP tool. Same input/output. Consumed by agents.116- **Cross-cutting API:** `critiqueTestsInFile(file, opts)` exported. Works on a single test file without project walk; honours framework filter and source-pairing toggle.117- **Shared craft infrastructure:** imports `LlmProvider` + 3-axis types + `derivePriority` from `packages/cli/src/shared/craft/`.118119## Success Criteria120121See `docs/changes/craft-pipeline/test-craft/proposal.md` for the full 36 success criteria. Highlights:122123- 8 seed rubrics ship in `catalog/rubrics/<id>.ts` (file-per-rubric, matches naming/spec/copy-craft)124- 3-axis output preserved (tier × impact × confidence)125- `cite.rubricId` populated on every finding (ADR 0020)126- Framework detection: `@playwright/test` / `@jest/globals` / `vitest` / `mocha` import signatures; vitest fallback127- Per-test extraction handles `.skip` / `.only` / `.todo` with metadata; describe-chain nesting captured128- Source pairing best-effort with silent skip when no match129- Plugin slash-commands pre-generated (avoids CI drift failure pattern)130131## Rationalizations to Reject132133These are common rationalizations that sound reasonable but lead to incorrect results. When you catch yourself thinking any of these, stop and follow the documented process instead.134135| Rationalization | Why It Is Wrong |136| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |137| "This test is green and coverage went up, so it's a good test." | Coverage is the floor (`vitest --coverage`). Test-craft asks whether the assertion proves something the implementation could plausibly violate (TEST-R002). A green tautology adds zero signal. |138| "`expect(user).toBeDefined()` after `createUser(...)` confirms it works, so R002 passes." | The return is defined by the signature — the assertion cannot fail. That is the tautology TEST-R002 targets. Assert the contract (`user.name`), not mere existence. |139| "The test asserts the mock returned exactly what I stubbed, so behaviour is verified." | Asserting the mock echoes your own setup, not the unit's behaviour. TEST-R007 wants assertions against the source's public contract, not the test's own scaffolding. |140| "Source pairing didn't resolve, so I'll still critique TEST-R007 from the test body." | Without resolved source, contract-not-implementation has nothing to compare against and should skip. Inventing a contract from the test body inverts the rubric it is meant to enforce. |141| "The test name reads like a full sentence, so TEST-R001 passes." | Contract-not-narrative-name wants the contract ("returns null when empty"), not readable narrative ("works correctly"). A grammatical sentence that names no contract is the failure mode. |142143## Examples144145### Example: Narrative test name146147**Input:** `src/parse.test.ts`:148149```ts150describe('parseTokens', () => {151 it('works correctly', () => {152 expect(parseTokens('a,b,c')).toEqual(['a', 'b', 'c']);153 });154});155```156157**Output (mock LLM):**158159```160src/parse.test.ts161 TEST-R001 [polish/medium/high] vitest:2162 parseTokens > works correctly163 "works correctly" narrates the test without naming the contract. Try164 "splits comma-separated input into trimmed segments" or "preserves165 order across the split".166```167168### Example: Tautological assertion169170**Input:**171172```ts173it('returns a user', () => {174 const user = createUser({ name: 'a' });175 expect(user).toBeDefined();176});177```178179**Output:**180181```182TEST-R002 [foundational/medium/high] vitest:8183 createUser > returns a user184 `toBeDefined()` after `createUser(...)` cannot fail — the return is185 always defined by the function's signature. Assert the contract:186 `expect(user.name).toBe('a')` or `expect(user).toMatchObject({...})`.187```188189### Example: Heavy fixture, trivial assertion190191**Input:**192193```ts194it('handles input', () => {195 // 47 lines of beforeEach setup that mocks 6 collaborators196 const result = handler.handle({});197 expect(result).toBeTruthy();198});199```200201**Output:**202203```204TEST-R004 [polish/large/medium] vitest:142205 handler > handles input206 47-line beforeEach + 6 mocks produce a result that's asserted with207 `toBeTruthy`. The fixture's complexity exceeds the test's signal —208 either drop the fixture and use a simpler stub, or assert the209 specific outcome that justified the setup (e.g., which collaborator210 was called with what arguments).211```212213## Gates214215- **No autofix.** v2's `align-test`.216- **No coverage analysis.** That's the floor (`vitest --coverage`).217- **No `.test-d.ts` type tests** (v1.x).218- **No fixture / helper / mock file critique** (v1.x).219- **No snapshot rubrics** (v1.x).220- **No languages beyond TS/JS + Python (pytest)** (v1.x).221- **No B' bootstrap.** Same posture as naming/spec/copy-craft.222- **No graph persistence.** Phase 1 MVP.223224## Escalation225226- **When the run refuses with "cannot run against the in-session provider":** test-craft has no two-step collect/finalize flow, so the in-session provider (which defers every prompt to the calling agent) cannot answer a single rubric. Configure a real backend via `agent.backends` + `HARNESS_CRAFT_LLM`, or set `HARNESS_CRAFT_LLM=mock` for tests. It refuses rather than critiquing nothing and reporting zero findings (#1346).227- **When the summary reports `critiqueErrors` or `testsTruncated` above zero:** the run is partly unmeasured — findings are a floor, not a total.228- **When LLM cost is too high:** drop `maxTestsPerFile` (default 20) or scope to specific frameworks with `--frameworks vitest`. Disable source-pairing with `--no-source-pair` to halve prompt size on every test.229- **When intentionally narrative test names get flagged (e.g., learning tests, examples):** scope via `--files` to exclude. v1.x adds `// test-craft:skip` annotation.230- **When source-pairing finds the wrong source for ambiguous names:** the LLM gets misleading context for `TEST-R007`. Use `--no-source-pair` to fall back to test-file-only rubrics, or v1.x's `harness.config.json` test→source mapping.231- **When jest-with-globals tests aren't detected:** the framework defaults to vitest (AST is compatible); critique still runs. If you need jest tagging specifically, add `import { describe, it } from '@jest/globals'` to make the signature explicit.232- **When `it.each` / `test.each` blocks get critiqued as a single test:** v1 captures the `each` invocation as one item with a generic name pattern. v1.x adds per-iteration critique.233234## Status235236**v1 — in implementation.** See:237238- Spec: `docs/changes/craft-pipeline/test-craft/proposal.md`239- Roadmap entry: part of the `craft-pipeline` initiative240- Sibling craft skills: `naming-craft`, `spec-craft`, `copy-craft`241- Shared infrastructure: `packages/cli/src/shared/craft/`242- Future: `align-test` (FIX side, v2), docs-craft, code-craft