@windyroad/retrospective 0.16.0 → 0.17.0-preview.277

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.
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Behavioural fixture for the tarball-shipped-shims advisory detector — per
4
+ # WR-P154 (P137 namespace-prefix detector must run against npm pack output
5
+ # not source tree) and the WR-ADR-049 / WR-ADR-052 / WR-ADR-055 cluster.
6
+ #
7
+ # Contract: `check-tarball-shipped-shims.sh [<root-dir>]` is a diagnose-only
8
+ # advisory script. It walks workspaces under `<root-dir>/packages/*/`, runs
9
+ # `npm pack --dry-run --json` per workspace to enumerate the file set that
10
+ # ships, then asserts that every WR-ADR-049-grammar bin shim
11
+ # (`bin/wr-<plugin>-<name>`) in the tarball has its `exec`'d
12
+ # `scripts/<name>.sh` target also in the tarball.
13
+ #
14
+ # Surface: shipped publish-manifest integrity for bin/scripts/ shim
15
+ # resolvability — different correctness axis than check-internal-id-leaks.sh
16
+ # (which measures source-tree namespace-prefix drift).
17
+ #
18
+ # WR-ADR-049-grammar shims that this script considers:
19
+ # bin/wr-<plugin>-<name>
20
+ # Non-grammar bins (e.g. `bin/install.mjs`, `bin/check-deps.sh`,
21
+ # `bin/windyroad-<plugin>` legacy installers) are skipped — they don't
22
+ # follow the script-resolution-via-bin-on-PATH ADR-049 contract this
23
+ # script enforces.
24
+ #
25
+ # Exit codes:
26
+ # 0 = always (advisory only — drift is signal, not failure)
27
+ # 2 = parse error (root dir missing/unreadable, or npm unavailable)
28
+ #
29
+ # Output format on drift (terse machine-readable per WR-ADR-038):
30
+ # TARBALL_DRIFT package=<name> shim=<bin/wr-...> target=<scripts/...> tarball-status=missing
31
+ #
32
+ # Followed by a final summary line (always emitted when any drift exists):
33
+ # TOTAL packages=<N> with_drift=<M> missing_targets=<K>
34
+ #
35
+ # Output is empty (no lines) when no shipped artefact carries broken
36
+ # shims — silent-on-pass per WR-ADR-045 hook injection budget.
37
+ #
38
+ # Output ordering (deterministic for stable retro-summary diffs):
39
+ # TARBALL_DRIFT lines sorted by `<package>/<shim>` identifier.
40
+ # TOTAL line last.
41
+ #
42
+ # Read-only — does NOT mutate any artefact. Per WR-ADR-052, this fixture
43
+ # is BEHAVIOURAL — it asserts script output on temp-fixture trees, NOT
44
+ # script source content. No greps of check-tarball-shipped-shims.sh source.
45
+ #
46
+ # @problem P154 (P137 namespace-prefix detector must run against
47
+ # npm pack output not source tree)
48
+ # @problem P140 (Step 6.5 fix-and-continue — same prevention surface)
49
+ # @adr ADR-049 (Plugin script resolution via bin/ on PATH)
50
+ # @adr ADR-038 (Progressive disclosure — terse machine-readable signal)
51
+ # @adr ADR-045 (Hook injection budget — silent-on-pass)
52
+ # @adr ADR-052 (Behavioural-tests-default — fixture pattern)
53
+ # @adr ADR-055 (Plugin-published namespace-prefixed permalinks —
54
+ # sibling adopter-context decision)
55
+ # @jtbd JTBD-302 (Trust That the README Describes the Plugin I Just
56
+ # Installed — executable correctness axis of adopter-facing content)
57
+ # @jtbd JTBD-101 (Extend the Suite with New Plugins — secondary
58
+ # plugin-developer feedback surface)
59
+
60
+ setup() {
61
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
62
+ SCRIPT="$SCRIPTS_DIR/check-tarball-shipped-shims.sh"
63
+ FIXTURE_ROOT="$(mktemp -d)"
64
+ }
65
+
66
+ teardown() {
67
+ rm -rf "$FIXTURE_ROOT"
68
+ }
69
+
70
+ # Helper: write a workspace package.json with a controlled `files` array.
71
+ # Args: <plugin> <files-json-array>
72
+ # Example: write_package_json alpha '["bin/", "scripts/"]'
73
+ write_package_json() {
74
+ local plugin="$1"
75
+ local files="$2"
76
+ local plugin_dir="$FIXTURE_ROOT/packages/$plugin"
77
+ mkdir -p "$plugin_dir"
78
+ cat > "$plugin_dir/package.json" <<EOF
79
+ {
80
+ "name": "@test/$plugin",
81
+ "version": "0.1.0",
82
+ "files": $files
83
+ }
84
+ EOF
85
+ }
86
+
87
+ # Helper: write an WR-ADR-049-grammar bin shim that exec-s a scripts/ target.
88
+ # Args: <plugin> <name> (shim becomes bin/wr-<plugin>-<name>, exec-s ../scripts/<name>.sh)
89
+ write_adr049_shim() {
90
+ local plugin="$1"
91
+ local name="$2"
92
+ local bin_dir="$FIXTURE_ROOT/packages/$plugin/bin"
93
+ mkdir -p "$bin_dir"
94
+ cat > "$bin_dir/wr-$plugin-$name" <<EOF
95
+ #!/usr/bin/env bash
96
+ exec "\$(dirname "\$0")/../scripts/$name.sh" "\$@"
97
+ EOF
98
+ chmod +x "$bin_dir/wr-$plugin-$name"
99
+ }
100
+
101
+ # Helper: write a script body matching the shim's exec target.
102
+ write_script() {
103
+ local plugin="$1"
104
+ local name="$2"
105
+ local script_dir="$FIXTURE_ROOT/packages/$plugin/scripts"
106
+ mkdir -p "$script_dir"
107
+ cat > "$script_dir/$name.sh" <<'EOF'
108
+ #!/usr/bin/env bash
109
+ echo "fixture script body"
110
+ EOF
111
+ chmod +x "$script_dir/$name.sh"
112
+ }
113
+
114
+ # Helper: write a non-WR-ADR-049-grammar bin entry (e.g. legacy installer).
115
+ # These should be silently ignored by the script.
116
+ write_non_grammar_bin() {
117
+ local plugin="$1"
118
+ local name="$2"
119
+ local bin_dir="$FIXTURE_ROOT/packages/$plugin/bin"
120
+ mkdir -p "$bin_dir"
121
+ cat > "$bin_dir/$name" <<'EOF'
122
+ #!/usr/bin/env bash
123
+ echo "legacy installer"
124
+ EOF
125
+ chmod +x "$bin_dir/$name"
126
+ }
127
+
128
+ # ── Existence + executable ──────────────────────────────────────────────────
129
+
130
+ @test "check-tarball-shipped-shims: script exists" {
131
+ [ -f "$SCRIPT" ]
132
+ }
133
+
134
+ @test "check-tarball-shipped-shims: script is executable" {
135
+ [ -x "$SCRIPT" ]
136
+ }
137
+
138
+ # ── Empty / clean trees ─────────────────────────────────────────────────────
139
+
140
+ @test "check-tarball-shipped-shims: empty tree (no packages dir) produces no output and exits 0" {
141
+ run "$SCRIPT" "$FIXTURE_ROOT"
142
+ [ "$status" -eq 0 ]
143
+ [ -z "$output" ]
144
+ }
145
+
146
+ @test "check-tarball-shipped-shims: packages dir with no workspaces produces no output and exits 0" {
147
+ mkdir -p "$FIXTURE_ROOT/packages"
148
+ run "$SCRIPT" "$FIXTURE_ROOT"
149
+ [ "$status" -eq 0 ]
150
+ [ -z "$output" ]
151
+ }
152
+
153
+ @test "check-tarball-shipped-shims: workspace with no WR-ADR-049 shims produces no output" {
154
+ write_package_json "alpha" '["bin/"]'
155
+ write_non_grammar_bin "alpha" "windyroad-alpha"
156
+ run "$SCRIPT" "$FIXTURE_ROOT"
157
+ [ "$status" -eq 0 ]
158
+ [ -z "$output" ]
159
+ }
160
+
161
+ @test "check-tarball-shipped-shims: clean workspace (shim + target both ship) produces no output" {
162
+ write_package_json "alpha" '["bin/", "scripts/"]'
163
+ write_adr049_shim "alpha" "good"
164
+ write_script "alpha" "good"
165
+ run "$SCRIPT" "$FIXTURE_ROOT"
166
+ [ "$status" -eq 0 ]
167
+ [ -z "$output" ]
168
+ }
169
+
170
+ # ── Drift detection — the iter-20 P033 sibling-finding shape ────────────────
171
+
172
+ @test "check-tarball-shipped-shims: shim present in tarball, target NOT in tarball — flagged" {
173
+ # The canonical broken shape: scripts/ exists on disk, shim references it,
174
+ # but package.json#files omits scripts/ so the target isn't shipped.
175
+ write_package_json "alpha" '["bin/"]'
176
+ write_adr049_shim "alpha" "broken"
177
+ write_script "alpha" "broken"
178
+ run "$SCRIPT" "$FIXTURE_ROOT"
179
+ [ "$status" -eq 0 ]
180
+ echo "$output" | grep -E "^TARBALL_DRIFT package=@test/alpha shim=bin/wr-alpha-broken target=scripts/broken.sh tarball-status=missing$"
181
+ }
182
+
183
+ @test "check-tarball-shipped-shims: TOTAL summary emitted on any drift" {
184
+ write_package_json "alpha" '["bin/"]'
185
+ write_adr049_shim "alpha" "broken"
186
+ write_script "alpha" "broken"
187
+ run "$SCRIPT" "$FIXTURE_ROOT"
188
+ [ "$status" -eq 0 ]
189
+ echo "$output" | grep -E "^TOTAL packages=1 with_drift=1 missing_targets=1$"
190
+ }
191
+
192
+ @test "check-tarball-shipped-shims: multiple shims in one workspace — each missing target flagged" {
193
+ write_package_json "alpha" '["bin/"]'
194
+ write_adr049_shim "alpha" "alpha-one"
195
+ write_script "alpha" "alpha-one"
196
+ write_adr049_shim "alpha" "alpha-two"
197
+ write_script "alpha" "alpha-two"
198
+ run "$SCRIPT" "$FIXTURE_ROOT"
199
+ [ "$status" -eq 0 ]
200
+ echo "$output" | grep -E "^TARBALL_DRIFT package=@test/alpha shim=bin/wr-alpha-alpha-one target=scripts/alpha-one.sh tarball-status=missing$"
201
+ echo "$output" | grep -E "^TARBALL_DRIFT package=@test/alpha shim=bin/wr-alpha-alpha-two target=scripts/alpha-two.sh tarball-status=missing$"
202
+ echo "$output" | grep -E "^TOTAL packages=1 with_drift=1 missing_targets=2$"
203
+ }
204
+
205
+ @test "check-tarball-shipped-shims: drift across multiple packages aggregates correctly" {
206
+ write_package_json "alpha" '["bin/"]'
207
+ write_adr049_shim "alpha" "a-broken"
208
+ write_script "alpha" "a-broken"
209
+ write_package_json "beta" '["bin/"]'
210
+ write_adr049_shim "beta" "b-broken"
211
+ write_script "beta" "b-broken"
212
+ run "$SCRIPT" "$FIXTURE_ROOT"
213
+ [ "$status" -eq 0 ]
214
+ echo "$output" | grep -E "^TOTAL packages=2 with_drift=2 missing_targets=2$"
215
+ }
216
+
217
+ # ── Mixed clean + drift workspaces ──────────────────────────────────────────
218
+
219
+ @test "check-tarball-shipped-shims: clean workspace + broken workspace — only broken flagged, TOTAL counts only with_drift" {
220
+ write_package_json "alpha" '["bin/", "scripts/"]'
221
+ write_adr049_shim "alpha" "good"
222
+ write_script "alpha" "good"
223
+ write_package_json "beta" '["bin/"]'
224
+ write_adr049_shim "beta" "broken"
225
+ write_script "beta" "broken"
226
+ run "$SCRIPT" "$FIXTURE_ROOT"
227
+ [ "$status" -eq 0 ]
228
+ ! echo "$output" | grep -q "alpha"
229
+ echo "$output" | grep -E "^TARBALL_DRIFT package=@test/beta shim=bin/wr-beta-broken target=scripts/broken.sh tarball-status=missing$"
230
+ echo "$output" | grep -E "^TOTAL packages=1 with_drift=1 missing_targets=1$"
231
+ }
232
+
233
+ # ── Determinism ─────────────────────────────────────────────────────────────
234
+
235
+ @test "check-tarball-shipped-shims: TARBALL_DRIFT lines sorted deterministically by package/shim" {
236
+ write_package_json "zeta" '["bin/"]'
237
+ write_adr049_shim "zeta" "z-shim"
238
+ write_script "zeta" "z-shim"
239
+ write_package_json "alpha" '["bin/"]'
240
+ write_adr049_shim "alpha" "a-shim"
241
+ write_script "alpha" "a-shim"
242
+ write_package_json "mu" '["bin/"]'
243
+ write_adr049_shim "mu" "m-shim"
244
+ write_script "mu" "m-shim"
245
+ run "$SCRIPT" "$FIXTURE_ROOT"
246
+ [ "$status" -eq 0 ]
247
+ local first
248
+ first=$(echo "$output" | grep '^TARBALL_DRIFT' | head -1)
249
+ echo "$first" | grep -q "package=@test/alpha"
250
+ local last
251
+ last=$(echo "$output" | grep '^TARBALL_DRIFT' | tail -1)
252
+ echo "$last" | grep -q "package=@test/zeta"
253
+ }
254
+
255
+ # ── Mixed grammar — non-WR-ADR-049 bins ignored ─────────────────────────────
256
+
257
+ @test "check-tarball-shipped-shims: legacy bin (non-WR-ADR-049 grammar) alongside grammar shim — only grammar shim checked" {
258
+ write_package_json "alpha" '["bin/"]'
259
+ write_non_grammar_bin "alpha" "windyroad-alpha"
260
+ write_adr049_shim "alpha" "broken"
261
+ write_script "alpha" "broken"
262
+ run "$SCRIPT" "$FIXTURE_ROOT"
263
+ [ "$status" -eq 0 ]
264
+ ! echo "$output" | grep -q "windyroad-alpha"
265
+ echo "$output" | grep -E "^TARBALL_DRIFT package=@test/alpha shim=bin/wr-alpha-broken target=scripts/broken.sh tarball-status=missing$"
266
+ }
267
+
268
+ # ── Pre-check error path ────────────────────────────────────────────────────
269
+
270
+ @test "check-tarball-shipped-shims: missing root dir exits 2" {
271
+ run "$SCRIPT" "/nonexistent/path/$$"
272
+ [ "$status" -eq 2 ]
273
+ }
274
+
275
+ # ── Silent-on-pass invariant ────────────────────────────────────────────────
276
+
277
+ @test "check-tarball-shipped-shims: no TOTAL line emitted when output is empty" {
278
+ write_package_json "alpha" '["bin/", "scripts/"]'
279
+ write_adr049_shim "alpha" "good"
280
+ write_script "alpha" "good"
281
+ run "$SCRIPT" "$FIXTURE_ROOT"
282
+ [ "$status" -eq 0 ]
283
+ [ -z "$output" ]
284
+ }
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # packages/retrospective/scripts/test/check-tickets-deferred-cause.bats
4
+ #
5
+ # Behavioural tests for `check-tickets-deferred-cause.sh` — the
6
+ # Tickets Deferred cause-allowlist advisory script (P148). Mirrors
7
+ # the fixture-based test pattern of `check-ask-hygiene.bats`.
8
+ #
9
+ # Tests are behavioural per ADR-005 / ADR-037 / P081 — they exercise
10
+ # the script end-to-end against fixture retro summary directories
11
+ # and assert on stdout / stderr / exit shape. No structural greps of
12
+ # the script source itself.
13
+ #
14
+ # @problem P148 (Agent defers ticket creation — broadens Stage 1 fallback gate)
15
+ # @problem P081 (Structural-content tests are wasteful — behavioural preferred)
16
+ # @adr ADR-044 (Decision-Delegation Contract)
17
+ # @adr ADR-040 (Tier 3 advisory-not-fail-closed)
18
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe)
19
+ # @adr ADR-005 / ADR-037 (Plugin testing strategy — behavioural tests)
20
+ # @jtbd JTBD-001 / JTBD-006 / JTBD-201
21
+
22
+ SCRIPT="${BATS_TEST_DIRNAME}/../check-tickets-deferred-cause.sh"
23
+
24
+ setup() {
25
+ TEST_DIR="$(mktemp -d)"
26
+ }
27
+
28
+ teardown() {
29
+ rm -rf "$TEST_DIR"
30
+ }
31
+
32
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
33
+
34
+ @test "script file exists and is executable" {
35
+ [ -f "$SCRIPT" ]
36
+ [ -x "$SCRIPT" ]
37
+ }
38
+
39
+ @test "missing retros dir exits 2 with error message on stderr" {
40
+ run bash "$SCRIPT" "$TEST_DIR/does-not-exist"
41
+ [ "$status" -eq 2 ]
42
+ [[ "$output" == *"retros dir not found"* ]]
43
+ }
44
+
45
+ @test "empty retros dir exits 0 with empty stdout" {
46
+ run bash "$SCRIPT" "$TEST_DIR"
47
+ [ "$status" -eq 0 ]
48
+ [ -z "$output" ]
49
+ }
50
+
51
+ @test "default retros-dir argument is docs/retros (when omitted)" {
52
+ cd "$TEST_DIR"
53
+ run bash "$SCRIPT"
54
+ [ "$status" -eq 2 ]
55
+ [[ "$output" == *"docs/retros"* ]]
56
+ }
57
+
58
+ # ── No-defer steady state ───────────────────────────────────────────────────
59
+
60
+ @test "retro file with no Tickets Deferred section emits no output" {
61
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
62
+ ## Session Retrospective
63
+
64
+ ### Briefing Changes
65
+ - Added: foo
66
+
67
+ ### Problems Created/Updated
68
+ - P148: opened
69
+
70
+ ### No Action Needed
71
+ - learning captured
72
+ RETRO
73
+ run bash "$SCRIPT" "$TEST_DIR"
74
+ [ "$status" -eq 0 ]
75
+ [ -z "$output" ]
76
+ }
77
+
78
+ # ── Good fixture (skill_unavailable cause) ──────────────────────────────────
79
+
80
+ @test "good fixture: skill_unavailable cause → 0 violations" {
81
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
82
+ ## Session Retrospective
83
+
84
+ ### Tickets Deferred
85
+
86
+ | Observation | Cause | Citation |
87
+ |-------------|-------|----------|
88
+ | Polling regex deadlock observation | `skill_unavailable` | Step 2b detection |
89
+ | SIGTERM flush caveat | `skill_unavailable` | Step 4a verification |
90
+ RETRO
91
+ run bash "$SCRIPT" "$TEST_DIR"
92
+ [ "$status" -eq 0 ]
93
+ [[ "$output" == *"deferred=2"* ]]
94
+ [[ "$output" == *"with_valid_cause=2"* ]]
95
+ [[ "$output" == *"violations=0"* ]]
96
+ }
97
+
98
+ # ── Bad fixture (session_pressure cause — the P148 anti-pattern) ────────────
99
+
100
+ @test "bad fixture: session_pressure cause → all entries are violations" {
101
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
102
+ ## Session Retrospective
103
+
104
+ ### Tickets Deferred
105
+
106
+ | Observation | Cause | Citation |
107
+ |-------------|-------|----------|
108
+ | Polling regex deadlock | `session_pressure` | Step 2b |
109
+ | SIGTERM flush caveat | `context_heavyweight` | Step 4a |
110
+ RETRO
111
+ run bash "$SCRIPT" "$TEST_DIR"
112
+ [ "$status" -eq 0 ]
113
+ [[ "$output" == *"deferred=2"* ]]
114
+ [[ "$output" == *"with_valid_cause=0"* ]]
115
+ [[ "$output" == *"violations=2"* ]]
116
+ }
117
+
118
+ # ── Legacy fixture (no Cause column — pre-P148 retro shape) ─────────────────
119
+
120
+ @test "legacy fixture: no Cause column → all entries are violations" {
121
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
122
+ ## Session Retrospective
123
+
124
+ ### Tickets Deferred
125
+
126
+ | Observation | Citation |
127
+ |-------------|----------|
128
+ | Polling regex deadlock | Step 2b |
129
+ | SIGTERM flush caveat | Step 4a |
130
+ RETRO
131
+ run bash "$SCRIPT" "$TEST_DIR"
132
+ [ "$status" -eq 0 ]
133
+ [[ "$output" == *"deferred=2"* ]]
134
+ [[ "$output" == *"violations=2"* ]]
135
+ }
136
+
137
+ @test "legacy fixture exit code is 0 — advisory contract holds for AFK safety" {
138
+ # JTBD-006 line 32 (extended AFK safety): legacy retros lacking the Cause
139
+ # column would break AFK loops if exit code went non-zero. Fail-closed
140
+ # escalation belongs at a future hook tier per P135 R6 trajectory, not
141
+ # at the script.
142
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
143
+ ### Tickets Deferred
144
+
145
+ | Observation | Citation |
146
+ |-------------|----------|
147
+ | obs1 | step1 |
148
+ RETRO
149
+ run bash "$SCRIPT" "$TEST_DIR"
150
+ [ "$status" -eq 0 ]
151
+ }
152
+
153
+ # ── Mixed fixture ───────────────────────────────────────────────────────────
154
+
155
+ @test "mixed fixture: one valid + one invalid → violations=1, with_valid_cause=1" {
156
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
157
+ ### Tickets Deferred
158
+
159
+ | Observation | Cause | Citation |
160
+ |-------------|-------|----------|
161
+ | Valid observation | `skill_unavailable` | Step 2b |
162
+ | Invalid observation | `session_pressure` | Step 4a |
163
+ RETRO
164
+ run bash "$SCRIPT" "$TEST_DIR"
165
+ [ "$status" -eq 0 ]
166
+ [[ "$output" == *"deferred=2"* ]]
167
+ [[ "$output" == *"with_valid_cause=1"* ]]
168
+ [[ "$output" == *"violations=1"* ]]
169
+ }
170
+
171
+ # ── Empty Cause cell ────────────────────────────────────────────────────────
172
+
173
+ @test "empty Cause cell counts as a violation (cause-required invariant)" {
174
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
175
+ ### Tickets Deferred
176
+
177
+ | Observation | Cause | Citation |
178
+ |-------------|-------|----------|
179
+ | Observation with empty cause | | Step 2b |
180
+ RETRO
181
+ run bash "$SCRIPT" "$TEST_DIR"
182
+ [ "$status" -eq 0 ]
183
+ [[ "$output" == *"violations=1"* ]]
184
+ }
185
+
186
+ # ── Format tolerance ────────────────────────────────────────────────────────
187
+
188
+ @test "Cause cell tolerates surrounding whitespace and bold markers" {
189
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
190
+ ### Tickets Deferred
191
+
192
+ | Observation | Cause | Citation |
193
+ |-------------|-------|----------|
194
+ | obs1 | `skill_unavailable` | Step 2b |
195
+ | obs2 | **skill_unavailable** | Step 4a |
196
+ RETRO
197
+ run bash "$SCRIPT" "$TEST_DIR"
198
+ [ "$status" -eq 0 ]
199
+ [[ "$output" == *"deferred=2"* ]]
200
+ [[ "$output" == *"with_valid_cause=2"* ]]
201
+ [[ "$output" == *"violations=0"* ]]
202
+ }
203
+
204
+ @test "placeholder template row is skipped — not counted as a deferred entry" {
205
+ # The retro summary template includes a placeholder example row in the
206
+ # SKILL.md template (with `<one-line observation summary>` literal text).
207
+ # When a retro is rendered with no real deferred entries, the template
208
+ # row may persist; the script must NOT count it as a real deferred
209
+ # observation.
210
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
211
+ ### Tickets Deferred
212
+
213
+ | Observation | Cause | Citation |
214
+ |-------------|-------|----------|
215
+ | <one-line observation summary> | `skill_unavailable` | <retro-step-citation> |
216
+ RETRO
217
+ run bash "$SCRIPT" "$TEST_DIR"
218
+ [ "$status" -eq 0 ]
219
+ [ -z "$output" ]
220
+ }
221
+
222
+ # ── Multi-file behaviour ────────────────────────────────────────────────────
223
+
224
+ @test "multiple retro files emit per-file lines plus a TOTAL summary line" {
225
+ cat > "$TEST_DIR/2026-04-25-retro.md" <<'RETRO'
226
+ ### Tickets Deferred
227
+
228
+ | Observation | Cause | Citation |
229
+ |-------------|-------|----------|
230
+ | good obs | `skill_unavailable` | Step 2b |
231
+ RETRO
232
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
233
+ ### Tickets Deferred
234
+
235
+ | Observation | Cause | Citation |
236
+ |-------------|-------|----------|
237
+ | bad obs | `session_pressure` | Step 4a |
238
+ RETRO
239
+ run bash "$SCRIPT" "$TEST_DIR"
240
+ [ "$status" -eq 0 ]
241
+ [[ "$output" == *"RETRO 2026-04-25"* ]]
242
+ [[ "$output" == *"RETRO 2026-04-29"* ]]
243
+ [[ "$output" == *"TOTAL files=2 deferred=2 with_valid_cause=1 violations=1"* ]]
244
+ }
245
+
246
+ @test "files sorted oldest-first by date prefix" {
247
+ for d in 27 25 26; do
248
+ cat > "$TEST_DIR/2026-04-$d-retro.md" <<RETRO
249
+ ### Tickets Deferred
250
+
251
+ | Observation | Cause | Citation |
252
+ |-------------|-------|----------|
253
+ | obs$d | \`skill_unavailable\` | Step$d |
254
+ RETRO
255
+ done
256
+ run bash "$SCRIPT" "$TEST_DIR"
257
+ [ "$status" -eq 0 ]
258
+ line1="${lines[0]}"
259
+ line2="${lines[1]}"
260
+ line3="${lines[2]}"
261
+ [[ "$line1" == *"2026-04-25"* ]]
262
+ [[ "$line2" == *"2026-04-26"* ]]
263
+ [[ "$line3" == *"2026-04-27"* ]]
264
+ }
265
+
266
+ # ── File-type filtering ─────────────────────────────────────────────────────
267
+
268
+ @test "ask-hygiene trail files are skipped (not retro summaries)" {
269
+ cat > "$TEST_DIR/2026-04-29-ask-hygiene.md" <<'TRAIL'
270
+ ### Tickets Deferred
271
+
272
+ | Observation | Cause | Citation |
273
+ |-------------|-------|----------|
274
+ | should be ignored | `session_pressure` | irrelevant |
275
+ TRAIL
276
+ run bash "$SCRIPT" "$TEST_DIR"
277
+ [ "$status" -eq 0 ]
278
+ [ -z "$output" ]
279
+ }
280
+
281
+ @test "context-analysis files are skipped (not retro summaries)" {
282
+ cat > "$TEST_DIR/2026-04-29-context-analysis.md" <<'CTX'
283
+ ### Tickets Deferred
284
+
285
+ | Observation | Cause | Citation |
286
+ |-------------|-------|----------|
287
+ | should be ignored | `session_pressure` | irrelevant |
288
+ CTX
289
+ run bash "$SCRIPT" "$TEST_DIR"
290
+ [ "$status" -eq 0 ]
291
+ [ -z "$output" ]
292
+ }
293
+
294
+ @test "files without a YYYY-MM-DD date prefix are skipped" {
295
+ cat > "$TEST_DIR/README.md" <<'RM'
296
+ ### Tickets Deferred
297
+
298
+ | Observation | Cause | Citation |
299
+ |-------------|-------|----------|
300
+ | should be ignored | `session_pressure` | irrelevant |
301
+ RM
302
+ run bash "$SCRIPT" "$TEST_DIR"
303
+ [ "$status" -eq 0 ]
304
+ [ -z "$output" ]
305
+ }
306
+
307
+ # ── Cross-shell portability (P124 / P133 lessons) ───────────────────────────
308
+
309
+ @test "script glob iteration uses portable for-loop existence check" {
310
+ run bash "$SCRIPT" "$TEST_DIR"
311
+ [ "$status" -eq 0 ]
312
+ [[ "$output" != *"*.md"* ]]
313
+ }
314
+
315
+ # ── Read-only contract ──────────────────────────────────────────────────────
316
+
317
+ @test "script is read-only — fixture tree unchanged after run" {
318
+ cat > "$TEST_DIR/2026-04-29-retro.md" <<'RETRO'
319
+ ### Tickets Deferred
320
+
321
+ | Observation | Cause | Citation |
322
+ |-------------|-------|----------|
323
+ | obs1 | `skill_unavailable` | Step 2b |
324
+ RETRO
325
+ pre_hash=$(find "$TEST_DIR" -type f -exec cksum {} \; 2>/dev/null | sort | cksum | awk '{print $1}')
326
+ run bash "$SCRIPT" "$TEST_DIR"
327
+ [ "$status" -eq 0 ]
328
+ post_hash=$(find "$TEST_DIR" -type f -exec cksum {} \; 2>/dev/null | sort | cksum | awk '{print $1}')
329
+ [ "$pre_hash" = "$post_hash" ]
330
+ }