Files
msd-core/gsd-core/references/tdd.md
Tom Boucher ed819aa4d6 fix(#4379): make the TDD RED-commit pathspec language-agnostic (#4715)
* fix(#4379): make the TDD RED pathspec language-agnostic

The pathspec IS this gate's definition of "a test file", and it listed
only JS/TS conventions. Go's *_test.go matches none of them, so a commit
adding a failing Go test was invisible, RED_COMMIT came back empty, and
every behaviour-adding task halted with TDD GATE TRIPPED. references/tdd.md
already advertises `go test ./...` and `cargo test` as supported, so the
gate was refusing to see tests the docs promised to support.

Two corrections, both measured against a seeded repo rather than reasoned:

- cover the conventions tdd.md advertises: *_test.go, test_*.py,
  *_test.py, *_test.exs, *_spec.rb, *_test.rb.
- drop the `**/` prefix. It does NOT match a path with no directory
  component, so a root-level foo.test.js was invisible even in the
  language the gate did support -- a second defect the report did not
  mention. A bare glob matches at every depth.

Deliberately not widened to ordinary source: a pathspec matching
implementation files would make the gate pass on any in-scope commit,
which is worse than tripping wrongly. Rust is a known gap for that exact
reason and is now documented rather than silently broken.

Driving the shipped pathspec against a seeded repo: before, 0 of 7
language/root conventions matched; after, 7 of 7, with src/impl.go and
src/lib.rs correctly unmatched in both.

Emitted-Drift-Ack-Growth: execute-phase.md — the widened pathspec plus the comment recording why it must not cover ordinary source
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4379): drive the shipped RED pathspec against a real repo

The existing row pinned the pathspec as a literal string, which the fix
makes stale. Re-point it, and add behavioural coverage that EXTRACTS the
pathspec from the shipped workflow and runs git log with it against a
seeded repo -- re-typing the pattern into the test would only assert that
two copies of a string agree.

Rows: every advertised convention is visible; a root-level test file is
not invisible (the half the report missed); existing JS/TS still matches;
implementation files never match, so the gate can still trip; and Rust
inline #[test] stays out of reach, asserted rather than left silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): add changeset fragment

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4379): be honest about the widened pathspec's cost

Adversarial review: the rationale comment claimed the change was safe
without naming what it gives up. `*.spec.*` can match a non-test file
carrying the word (api.spec.json, openapi.spec.yaml), which lets the gate
pass on a commit touching only that. Not new -- `**/*.spec.*` already
matched those at any nested path, so dropping `**/` extends the same
class to the root -- but the comment should say so rather than imply
the widening is free.

Also: the tdd.md list named Ruby and Elixir as recognised while the
detection step above it enumerates only Node/Python/Go/Rust. Say
explicitly that the gate's pathspec is wider than the detected project
types, and why that is deliberate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#4379): drop a vacuous row, fold its point into a real one

Adversarial review: the "rust inline #[test] remains out of reach" row
asserted src/lib.rs never matches -- the identical assertion to the
"implementation files never match" row directly below it. It exercised
nothing about #[test] semantics and would have passed against almost any
fix, so it was coverage theatre.

Delete it and move its rationale into the row that already carries the
assertion, where it explains WHY the Rust gap follows from that row
holding: the two cannot both be satisfied by a path-based gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4379): move the pathspec rationale out of a size-capped file

execute-phase.md sits under a FROZEN byte ceiling (ADR-857 Phase 6,
#1168: < 93600). The 20-line rationale comment I added pushed it to
93933 and tripped seven tests, all the same ceiling. Base was 92371, so
the budget was 1229 bytes and the comment spent 1481.

Keep six lines at the call site -- what the pathspec is, why it is not
wider, where to read more -- and move the trade-off detail to
references/tdd.md, which has no ceiling. That is the right home anyway:
the workflow is loaded into context on every run, the reference is read
on demand.

92882 bytes, 718 under. Pathspec line byte-identical; re-proved
behaviour after the trim: 0/7 conventions before, 7/7 after, no
implementation files matched either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): refresh the compact-content benchmark baseline

execute-phase.md changed size, so the committed baseline drifted. The
script's own contract makes it a report that exits 0, but the test
asserts the committed baseline is up to date -- refresh via --write,
which is what the drift message instructs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4379): backfill the changeset PR number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:57:08 -04:00

