@windyroad/tdd 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/bin/install.mjs +3 -2
- package/hooks/hooks.json +2 -1
- package/hooks/lib/tdd-gate.sh +153 -32
- package/hooks/tdd-enforce-edit.sh +25 -9
- package/hooks/tdd-inject.sh +30 -19
- package/hooks/tdd-post-write.sh +34 -29
- package/hooks/tdd-setup-marker.sh +23 -0
- package/hooks/test/tdd-gate.bats +276 -0
- package/hooks/test/tdd-project-root.bats +20 -0
- package/lib/install-utils.mjs +143 -0
- package/package.json +3 -2
- package/skills/{wr:tdd → setup-tests}/SKILL.md +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @windyroad/tdd
|
|
2
|
+
|
|
3
|
+
**TDD state machine enforcement for Claude Code.** Forces the Red-Green-Refactor cycle so your AI agent writes tests before implementation -- every time.
|
|
4
|
+
|
|
5
|
+
Part of [Windy Road Agent Plugins](../../README.md).
|
|
6
|
+
|
|
7
|
+
## What It Does
|
|
8
|
+
|
|
9
|
+
AI agents love to jump straight to implementation. This plugin stops that. It enforces a strict TDD state machine:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
IDLE ──> RED ──> GREEN ──> RED (next test)
|
|
13
|
+
│
|
|
14
|
+
└──> Refactor (staying GREEN)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **IDLE** -- No test written yet. Implementation file edits are blocked.
|
|
18
|
+
- **RED** -- A failing test exists. Implementation edits are allowed.
|
|
19
|
+
- **GREEN** -- Tests pass. You can refactor or write a new failing test.
|
|
20
|
+
- **BLOCKED** -- Test runner error or timeout. Fix the setup before continuing.
|
|
21
|
+
|
|
22
|
+
The agent must write a failing test first. There are no shortcuts.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx @windyroad/tdd
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Restart Claude Code after installing.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
The plugin activates automatically. On first use in a project without a test framework, it directs you to set one up:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
/wr-tdd:setup-tests
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This examines your codebase, recommends a test runner, configures `package.json`, and creates an example test.
|
|
41
|
+
|
|
42
|
+
Once active, the workflow is enforced on every edit:
|
|
43
|
+
|
|
44
|
+
1. Write a test file (`*.test.ts`, `*.spec.ts`, etc.) that describes the desired behaviour
|
|
45
|
+
2. The test must fail (RED state) -- proving the test is meaningful
|
|
46
|
+
3. Write the minimum implementation to make it pass (GREEN state)
|
|
47
|
+
4. Refactor while keeping tests green
|
|
48
|
+
5. Repeat
|
|
49
|
+
|
|
50
|
+
Test files and config/doc files are always writable regardless of state.
|
|
51
|
+
|
|
52
|
+
## How It Works
|
|
53
|
+
|
|
54
|
+
| Hook | Trigger | What it does |
|
|
55
|
+
|------|---------|-------------|
|
|
56
|
+
| `tdd-inject.sh` | Every prompt | Injects the current TDD state into the prompt |
|
|
57
|
+
| `tdd-enforce-edit.sh` | Edit or Write | Blocks implementation edits in IDLE or BLOCKED state |
|
|
58
|
+
| `tdd-post-write.sh` | After edit | Runs tests and transitions the state machine |
|
|
59
|
+
| `tdd-setup-marker.sh` | Skill completes | Marks test setup as done |
|
|
60
|
+
| `tdd-reset.sh` | Session end | Resets the TDD state |
|
|
61
|
+
|
|
62
|
+
## Updating and Uninstalling
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @windyroad/tdd --update
|
|
66
|
+
npx @windyroad/tdd --uninstall
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Licence
|
|
70
|
+
|
|
71
|
+
[MIT](../../LICENSE)
|
package/bin/install.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { resolve, dirname } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const utils = await import(resolve(__dirname, "
|
|
7
|
+
const utils = await import(resolve(__dirname, "../lib/install-utils.mjs"));
|
|
8
8
|
|
|
9
9
|
const PLUGIN = "wr-tdd";
|
|
10
10
|
const DEPS = [];
|
|
@@ -20,6 +20,7 @@ TDD state machine enforcement (Red-Green-Refactor cycle)
|
|
|
20
20
|
Options:
|
|
21
21
|
--update Update this plugin and its skills
|
|
22
22
|
--uninstall Remove this plugin
|
|
23
|
+
--scope Installation scope: project (default) or user
|
|
23
24
|
--dry-run Show what would be done without executing
|
|
24
25
|
--help, -h Show this help
|
|
25
26
|
`);
|
|
@@ -38,5 +39,5 @@ if (flags.uninstall) {
|
|
|
38
39
|
} else if (flags.update) {
|
|
39
40
|
utils.updatePackage(PLUGIN);
|
|
40
41
|
} else {
|
|
41
|
-
utils.installPackage(PLUGIN, { deps: DEPS });
|
|
42
|
+
utils.installPackage(PLUGIN, { deps: DEPS, scope: flags.scope });
|
|
42
43
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tdd-enforce-edit.sh" }] }
|
|
8
8
|
],
|
|
9
9
|
"PostToolUse": [
|
|
10
|
-
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tdd-post-write.sh" }] }
|
|
10
|
+
{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tdd-post-write.sh" }] },
|
|
11
|
+
{ "matcher": "Skill", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tdd-setup-marker.sh" }] }
|
|
11
12
|
],
|
|
12
13
|
"Stop": [
|
|
13
14
|
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tdd-reset.sh" }] }
|
package/hooks/lib/tdd-gate.sh
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# TDD Gate - shared library for TDD enforcement hooks.
|
|
3
3
|
# Sourced by tdd-inject.sh, tdd-enforce-edit.sh, tdd-post-write.sh, tdd-reset.sh.
|
|
4
|
-
# Provides: tdd_classify_file, tdd_read_state, tdd_write_state,
|
|
5
|
-
# tdd_add_test_file, tdd_get_test_files,
|
|
6
|
-
#
|
|
4
|
+
# Provides: tdd_classify_file, tdd_read_state, tdd_write_state, tdd_run_test_file,
|
|
5
|
+
# tdd_add_test_file, tdd_get_test_files, tdd_find_test_for_impl,
|
|
6
|
+
# tdd_read_state_for_impl, tdd_get_all_states, tdd_cleanup,
|
|
7
|
+
# tdd_has_test_script, tdd_deny_json
|
|
7
8
|
|
|
8
9
|
# --- Configuration ---
|
|
9
10
|
TDD_TEST_CMD="${TDD_TEST_CMD:-npm test --}"
|
|
@@ -27,8 +28,8 @@ tdd_classify_file() {
|
|
|
27
28
|
|
|
28
29
|
# Exempt files (not gated)
|
|
29
30
|
case "$FILE_PATH" in
|
|
30
|
-
# Config files
|
|
31
|
-
*.config.*|*.json|*.yml|*.yaml) echo "exempt"; return ;;
|
|
31
|
+
# Config and setup files (test infrastructure)
|
|
32
|
+
*.config.*|*.setup.*|*.json|*.yml|*.yaml) echo "exempt"; return ;;
|
|
32
33
|
# Module configs (*.mjs, *.cjs are config when at root or named as config)
|
|
33
34
|
*.mjs|*.cjs) echo "exempt"; return ;;
|
|
34
35
|
# Styles
|
|
@@ -58,17 +59,28 @@ tdd_classify_file() {
|
|
|
58
59
|
echo "exempt"
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
# --- State Management ---
|
|
62
|
-
#
|
|
63
|
-
|
|
62
|
+
# --- State Management (per-test-file) ---
|
|
63
|
+
# State is stored per test file in a session-scoped directory.
|
|
64
|
+
# Each test file gets its own state: IDLE, RED, GREEN, or BLOCKED.
|
|
65
|
+
|
|
66
|
+
_tdd_state_dir() { echo "/tmp/tdd-state-${1}"; }
|
|
64
67
|
_tdd_test_files_file() { echo "/tmp/tdd-test-files-${1}"; }
|
|
65
68
|
_tdd_test_stdout_file() { echo "/tmp/tdd-test-stdout-${1}"; }
|
|
66
69
|
|
|
67
|
-
#
|
|
70
|
+
# Encode a file path for use as a filename (replace / with __)
|
|
71
|
+
_tdd_encode_path() {
|
|
72
|
+
echo "$1" | sed 's|/|__|g'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Read state for a specific test file. Returns: IDLE, RED, GREEN, or BLOCKED
|
|
68
76
|
tdd_read_state() {
|
|
69
77
|
local SESSION_ID="$1"
|
|
70
|
-
local
|
|
71
|
-
|
|
78
|
+
local TEST_FILE="$2"
|
|
79
|
+
local STATE_DIR
|
|
80
|
+
STATE_DIR=$(_tdd_state_dir "$SESSION_ID")
|
|
81
|
+
local ENCODED
|
|
82
|
+
ENCODED=$(_tdd_encode_path "$TEST_FILE")
|
|
83
|
+
local STATE_FILE="${STATE_DIR}/${ENCODED}"
|
|
72
84
|
if [ -f "$STATE_FILE" ]; then
|
|
73
85
|
cat "$STATE_FILE"
|
|
74
86
|
else
|
|
@@ -76,13 +88,17 @@ tdd_read_state() {
|
|
|
76
88
|
fi
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
# Write state
|
|
91
|
+
# Write state for a specific test file
|
|
80
92
|
tdd_write_state() {
|
|
81
93
|
local SESSION_ID="$1"
|
|
82
|
-
local
|
|
83
|
-
local
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
local TEST_FILE="$2"
|
|
95
|
+
local NEW_STATE="$3"
|
|
96
|
+
local STATE_DIR
|
|
97
|
+
STATE_DIR=$(_tdd_state_dir "$SESSION_ID")
|
|
98
|
+
mkdir -p "$STATE_DIR"
|
|
99
|
+
local ENCODED
|
|
100
|
+
ENCODED=$(_tdd_encode_path "$TEST_FILE")
|
|
101
|
+
echo "$NEW_STATE" > "${STATE_DIR}/${ENCODED}"
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
# Track test files touched this session
|
|
@@ -108,31 +124,133 @@ tdd_get_test_files() {
|
|
|
108
124
|
fi
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
# --- Impl-to-Test Association ---
|
|
128
|
+
# Given an implementation file, find its associated test file from tracked tests.
|
|
129
|
+
# Convention: Hero.tsx ↔ Hero.test.tsx or Hero.spec.tsx (same dir or __tests__/)
|
|
130
|
+
# Returns the first matching tracked test file, or empty string if none.
|
|
131
|
+
tdd_find_test_for_impl() {
|
|
132
|
+
local SESSION_ID="$1"
|
|
133
|
+
local IMPL_PATH="$2"
|
|
134
|
+
local DIR BASENAME STEM EXT
|
|
135
|
+
DIR=$(dirname "$IMPL_PATH")
|
|
136
|
+
BASENAME=$(basename "$IMPL_PATH")
|
|
137
|
+
|
|
138
|
+
# Strip extension to get stem (e.g., "Hero" from "Hero.tsx")
|
|
139
|
+
# Handle .ts, .tsx, .js, .jsx
|
|
140
|
+
case "$BASENAME" in
|
|
141
|
+
*.tsx) STEM="${BASENAME%.tsx}"; EXT="tsx" ;;
|
|
142
|
+
*.ts) STEM="${BASENAME%.ts}"; EXT="ts" ;;
|
|
143
|
+
*.jsx) STEM="${BASENAME%.jsx}"; EXT="jsx" ;;
|
|
144
|
+
*.js) STEM="${BASENAME%.js}"; EXT="js" ;;
|
|
145
|
+
*) STEM="$BASENAME"; EXT="" ;;
|
|
146
|
+
esac
|
|
147
|
+
|
|
148
|
+
local TEST_FILES
|
|
149
|
+
TEST_FILES=$(tdd_get_test_files "$SESSION_ID")
|
|
150
|
+
if [ -z "$TEST_FILES" ]; then
|
|
151
|
+
echo ""
|
|
152
|
+
return
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# Check tracked test files for a match
|
|
156
|
+
# Priority: exact match in tracked files (any convention)
|
|
157
|
+
while IFS= read -r tracked; do
|
|
158
|
+
local tracked_dir tracked_base
|
|
159
|
+
tracked_dir=$(dirname "$tracked")
|
|
160
|
+
tracked_base=$(basename "$tracked")
|
|
161
|
+
|
|
162
|
+
# Same directory: Hero.test.tsx, Hero.spec.tsx, etc.
|
|
163
|
+
if [ "$tracked_dir" = "$DIR" ]; then
|
|
164
|
+
case "$tracked_base" in
|
|
165
|
+
"${STEM}.test."*|"${STEM}.spec."*) echo "$tracked"; return ;;
|
|
166
|
+
esac
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
# __tests__ subdirectory: src/__tests__/Hero.test.tsx for src/Hero.tsx
|
|
170
|
+
if [ "$tracked_dir" = "${DIR}/__tests__" ]; then
|
|
171
|
+
case "$tracked_base" in
|
|
172
|
+
"${STEM}.test."*|"${STEM}.spec."*) echo "$tracked"; return ;;
|
|
173
|
+
esac
|
|
174
|
+
fi
|
|
175
|
+
|
|
176
|
+
# Parent __tests__: src/__tests__/Hero.test.tsx for src/components/Hero.tsx
|
|
177
|
+
# (only if dir contains __tests__)
|
|
178
|
+
case "$tracked" in
|
|
179
|
+
*/__tests__/*)
|
|
180
|
+
case "$tracked_base" in
|
|
181
|
+
"${STEM}.test."*|"${STEM}.spec."*) echo "$tracked"; return ;;
|
|
182
|
+
esac
|
|
183
|
+
;;
|
|
184
|
+
esac
|
|
185
|
+
done <<< "$TEST_FILES"
|
|
186
|
+
|
|
187
|
+
# No tracked test found
|
|
188
|
+
echo ""
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Suggest a test file path for an impl file using naming convention.
|
|
192
|
+
# Used for user-facing messages (e.g., "write src/Hero.test.tsx first").
|
|
193
|
+
tdd_suggest_test_path() {
|
|
194
|
+
local IMPL_PATH="$1"
|
|
195
|
+
local DIR BASENAME STEM EXT
|
|
196
|
+
DIR=$(dirname "$IMPL_PATH")
|
|
197
|
+
BASENAME=$(basename "$IMPL_PATH")
|
|
198
|
+
case "$BASENAME" in
|
|
199
|
+
*.tsx) STEM="${BASENAME%.tsx}"; EXT="tsx" ;;
|
|
200
|
+
*.ts) STEM="${BASENAME%.ts}"; EXT="ts" ;;
|
|
201
|
+
*.jsx) STEM="${BASENAME%.jsx}"; EXT="jsx" ;;
|
|
202
|
+
*.js) STEM="${BASENAME%.js}"; EXT="js" ;;
|
|
203
|
+
*) echo ""; return ;;
|
|
204
|
+
esac
|
|
205
|
+
echo "${DIR}/${STEM}.test.${EXT}"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Read state for an implementation file by looking up its associated test
|
|
209
|
+
tdd_read_state_for_impl() {
|
|
210
|
+
local SESSION_ID="$1"
|
|
211
|
+
local IMPL_PATH="$2"
|
|
212
|
+
local TEST_FILE
|
|
213
|
+
TEST_FILE=$(tdd_find_test_for_impl "$SESSION_ID" "$IMPL_PATH")
|
|
214
|
+
if [ -z "$TEST_FILE" ]; then
|
|
215
|
+
echo "IDLE"
|
|
216
|
+
return
|
|
217
|
+
fi
|
|
218
|
+
tdd_read_state "$SESSION_ID" "$TEST_FILE"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Get all tracked test files with their states (format: "path:STATE" per line)
|
|
222
|
+
tdd_get_all_states() {
|
|
223
|
+
local SESSION_ID="$1"
|
|
224
|
+
local TEST_FILES
|
|
225
|
+
TEST_FILES=$(tdd_get_test_files "$SESSION_ID")
|
|
226
|
+
if [ -z "$TEST_FILES" ]; then
|
|
227
|
+
return
|
|
228
|
+
fi
|
|
229
|
+
while IFS= read -r test_file; do
|
|
230
|
+
local state
|
|
231
|
+
state=$(tdd_read_state "$SESSION_ID" "$test_file")
|
|
232
|
+
echo "${test_file}:${state}"
|
|
233
|
+
done <<< "$TEST_FILES"
|
|
234
|
+
}
|
|
235
|
+
|
|
111
236
|
# --- Test Runner ---
|
|
112
|
-
# Runs
|
|
237
|
+
# Runs a single test file.
|
|
113
238
|
# Returns: 0=pass, 1=fail, 124=timeout, other=error
|
|
114
239
|
# Saves stdout to marker file for debugging.
|
|
115
|
-
|
|
240
|
+
tdd_run_test_file() {
|
|
116
241
|
local SESSION_ID="$1"
|
|
242
|
+
local TEST_FILE="$2"
|
|
117
243
|
local STDOUT_FILE
|
|
118
244
|
STDOUT_FILE=$(_tdd_test_stdout_file "$SESSION_ID")
|
|
119
|
-
local TEST_FILES
|
|
120
|
-
TEST_FILES=$(tdd_get_test_files "$SESSION_ID")
|
|
121
245
|
|
|
122
|
-
if [ -z "$
|
|
123
|
-
echo "No test
|
|
124
|
-
return 0
|
|
246
|
+
if [ -z "$TEST_FILE" ]; then
|
|
247
|
+
echo "No test file specified" > "$STDOUT_FILE"
|
|
248
|
+
return 0
|
|
125
249
|
fi
|
|
126
250
|
|
|
127
|
-
#
|
|
128
|
-
local ARGS=""
|
|
129
|
-
while IFS= read -r f; do
|
|
130
|
-
ARGS="$ARGS $f"
|
|
131
|
-
done <<< "$TEST_FILES"
|
|
132
|
-
|
|
133
|
-
# Run with timeout
|
|
251
|
+
# Run with timeout — only the specified test file
|
|
134
252
|
local EXIT_CODE
|
|
135
|
-
timeout "$TDD_TEST_TIMEOUT" bash -c "$TDD_TEST_CMD $
|
|
253
|
+
timeout "$TDD_TEST_TIMEOUT" bash -c "$TDD_TEST_CMD $TEST_FILE" > "$STDOUT_FILE" 2>&1
|
|
136
254
|
EXIT_CODE=$?
|
|
137
255
|
|
|
138
256
|
return $EXIT_CODE
|
|
@@ -155,9 +273,12 @@ tdd_has_test_script() {
|
|
|
155
273
|
# --- Cleanup ---
|
|
156
274
|
tdd_cleanup() {
|
|
157
275
|
local SESSION_ID="$1"
|
|
158
|
-
|
|
276
|
+
local STATE_DIR
|
|
277
|
+
STATE_DIR=$(_tdd_state_dir "$SESSION_ID")
|
|
278
|
+
rm -rf "$STATE_DIR"
|
|
159
279
|
rm -f "$(_tdd_test_files_file "$SESSION_ID")"
|
|
160
280
|
rm -f "$(_tdd_test_stdout_file "$SESSION_ID")"
|
|
281
|
+
rm -f "/tmp/tdd-setup-active-${SESSION_ID}"
|
|
161
282
|
}
|
|
162
283
|
|
|
163
284
|
# --- Deny Helper ---
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# TDD - PreToolUse enforcement hook (Edit|Write)
|
|
3
|
-
# Blocks implementation file edits unless state is RED or GREEN.
|
|
3
|
+
# Blocks implementation file edits unless the associated test's state is RED or GREEN.
|
|
4
4
|
# Test files and exempt files are always allowed.
|
|
5
|
+
# Per-file state: a failing Countdown test does NOT block editing Hero.
|
|
5
6
|
|
|
6
7
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
8
|
source "$SCRIPT_DIR/lib/tdd-gate.sh"
|
|
@@ -15,16 +16,30 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
15
16
|
exit 0
|
|
16
17
|
fi
|
|
17
18
|
|
|
19
|
+
# P004: Only gate files inside the project root.
|
|
20
|
+
case "$FILE_PATH" in
|
|
21
|
+
/*)
|
|
22
|
+
case "$FILE_PATH" in
|
|
23
|
+
"$PWD"/*) ;;
|
|
24
|
+
*) exit 0 ;;
|
|
25
|
+
esac
|
|
26
|
+
;;
|
|
27
|
+
esac
|
|
28
|
+
|
|
18
29
|
# Classify first, then check test script (only impl files need gating)
|
|
19
30
|
FILE_TYPE=$(tdd_classify_file "$FILE_PATH")
|
|
20
31
|
if [ "$FILE_TYPE" != "impl" ]; then
|
|
21
32
|
exit 0
|
|
22
33
|
fi
|
|
23
34
|
|
|
24
|
-
# If no test script configured,
|
|
35
|
+
# If no test script configured, check if setup skill is running
|
|
25
36
|
if ! tdd_has_test_script; then
|
|
37
|
+
# Allow edits if the setup skill is actively running (chicken-and-egg bypass)
|
|
38
|
+
if [ -n "$SESSION_ID" ] && [ -f "/tmp/tdd-setup-active-${SESSION_ID}" ]; then
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
26
41
|
BASENAME=$(basename "$FILE_PATH")
|
|
27
|
-
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' because no test script is configured in package.json. Run /wr-tdd:
|
|
42
|
+
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' because no test script is configured in package.json. Run /wr-tdd:setup-tests to set up a test framework for this project. TDD enforcement requires a working test runner before implementation code can be written."
|
|
28
43
|
exit 0
|
|
29
44
|
fi
|
|
30
45
|
|
|
@@ -34,25 +49,26 @@ if [ -z "$SESSION_ID" ]; then
|
|
|
34
49
|
exit 0
|
|
35
50
|
fi
|
|
36
51
|
|
|
37
|
-
# Check state for implementation
|
|
38
|
-
STATE=$(
|
|
52
|
+
# Check state for THIS implementation file's associated test
|
|
53
|
+
STATE=$(tdd_read_state_for_impl "$SESSION_ID" "$FILE_PATH")
|
|
39
54
|
BASENAME=$(basename "$FILE_PATH")
|
|
55
|
+
SUGGESTED_TEST=$(tdd_suggest_test_path "$FILE_PATH")
|
|
40
56
|
|
|
41
57
|
case "$STATE" in
|
|
42
58
|
RED|GREEN)
|
|
43
|
-
# Allowed:
|
|
59
|
+
# Allowed: this file's test is in the TDD cycle
|
|
44
60
|
exit 0
|
|
45
61
|
;;
|
|
46
62
|
IDLE)
|
|
47
|
-
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- no tests written yet. TDD state is IDLE.
|
|
63
|
+
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- no tests written for this file yet. TDD state is IDLE. Write a failing test first (e.g., ${SUGGESTED_TEST}) before editing this implementation file."
|
|
48
64
|
exit 0
|
|
49
65
|
;;
|
|
50
66
|
BLOCKED)
|
|
51
|
-
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- test runner
|
|
67
|
+
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- its test runner timed out. TDD state is BLOCKED. Fix the test setup for this file before continuing."
|
|
52
68
|
exit 0
|
|
53
69
|
;;
|
|
54
70
|
*)
|
|
55
|
-
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- unknown TDD state '${STATE}'. Write a failing test first."
|
|
71
|
+
tdd_deny_json "BLOCKED: Cannot edit '${BASENAME}' -- unknown TDD state '${STATE}'. Write a failing test first (e.g., ${SUGGESTED_TEST})."
|
|
56
72
|
exit 0
|
|
57
73
|
;;
|
|
58
74
|
esac
|
package/hooks/tdd-inject.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# TDD - UserPromptSubmit hook
|
|
3
|
-
# Injects TDD instructions and
|
|
3
|
+
# Injects TDD instructions and per-file state into every prompt.
|
|
4
4
|
# Only active when a test script is configured in package.json.
|
|
5
5
|
|
|
6
6
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -18,56 +18,67 @@ This project has NO test script configured in package.json. Implementation file
|
|
|
18
18
|
edits (.ts, .tsx, .js, .jsx) are BLOCKED until testing is set up.
|
|
19
19
|
|
|
20
20
|
If the user's task involves writing or editing implementation code, you MUST
|
|
21
|
-
run /wr-tdd:
|
|
21
|
+
run /wr-tdd:setup-tests first to configure a test framework for this project.
|
|
22
22
|
|
|
23
23
|
Test files, config files, docs, and styles are still writable.
|
|
24
24
|
HOOK_OUTPUT
|
|
25
25
|
exit 0
|
|
26
26
|
fi
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Collect per-file states
|
|
29
|
+
ALL_STATES=""
|
|
29
30
|
if [ -n "$SESSION_ID" ]; then
|
|
30
|
-
|
|
31
|
+
ALL_STATES=$(tdd_get_all_states "$SESSION_ID")
|
|
31
32
|
fi
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Determine overall status for the header
|
|
35
|
+
OVERALL="IDLE"
|
|
36
|
+
if [ -n "$ALL_STATES" ]; then
|
|
37
|
+
if echo "$ALL_STATES" | grep -q ":BLOCKED$"; then
|
|
38
|
+
OVERALL="BLOCKED (some tests)"
|
|
39
|
+
elif echo "$ALL_STATES" | grep -q ":RED$"; then
|
|
40
|
+
OVERALL="RED (some tests failing)"
|
|
41
|
+
elif echo "$ALL_STATES" | grep -q ":GREEN$"; then
|
|
42
|
+
OVERALL="GREEN"
|
|
43
|
+
fi
|
|
36
44
|
fi
|
|
37
45
|
|
|
38
46
|
cat <<HOOK_OUTPUT
|
|
39
47
|
INSTRUCTION: MANDATORY TDD ENFORCEMENT. YOU MUST FOLLOW THIS.
|
|
40
48
|
|
|
41
|
-
This project enforces Red-Green-Refactor via hooks. Your current TDD state is: **${
|
|
49
|
+
This project enforces Red-Green-Refactor via hooks. Your current TDD state is: **${OVERALL}**
|
|
42
50
|
|
|
43
|
-
STATE RULES:
|
|
51
|
+
STATE RULES (per test file — each component has independent state):
|
|
44
52
|
- IDLE: You MUST write a failing test FIRST before any implementation code.
|
|
45
|
-
Implementation file edits (.ts, .tsx, .js, .jsx) are BLOCKED until you write a test.
|
|
53
|
+
Implementation file edits (.ts, .tsx, .js, .jsx) are BLOCKED until you write a test for that file.
|
|
46
54
|
- RED: Tests are failing. Write implementation code to make them pass.
|
|
47
|
-
Implementation file edits are ALLOWED.
|
|
55
|
+
Implementation file edits are ALLOWED for files whose associated test is RED.
|
|
48
56
|
- GREEN: Tests are passing. You may refactor or write a new failing test.
|
|
49
|
-
Implementation file edits are ALLOWED.
|
|
50
|
-
- BLOCKED: Test runner
|
|
51
|
-
Implementation file edits are BLOCKED.
|
|
57
|
+
Implementation file edits are ALLOWED for files whose associated test is GREEN.
|
|
58
|
+
- BLOCKED: Test runner timed out. Fix the test setup before continuing.
|
|
59
|
+
Implementation file edits are BLOCKED for files whose associated test is BLOCKED.
|
|
52
60
|
|
|
53
61
|
WORKFLOW:
|
|
54
|
-
1. Write a test file (*.test.ts
|
|
62
|
+
1. Write a test file (*.test.ts or *.spec.ts) that describes the desired behavior
|
|
55
63
|
2. The test MUST fail (RED state) -- this proves the test is meaningful
|
|
56
64
|
3. Write the minimum implementation to make the test pass (GREEN state)
|
|
57
65
|
4. Refactor while keeping tests green
|
|
58
66
|
5. Repeat for the next behavior
|
|
59
67
|
|
|
60
68
|
IMPORTANT:
|
|
69
|
+
- State is tracked PER TEST FILE — a failing Countdown test does NOT block editing Hero
|
|
61
70
|
- Test files and config/doc/style files are ALWAYS writable regardless of state
|
|
62
|
-
- Implementation files are ONLY writable
|
|
63
|
-
- The hook runs
|
|
71
|
+
- Implementation files are ONLY writable when their associated test is RED or GREEN
|
|
72
|
+
- The hook runs only the relevant test after each file write (not the full suite)
|
|
64
73
|
- To refactor existing code, touch the relevant test file first to enter the cycle
|
|
65
74
|
HOOK_OUTPUT
|
|
66
75
|
|
|
67
|
-
if [ -n "$
|
|
76
|
+
if [ -n "$ALL_STATES" ]; then
|
|
68
77
|
echo ""
|
|
69
78
|
echo "TRACKED TEST FILES THIS SESSION:"
|
|
70
|
-
echo "$
|
|
79
|
+
echo "$ALL_STATES" | while IFS=: read -r file state; do
|
|
80
|
+
echo " - ${file} [${state}]"
|
|
81
|
+
done
|
|
71
82
|
fi
|
|
72
83
|
|
|
73
84
|
exit 0
|
package/hooks/tdd-post-write.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# TDD - PostToolUse hook (Edit|Write)
|
|
3
|
-
# Runs
|
|
3
|
+
# Runs only the relevant test after file writes and transitions per-file state.
|
|
4
4
|
# Emits additionalContext with the current TDD state.
|
|
5
5
|
|
|
6
6
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -28,45 +28,49 @@ if [ "$FILE_TYPE" = "exempt" ]; then
|
|
|
28
28
|
exit 0
|
|
29
29
|
fi
|
|
30
30
|
|
|
31
|
-
#
|
|
31
|
+
# Determine which test file to run
|
|
32
|
+
TEST_FILE=""
|
|
33
|
+
|
|
32
34
|
if [ "$FILE_TYPE" = "test" ]; then
|
|
35
|
+
# Written a test file — track it and run it
|
|
33
36
|
tdd_add_test_file "$SESSION_ID" "$FILE_PATH"
|
|
37
|
+
TEST_FILE="$FILE_PATH"
|
|
38
|
+
elif [ "$FILE_TYPE" = "impl" ]; then
|
|
39
|
+
# Written an impl file — find and run its associated test
|
|
40
|
+
TEST_FILE=$(tdd_find_test_for_impl "$SESSION_ID" "$FILE_PATH")
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# If no test file to run, nothing to do
|
|
44
|
+
if [ -z "$TEST_FILE" ]; then
|
|
45
|
+
exit 0
|
|
34
46
|
fi
|
|
35
47
|
|
|
36
|
-
# Run
|
|
37
|
-
|
|
48
|
+
# Run only the relevant test file
|
|
49
|
+
tdd_run_test_file "$SESSION_ID" "$TEST_FILE"
|
|
38
50
|
TEST_EXIT=$?
|
|
39
51
|
|
|
40
|
-
# Read current state
|
|
41
|
-
OLD_STATE=$(tdd_read_state "$SESSION_ID")
|
|
52
|
+
# Read current state for this specific test file
|
|
53
|
+
OLD_STATE=$(tdd_read_state "$SESSION_ID" "$TEST_FILE")
|
|
42
54
|
|
|
43
|
-
# Transition state based on
|
|
55
|
+
# Transition state based on test result
|
|
56
|
+
# Only timeout (124) → BLOCKED. All other failures → RED.
|
|
44
57
|
NEW_STATE="$OLD_STATE"
|
|
45
58
|
|
|
46
59
|
if [ $TEST_EXIT -eq 0 ]; then
|
|
47
60
|
# Tests pass
|
|
48
61
|
NEW_STATE="GREEN"
|
|
49
|
-
elif [ $TEST_EXIT -eq
|
|
50
|
-
#
|
|
51
|
-
case "$FILE_TYPE" in
|
|
52
|
-
test)
|
|
53
|
-
NEW_STATE="RED"
|
|
54
|
-
;;
|
|
55
|
-
impl)
|
|
56
|
-
case "$OLD_STATE" in
|
|
57
|
-
RED) NEW_STATE="RED" ;; # still working
|
|
58
|
-
GREEN) NEW_STATE="RED" ;; # broke something
|
|
59
|
-
*) NEW_STATE="RED" ;;
|
|
60
|
-
esac
|
|
61
|
-
;;
|
|
62
|
-
esac
|
|
63
|
-
else
|
|
64
|
-
# Timeout (124) or other error
|
|
62
|
+
elif [ $TEST_EXIT -eq 124 ]; then
|
|
63
|
+
# Timeout — genuinely broken setup
|
|
65
64
|
NEW_STATE="BLOCKED"
|
|
65
|
+
else
|
|
66
|
+
# Tests fail (exit code 1, 2, or any other non-zero)
|
|
67
|
+
# This includes import errors, syntax errors, assertion failures.
|
|
68
|
+
# All of these are RED — the user should write impl to fix them.
|
|
69
|
+
NEW_STATE="RED"
|
|
66
70
|
fi
|
|
67
71
|
|
|
68
|
-
# Write new state
|
|
69
|
-
tdd_write_state "$SESSION_ID" "$NEW_STATE"
|
|
72
|
+
# Write new state for this specific test file
|
|
73
|
+
tdd_write_state "$SESSION_ID" "$TEST_FILE" "$NEW_STATE"
|
|
70
74
|
|
|
71
75
|
# Read last test output for context
|
|
72
76
|
STDOUT_FILE="/tmp/tdd-test-stdout-${SESSION_ID}"
|
|
@@ -85,7 +89,8 @@ fi
|
|
|
85
89
|
|
|
86
90
|
cat <<EOF
|
|
87
91
|
TDD STATE UPDATE: ${TRANSITION}
|
|
88
|
-
|
|
92
|
+
Test file: ${TEST_FILE}
|
|
93
|
+
State: ${NEW_STATE}
|
|
89
94
|
File written: ${FILE_PATH} (${FILE_TYPE})
|
|
90
95
|
Test result: exit code ${TEST_EXIT}
|
|
91
96
|
EOF
|
|
@@ -99,15 +104,15 @@ fi
|
|
|
99
104
|
case "$NEW_STATE" in
|
|
100
105
|
RED)
|
|
101
106
|
echo ""
|
|
102
|
-
echo "ACTION: Tests are failing. Write implementation code to make them pass."
|
|
107
|
+
echo "ACTION: Tests are failing for ${TEST_FILE}. Write implementation code to make them pass."
|
|
103
108
|
;;
|
|
104
109
|
GREEN)
|
|
105
110
|
echo ""
|
|
106
|
-
echo "ACTION: Tests are passing. You may refactor or write a new failing test for the next behavior."
|
|
111
|
+
echo "ACTION: Tests are passing for ${TEST_FILE}. You may refactor or write a new failing test for the next behavior."
|
|
107
112
|
;;
|
|
108
113
|
BLOCKED)
|
|
109
114
|
echo ""
|
|
110
|
-
echo "ACTION: Test runner
|
|
115
|
+
echo "ACTION: Test runner timed out for ${TEST_FILE} (exit code ${TEST_EXIT}). Fix the test setup before continuing."
|
|
111
116
|
;;
|
|
112
117
|
esac
|
|
113
118
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse:Skill hook — sets a marker when /wr-tdd:setup-tests is invoked.
|
|
3
|
+
# This marker allows tdd-enforce-edit.sh to permit edits during test setup,
|
|
4
|
+
# avoiding the chicken-and-egg problem where the setup skill needs to write
|
|
5
|
+
# .ts/.js files but the enforce hook blocks them.
|
|
6
|
+
|
|
7
|
+
INPUT=$(cat)
|
|
8
|
+
|
|
9
|
+
SKILL_NAME=$(echo "$INPUT" | jq -r '.tool_input.skill // empty') || true
|
|
10
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') || true
|
|
11
|
+
|
|
12
|
+
if [ -z "$SESSION_ID" ] || [ -z "$SKILL_NAME" ]; then
|
|
13
|
+
exit 0
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
# Match the TDD setup skill (handles both full and short names)
|
|
17
|
+
case "$SKILL_NAME" in
|
|
18
|
+
*tdd*setup*|*setup*test*)
|
|
19
|
+
touch "/tmp/tdd-setup-active-${SESSION_ID}"
|
|
20
|
+
;;
|
|
21
|
+
esac
|
|
22
|
+
|
|
23
|
+
exit 0
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Tests for tdd-gate.sh shared library functions
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
source "$SCRIPT_DIR/lib/tdd-gate.sh"
|
|
8
|
+
TEST_SESSION="test-$$-$BATS_TEST_NUMBER"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
teardown() {
|
|
12
|
+
tdd_cleanup "$TEST_SESSION" 2>/dev/null || true
|
|
13
|
+
rm -f "/tmp/tdd-setup-active-${TEST_SESSION}"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# --- tdd_classify_file ---
|
|
17
|
+
|
|
18
|
+
@test "classify_file: .test.ts is test" {
|
|
19
|
+
result=$(tdd_classify_file "src/utils.test.ts")
|
|
20
|
+
[ "$result" = "test" ]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@test "classify_file: .spec.jsx is test" {
|
|
24
|
+
result=$(tdd_classify_file "src/App.spec.jsx")
|
|
25
|
+
[ "$result" = "test" ]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@test "classify_file: __tests__ directory is test" {
|
|
29
|
+
result=$(tdd_classify_file "src/__tests__/utils.ts")
|
|
30
|
+
[ "$result" = "test" ]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@test "classify_file: .config.ts is exempt" {
|
|
34
|
+
result=$(tdd_classify_file "vitest.config.ts")
|
|
35
|
+
[ "$result" = "exempt" ]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@test "classify_file: .setup.ts is exempt" {
|
|
39
|
+
result=$(tdd_classify_file "vitest.setup.ts")
|
|
40
|
+
[ "$result" = "exempt" ]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "classify_file: .json is exempt" {
|
|
44
|
+
result=$(tdd_classify_file "package.json")
|
|
45
|
+
[ "$result" = "exempt" ]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@test "classify_file: .css is exempt" {
|
|
49
|
+
result=$(tdd_classify_file "src/styles.css")
|
|
50
|
+
[ "$result" = "exempt" ]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@test "classify_file: .md is exempt" {
|
|
54
|
+
result=$(tdd_classify_file "README.md")
|
|
55
|
+
[ "$result" = "exempt" ]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@test "classify_file: .sh is exempt" {
|
|
59
|
+
result=$(tdd_classify_file "scripts/build.sh")
|
|
60
|
+
[ "$result" = "exempt" ]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@test "classify_file: .ts is impl" {
|
|
64
|
+
result=$(tdd_classify_file "src/utils.ts")
|
|
65
|
+
[ "$result" = "impl" ]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@test "classify_file: .tsx is impl" {
|
|
69
|
+
result=$(tdd_classify_file "src/App.tsx")
|
|
70
|
+
[ "$result" = "impl" ]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@test "classify_file: .js is impl" {
|
|
74
|
+
result=$(tdd_classify_file "src/index.js")
|
|
75
|
+
[ "$result" = "impl" ]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@test "classify_file: unknown extension is exempt" {
|
|
79
|
+
result=$(tdd_classify_file "Dockerfile")
|
|
80
|
+
[ "$result" = "exempt" ]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# --- tdd_has_test_script ---
|
|
84
|
+
|
|
85
|
+
@test "has_test_script: returns false when no package.json" {
|
|
86
|
+
cd "$(mktemp -d)"
|
|
87
|
+
run tdd_has_test_script
|
|
88
|
+
[ "$status" -ne 0 ]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@test "has_test_script: returns false for default npm test script" {
|
|
92
|
+
local tmpdir=$(mktemp -d)
|
|
93
|
+
echo '{"scripts":{"test":"echo \"Error: no test specified\" && exit 1"}}' > "$tmpdir/package.json"
|
|
94
|
+
cd "$tmpdir"
|
|
95
|
+
run tdd_has_test_script
|
|
96
|
+
[ "$status" -ne 0 ]
|
|
97
|
+
rm -rf "$tmpdir"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@test "has_test_script: returns true for real test script" {
|
|
101
|
+
local tmpdir=$(mktemp -d)
|
|
102
|
+
echo '{"scripts":{"test":"vitest"}}' > "$tmpdir/package.json"
|
|
103
|
+
cd "$tmpdir"
|
|
104
|
+
run tdd_has_test_script
|
|
105
|
+
[ "$status" -eq 0 ]
|
|
106
|
+
rm -rf "$tmpdir"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# --- Per-test-file state ---
|
|
110
|
+
|
|
111
|
+
@test "read_state: returns IDLE when no state exists for test file" {
|
|
112
|
+
result=$(tdd_read_state "$TEST_SESSION" "src/Hero.test.tsx")
|
|
113
|
+
[ "$result" = "IDLE" ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@test "write_state and read_state roundtrip for a specific test file" {
|
|
117
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "RED"
|
|
118
|
+
result=$(tdd_read_state "$TEST_SESSION" "src/Hero.test.tsx")
|
|
119
|
+
[ "$result" = "RED" ]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@test "per-file state: different test files have independent states" {
|
|
123
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "GREEN"
|
|
124
|
+
tdd_write_state "$TEST_SESSION" "src/Countdown.test.tsx" "RED"
|
|
125
|
+
|
|
126
|
+
hero_state=$(tdd_read_state "$TEST_SESSION" "src/Hero.test.tsx")
|
|
127
|
+
countdown_state=$(tdd_read_state "$TEST_SESSION" "src/Countdown.test.tsx")
|
|
128
|
+
|
|
129
|
+
[ "$hero_state" = "GREEN" ]
|
|
130
|
+
[ "$countdown_state" = "RED" ]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@test "per-file state: BLOCKED on one test does not affect another" {
|
|
134
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "GREEN"
|
|
135
|
+
tdd_write_state "$TEST_SESSION" "src/Countdown.test.tsx" "BLOCKED"
|
|
136
|
+
|
|
137
|
+
hero_state=$(tdd_read_state "$TEST_SESSION" "src/Hero.test.tsx")
|
|
138
|
+
[ "$hero_state" = "GREEN" ]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@test "cleanup removes all per-file state" {
|
|
142
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "RED"
|
|
143
|
+
tdd_write_state "$TEST_SESSION" "src/Countdown.test.tsx" "GREEN"
|
|
144
|
+
tdd_cleanup "$TEST_SESSION"
|
|
145
|
+
|
|
146
|
+
result=$(tdd_read_state "$TEST_SESSION" "src/Hero.test.tsx")
|
|
147
|
+
[ "$result" = "IDLE" ]
|
|
148
|
+
result=$(tdd_read_state "$TEST_SESSION" "src/Countdown.test.tsx")
|
|
149
|
+
[ "$result" = "IDLE" ]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@test "cleanup removes setup marker" {
|
|
153
|
+
touch "/tmp/tdd-setup-active-${TEST_SESSION}"
|
|
154
|
+
tdd_cleanup "$TEST_SESSION"
|
|
155
|
+
[ ! -f "/tmp/tdd-setup-active-${TEST_SESSION}" ]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# --- Test file tracking ---
|
|
159
|
+
|
|
160
|
+
@test "add_test_file and get_test_files roundtrip" {
|
|
161
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
162
|
+
result=$(tdd_get_test_files "$TEST_SESSION")
|
|
163
|
+
[ "$result" = "src/Hero.test.tsx" ]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@test "add_test_file deduplicates" {
|
|
167
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
168
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
169
|
+
local count
|
|
170
|
+
count=$(tdd_get_test_files "$TEST_SESSION" | wc -l | tr -d ' ')
|
|
171
|
+
[ "$count" -eq 1 ]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# --- Impl-to-test association ---
|
|
175
|
+
|
|
176
|
+
@test "find_test_for_impl: Hero.tsx associates with tracked Hero.test.tsx" {
|
|
177
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
178
|
+
result=$(tdd_find_test_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
179
|
+
[ "$result" = "src/Hero.test.tsx" ]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@test "find_test_for_impl: utils.ts associates with tracked utils.test.ts" {
|
|
183
|
+
tdd_add_test_file "$TEST_SESSION" "src/utils.test.ts"
|
|
184
|
+
result=$(tdd_find_test_for_impl "$TEST_SESSION" "src/utils.ts")
|
|
185
|
+
[ "$result" = "src/utils.test.ts" ]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@test "suggest_test_path: Hero.tsx suggests Hero.test.tsx" {
|
|
189
|
+
result=$(tdd_suggest_test_path "src/Hero.tsx")
|
|
190
|
+
[ "$result" = "src/Hero.test.tsx" ]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@test "suggest_test_path: utils.ts suggests utils.test.ts" {
|
|
194
|
+
result=$(tdd_suggest_test_path "src/utils.ts")
|
|
195
|
+
[ "$result" = "src/utils.test.ts" ]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@test "find_test_for_impl: prefers tracked .test over .spec" {
|
|
199
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.spec.tsx"
|
|
200
|
+
result=$(tdd_find_test_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
201
|
+
[ "$result" = "src/Hero.spec.tsx" ]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@test "find_test_for_impl: finds __tests__ variant when tracked" {
|
|
205
|
+
tdd_add_test_file "$TEST_SESSION" "src/__tests__/Hero.test.tsx"
|
|
206
|
+
result=$(tdd_find_test_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
207
|
+
[ "$result" = "src/__tests__/Hero.test.tsx" ]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@test "find_test_for_impl: returns empty when no test tracked" {
|
|
211
|
+
result=$(tdd_find_test_for_impl "$TEST_SESSION" "src/Unknown.tsx")
|
|
212
|
+
[ -z "$result" ]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# --- State for impl files (via association) ---
|
|
216
|
+
|
|
217
|
+
@test "read_state_for_impl: returns IDLE when no associated test" {
|
|
218
|
+
result=$(tdd_read_state_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
219
|
+
[ "$result" = "IDLE" ]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@test "read_state_for_impl: returns associated test's state" {
|
|
223
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
224
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "RED"
|
|
225
|
+
result=$(tdd_read_state_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
226
|
+
[ "$result" = "RED" ]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@test "read_state_for_impl: Hero GREEN while Countdown BLOCKED" {
|
|
230
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
231
|
+
tdd_add_test_file "$TEST_SESSION" "src/Countdown.test.tsx"
|
|
232
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "GREEN"
|
|
233
|
+
tdd_write_state "$TEST_SESSION" "src/Countdown.test.tsx" "BLOCKED"
|
|
234
|
+
|
|
235
|
+
hero=$(tdd_read_state_for_impl "$TEST_SESSION" "src/Hero.tsx")
|
|
236
|
+
countdown=$(tdd_read_state_for_impl "$TEST_SESSION" "src/Countdown.tsx")
|
|
237
|
+
|
|
238
|
+
[ "$hero" = "GREEN" ]
|
|
239
|
+
[ "$countdown" = "BLOCKED" ]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# --- get_all_states ---
|
|
243
|
+
|
|
244
|
+
@test "get_all_states: lists all test files with their states" {
|
|
245
|
+
tdd_add_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
246
|
+
tdd_add_test_file "$TEST_SESSION" "src/Countdown.test.tsx"
|
|
247
|
+
tdd_write_state "$TEST_SESSION" "src/Hero.test.tsx" "GREEN"
|
|
248
|
+
tdd_write_state "$TEST_SESSION" "src/Countdown.test.tsx" "RED"
|
|
249
|
+
|
|
250
|
+
result=$(tdd_get_all_states "$TEST_SESSION")
|
|
251
|
+
echo "$result" | grep -q "src/Hero.test.tsx:GREEN"
|
|
252
|
+
echo "$result" | grep -q "src/Countdown.test.tsx:RED"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# --- Scoped test runner ---
|
|
256
|
+
|
|
257
|
+
@test "run_test_file: runs only the specified test file" {
|
|
258
|
+
local tmpdir=$(mktemp -d)
|
|
259
|
+
cd "$tmpdir"
|
|
260
|
+
|
|
261
|
+
TDD_TEST_CMD="echo"
|
|
262
|
+
tdd_run_test_file "$TEST_SESSION" "src/Hero.test.tsx"
|
|
263
|
+
local exit_code=$?
|
|
264
|
+
[ "$exit_code" -eq 0 ]
|
|
265
|
+
|
|
266
|
+
# The stdout file should contain just the one file path (from echo)
|
|
267
|
+
local stdout_file="/tmp/tdd-test-stdout-${TEST_SESSION}"
|
|
268
|
+
[ -f "$stdout_file" ]
|
|
269
|
+
grep -q "src/Hero.test.tsx" "$stdout_file"
|
|
270
|
+
# Should NOT contain any other test file
|
|
271
|
+
local line_count
|
|
272
|
+
line_count=$(wc -l < "$stdout_file" | tr -d ' ')
|
|
273
|
+
[ "$line_count" -eq 1 ]
|
|
274
|
+
|
|
275
|
+
rm -rf "$tmpdir"
|
|
276
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P004: tdd-enforce-edit.sh project-root check.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
HOOK="$SCRIPT_DIR/tdd-enforce-edit.sh"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
run_hook_with_file() {
|
|
11
|
+
local file_path="$1"
|
|
12
|
+
local json="{\"tool_input\":{\"file_path\":\"${file_path}\"},\"session_id\":\"test-$$\"}"
|
|
13
|
+
echo "$json" | bash "$HOOK"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@test "tdd project-root: absolute .ts outside project exits 0" {
|
|
17
|
+
run run_hook_with_file "/Users/other/project/src/foo.ts"
|
|
18
|
+
[ "$status" -eq 0 ]
|
|
19
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
20
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install utilities for @windyroad/* packages.
|
|
3
|
+
* Used by both per-plugin installers and the meta-installer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const MARKETPLACE_REPO = "windyroad/agent-plugins";
|
|
9
|
+
const MARKETPLACE_NAME = "windyroad";
|
|
10
|
+
|
|
11
|
+
let _dryRun = false;
|
|
12
|
+
|
|
13
|
+
export { MARKETPLACE_REPO, MARKETPLACE_NAME };
|
|
14
|
+
|
|
15
|
+
export function setDryRun(value) {
|
|
16
|
+
_dryRun = value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isDryRun() {
|
|
20
|
+
return _dryRun;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function run(cmd, label) {
|
|
24
|
+
console.log(` ${label}...`);
|
|
25
|
+
if (_dryRun) {
|
|
26
|
+
console.log(` [dry-run] ${cmd}`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
execSync(cmd, { stdio: "inherit" });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
console.error(` FAILED: ${label}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkPrerequisites() {
|
|
39
|
+
if (_dryRun) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
execSync("claude --version", { stdio: "pipe" });
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(
|
|
45
|
+
"Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function addMarketplace() {
|
|
52
|
+
return run(
|
|
53
|
+
`claude plugin marketplace add ${MARKETPLACE_REPO}`,
|
|
54
|
+
`Marketplace: ${MARKETPLACE_NAME}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function installPlugin(pluginName, { scope = "project" } = {}) {
|
|
59
|
+
return run(
|
|
60
|
+
`claude plugin install ${pluginName}@${MARKETPLACE_NAME} --scope ${scope}`,
|
|
61
|
+
pluginName
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function updatePlugin(pluginName) {
|
|
66
|
+
return run(`claude plugin update ${pluginName}`, pluginName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function uninstallPlugin(pluginName) {
|
|
70
|
+
return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Install a single package: marketplace add + plugin install.
|
|
75
|
+
*/
|
|
76
|
+
export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
|
|
77
|
+
console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
|
|
78
|
+
|
|
79
|
+
addMarketplace();
|
|
80
|
+
installPlugin(pluginName, { scope });
|
|
81
|
+
|
|
82
|
+
if (deps.length > 0) {
|
|
83
|
+
console.log(`\nNote: This plugin works best with:`);
|
|
84
|
+
for (const dep of deps) {
|
|
85
|
+
console.log(` - @windyroad/${dep.replace("wr-", "")} (npx @windyroad/${dep.replace("wr-", "")})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
`\nDone! Restart Claude Code to activate.\n`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update a single package.
|
|
96
|
+
*/
|
|
97
|
+
export function updatePackage(pluginName) {
|
|
98
|
+
console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
99
|
+
|
|
100
|
+
run(
|
|
101
|
+
`claude plugin marketplace update ${MARKETPLACE_NAME}`,
|
|
102
|
+
"Updating marketplace"
|
|
103
|
+
);
|
|
104
|
+
updatePlugin(pluginName);
|
|
105
|
+
|
|
106
|
+
console.log("\nDone! Restart Claude Code to apply updates.\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Uninstall a single package.
|
|
111
|
+
*/
|
|
112
|
+
export function uninstallPackage(pluginName) {
|
|
113
|
+
console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
114
|
+
|
|
115
|
+
uninstallPlugin(pluginName);
|
|
116
|
+
|
|
117
|
+
console.log("\nDone. Restart Claude Code to apply changes.\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse standard flags used by all per-plugin installers.
|
|
122
|
+
*/
|
|
123
|
+
export function parseStandardArgs(argv) {
|
|
124
|
+
const args = argv.slice(2);
|
|
125
|
+
const flags = {
|
|
126
|
+
help: args.includes("--help") || args.includes("-h"),
|
|
127
|
+
uninstall: args.includes("--uninstall"),
|
|
128
|
+
update: args.includes("--update"),
|
|
129
|
+
dryRun: args.includes("--dry-run"),
|
|
130
|
+
scope: "project",
|
|
131
|
+
};
|
|
132
|
+
const scopeIdx = args.indexOf("--scope");
|
|
133
|
+
if (scopeIdx !== -1 && args[scopeIdx + 1]) {
|
|
134
|
+
const val = args[scopeIdx + 1];
|
|
135
|
+
if (["project", "user", "local"].includes(val)) {
|
|
136
|
+
flags.scope = val;
|
|
137
|
+
} else {
|
|
138
|
+
console.error("--scope requires: project, user, or local");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return flags;
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windyroad/tdd",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "TDD state machine enforcement (Red-Green-Refactor cycle)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"windyroad-tdd": "./bin/install.mjs"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"agents/",
|
|
24
24
|
"hooks/",
|
|
25
25
|
"skills/",
|
|
26
|
-
".claude-plugin/"
|
|
26
|
+
".claude-plugin/",
|
|
27
|
+
"lib/"
|
|
27
28
|
]
|
|
28
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: wr:
|
|
2
|
+
name: wr-tdd:setup-tests
|
|
3
3
|
description: Set up a test framework for the project. Examines the codebase, recommends a test runner, configures package.json, and creates an example test.
|
|
4
4
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
|
|
5
5
|
---
|