13 KiB

TDD is about design quality, not coverage metrics. The red-green-refactor cycle forces you to think about behavior before implementation, producing cleaner interfaces and more testable code.

Principle: If you can describe the behavior as expect(fn(input)).toBe(output) before writing fn, TDD improves the result.

Key insight: TDD work is fundamentally heavier than standard tasks—it requires 2-3 execution cycles (RED → GREEN → REFACTOR), each with file reads, test runs, and potential debugging. TDD features get dedicated plans to ensure full context is available throughout the cycle.

<when_to_use_tdd>

When TDD Improves Quality

TDD candidates (create a TDD plan):

  • Business logic with defined inputs/outputs
  • API endpoints with request/response contracts
  • Data transformations, parsing, formatting
  • Validation rules and constraints
  • Algorithms with testable behavior
  • State machines and workflows
  • Utility functions with clear specifications

Skip TDD (use standard plan with type="auto" tasks):

  • UI layout, styling, visual components
  • Configuration changes
  • Glue code connecting existing components
  • One-off scripts and migrations
  • Simple CRUD with no business logic
  • Exploratory prototyping

Heuristic: Can you write expect(fn(input)).toBe(output) before writing fn? → Yes: Create a TDD plan → No: Use standard plan, add tests after if needed </when_to_use_tdd>

<tdd_plan_structure>

TDD Plan Structure

Each TDD plan implements one feature through the full RED-GREEN-REFACTOR cycle.

---
phase: XX-name
plan: NN
type: tdd
---

<objective>
[What feature and why]
Purpose: [Design benefit of TDD for this feature]
Output: [Working, tested feature]
</objective>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@relevant/source/files.ts
</context>

<feature>
  <name>[Feature name]</name>
  <files>[source file, test file]</files>
  <behavior>
    [Expected behavior in testable terms]
    Cases: input → expected output
  </behavior>
  <implementation>[How to implement once tests pass]</implementation>
</feature>

<verification>
[Test command that proves feature works]
</verification>

<success_criteria>
- Failing test written and committed
- Implementation passes test
- Refactor complete (if needed)
- All 2-3 commits present
</success_criteria>

<output>
After completion, create SUMMARY.md with:
- RED: What test was written, why it failed
- GREEN: What implementation made it pass
- REFACTOR: What cleanup was done (if any)
- Commits: List of commits produced
</output>

One feature per TDD plan. If features are trivial enough to batch, they're trivial enough to skip TDD—use a standard plan and add tests after. </tdd_plan_structure>

<execution_flow>

Red-Green-Refactor Cycle

RED - Write failing test:

  1. Create test file following project conventions
  2. Write test describing expected behavior (from <behavior> element)
  3. Run test - it MUST fail intentionally (#3770): the TARGET test you named must be the test that fails, on an assertion for the planned behavior. A nonzero exit alone is NOT RED — syntax errors, zero-test discovery, fixture crashes, parser errors, and unrelated assertions are INVALID_RED and must not authorize GREEN.
  4. Persist the RED evidence record (command, exit code, failing test, expected result, actual result) and verify it: gsd_run check tdd-red-evidence <record.json>. Only verdict RED_EVIDENCE_OK satisfies the RED gate; INVALID_RED blocks GREEN until the RED phase is fixed.
  5. If test passes: feature exists or test is wrong. Investigate.
  6. Commit: test({phase}-{plan}): add failing test for [feature]

GREEN - Implement to pass:

  1. Write minimal code to make test pass
  2. No cleverness, no optimization - just make it work
  3. Run test - it MUST pass
  4. Commit: feat({phase}-{plan}): implement [feature]

REFACTOR (if needed):

  1. Clean up implementation if obvious improvements exist
  2. Run tests - MUST still pass
  3. Only commit if changes made: refactor({phase}-{plan}): clean up [feature]

Result: Each TDD plan produces 2-3 atomic commits. </execution_flow>

<test_quality>

Good Tests vs Bad Tests

Test behavior, not implementation:

  • Good: "returns formatted date string"
  • Bad: "calls formatDate helper with correct params"
  • Tests should survive refactors

One concept per test:

  • Good: Separate tests for valid input, empty input, malformed input
  • Bad: Single test checking all edge cases with multiple assertions

Descriptive names:

  • Good: "should reject empty email", "returns null for invalid ID"
  • Bad: "test1", "handles error", "works correctly"

No implementation details:

  • Good: Test public API, observable behavior
  • Bad: Mock internals, test private methods, assert on internal state </test_quality>

<framework_setup>

Test Framework Setup (If None Exists)

When executing a TDD plan but no test framework is configured, set it up as part of the RED phase:

1. Detect project type:

# JavaScript/TypeScript
if [ -f package.json ]; then echo "node"; fi

# Python
if [ -f requirements.txt ] || [ -f pyproject.toml ]; then echo "python"; fi

# Go
if [ -f go.mod ]; then echo "go"; fi

# Rust
if [ -f Cargo.toml ]; then echo "rust"; fi

2. Install minimal framework:

Project Framework Install
Node.js Jest npm install -D jest @types/jest ts-jest
Node.js (Vite) Vitest npm install -D vitest
Python pytest pip install pytest
Go testing Built-in
Rust cargo test Built-in

3. Create config if needed:

  • Jest: jest.config.js with ts-jest preset
  • Vitest: vitest.config.ts with test globals
  • pytest: pytest.ini or pyproject.toml section

4. Verify setup:

# Run empty test suite - should pass with 0 tests
npm test  # Node
pytest    # Python
go test ./...  # Go
cargo test    # Rust

5. Create first test file: Follow project conventions for test location. The RED-commit gate (workflows/execute-phase.md) recognises the patterns below at any depth, repo root included. Note the gate's pathspec is deliberately wider than the project types the detection step above enumerates — it costs nothing to recognise a convention the onboarding flow does not yet auto-detect, and a project using one should not have its RED commits go unseen:

  • *.test.* / *.spec.* next to source — JS/TS and anything sharing the convention
  • __tests__/ directory
  • tests/ directory at root
  • *_test.go — Go
  • test_*.py / *_test.py — Python (in addition to tests/)
  • *_test.exs — Elixir
  • *_spec.rb / *_test.rb — Ruby

Cost of the broad pathspec (#4379). *.spec.* can match a non-test file that happens to carry the word — api.spec.json, openapi.spec.yaml — which lets the RED gate pass on a commit touching only that. This is not new: the previous **/*.spec.* already matched those at any nested path, so dropping the **/ prefix only extends the same false-positive class to the repo root. It is accepted rather than narrowed, because narrowing it is a behaviour change to the currently-supported case and not part of making other languages visible.

Known gap — Rust (#4379). #[test] conventionally lives inside the implementation file, so a Rust RED commit touches src/*.rs and no path-based gate can distinguish it from ordinary source. Widening the pathspec to cover it would match all source and make the gate meaningless. cargo test works; the RED-commit gate cannot see it, so a Rust project using workflow.tdd_mode should expect the gate to trip.

Framework setup is a one-time cost included in the first TDD plan's RED phase. </framework_setup>

<error_handling>

Error Handling

Test doesn't fail in RED phase:

  • Feature may already exist - investigate
  • Test may be wrong (not testing what you think)
  • Fix before proceeding

Test doesn't pass in GREEN phase:

  • Debug implementation
  • Don't skip to refactor
  • Keep iterating until green

Tests fail in REFACTOR phase:

  • Undo refactor
  • Commit was premature
  • Refactor in smaller steps

Unrelated tests break:

  • Stop and investigate
  • May indicate coupling issue
  • Fix before proceeding </error_handling>

<commit_pattern>

Commit Pattern for TDD Plans

TDD plans produce 2-3 atomic commits (one per phase):

test(08-02): add failing test for email validation

- Tests valid email formats accepted
- Tests invalid formats rejected
- Tests empty input handling

feat(08-02): implement email validation

- Regex pattern matches RFC 5322
- Returns boolean for validity
- Handles edge cases (empty, null)

refactor(08-02): extract regex to constant (optional)

- Moved pattern to EMAIL_REGEX constant
- No behavior changes
- Tests still pass

Comparison with standard plans:

  • Standard plans: 1 commit per task, 2-4 commits per plan
  • TDD plans: 2-3 commits for single feature

Both follow same format: {type}({phase}-{plan}): {description}

Benefits:

  • Each commit independently revertable
  • Git bisect works at commit level
  • Clear history showing TDD discipline
  • Consistent with overall commit strategy </commit_pattern>

<gate_enforcement>

Gate Enforcement Rules

When workflow.tdd_mode is enabled in config, the RED/GREEN/REFACTOR gate sequence is enforced for all type: tdd plans.

Gate Definitions

Gate Required Commit Pattern Validation
RED Yes test({phase}-{plan}): ... Test exists AND fails before implementation — intentionally: check tdd-red-evidence returns RED_EVIDENCE_OK (target test failed on an assertion for the behavior; anything else is INVALID_RED)
GREEN Yes feat({phase}-{plan}): ... Test passes after implementation
REFACTOR No refactor({phase}-{plan}): ... Tests still pass after cleanup

Fail-Fast Rules

  1. Unexpected GREEN in RED phase: If the test passes before any implementation code is written, STOP. The feature may already exist or the test is wrong. Investigate before proceeding.
  2. INVALID_RED in RED phase (#3770): A nonzero exit is not RED by itself. Zero-test discovery, fixture/load crashes, nonzero exits with no failing test, unrelated failing tests, and unexpected greens all classify as INVALID_RED (gsd_run check tdd-red-evidence). STOP and fix the RED phase — do NOT proceed to GREEN.
  3. Missing RED commit: If no test(...) commit precedes the feat(...) commit, the TDD discipline was violated. Flag in SUMMARY.md.
  4. REFACTOR breaks tests: Undo the refactor immediately. Commit was premature — refactor in smaller steps.

Executor Gate Validation

After completing a type: tdd plan, the executor validates the git log:

# The commit protocol promises no zero-padding for ${PHASE}/${PLAN} — strip both and
# match the commit-scope position anchored (#4003). #4619: PHASE may be decimal/
# N-segment; zero-strip only the leading integer segment, escape the rest.
PHASE_INT=${PHASE%%.*}; PHASE_FRAC=${PHASE#"$PHASE_INT"}
PHASE_N="$((10#$PHASE_INT))${PHASE_FRAC//./\\.}"
PLAN_N=$((10#${PLAN}))
# Check for RED gate commit
git log --oneline -E --grep="^test\((0*${PHASE_N})-(0*${PLAN_N})\):" | head -1
# Check for GREEN gate commit  
git log --oneline -E --grep="^feat\((0*${PHASE_N})-(0*${PLAN_N})\):" | head -1
# Check for optional REFACTOR gate commit
git log --oneline -E --grep="^refactor\((0*${PHASE_N})-(0*${PLAN_N})\):" | head -1

If RED or GREEN gate commits are missing, add a ## TDD Gate Compliance section to SUMMARY.md with the violation details. </gate_enforcement>

<end_of_phase_review>

End-of-Phase TDD Review Checkpoint

When workflow.tdd_mode is enabled, the execute-phase orchestrator inserts a collaborative review checkpoint after all waves complete but before phase verification.

Review Checkpoint Format

### TDD REVIEW — Phase {X}

TDD Plans: {count} | Gate violations: {count}

| Plan | RED | GREEN | REFACTOR | Status |
|------|-----|-------|----------|--------|
| {id} |  ✓  |   ✓   |    ✓     | Pass   |
| {id} |  ✓  |   ✗   |    —     | FAIL   |

{If violations exist:}
⚠ Gate violations are advisory — review before advancing.

What the Review Checks

  1. Gate sequence: Each TDD plan has RED → GREEN commits in order
  2. Test quality: RED phase tests fail for the right reason (not import errors or syntax)
  3. Minimal GREEN: Implementation is minimal — no premature optimization in GREEN phase
  4. Refactor discipline: If REFACTOR commit exists, tests still pass

This checkpoint is advisory — it does not block phase completion but surfaces TDD discipline issues for human review. </end_of_phase_review>

<context_budget>

Context Budget

TDD plans target ~40% context usage (lower than standard plans' ~50%).

Why lower:

  • RED phase: write test, run test, potentially debug why it didn't fail
  • GREEN phase: implement, run test, potentially iterate on failures
  • REFACTOR phase: modify code, run tests, verify no regressions

Each phase involves reading files, running commands, analyzing output. The back-and-forth is inherently heavier than linear task execution.

Single feature focus ensures full quality throughout the cycle. </context_budget>