@windyroad/itil 0.47.12-preview.598 → 0.47.12-preview.617

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,278 @@
1
+ #!/usr/bin/env bats
2
+ # tdd-review: structural-permitted (justification: the doc-lint slice below
3
+ # asserts SKILL.md / ADR-032 prose contract — SKILL.md is the contract
4
+ # document per ADR-037 Permitted Exception; these guards catch prose drift
5
+ # away from the behavioural HALT-with-advisory contract exercised above. The
6
+ # load-bearing core of this fixture is behavioural per ADR-052. harness-gap P012)
7
+ #
8
+ # Behavioural test: work-problems Step 5 exit-code semantics — the
9
+ # is_error:true TRANSIENT-API-ERROR HALT branch (P214). When an iter
10
+ # subprocess returns `is_error: true` with `total_cost_usd: 0` AND no staged
11
+ # work in the tree (the 529 Overloaded / 429 rate-limit / 401 auth-expired
12
+ # shape — the API call never landed; nothing was done; metadata records the
13
+ # failure), the orchestrator MUST halt the loop with a class-appropriate
14
+ # advisory line in the final summary — NOT silently treat exit-0 as success
15
+ # and try to parse a missing ITERATION_SUMMARY block.
16
+ #
17
+ # This is the HALT counterpart to the existing P261 SALVAGE branch (covered
18
+ # by work-problems-step-5-stream-timeout-salvage.bats):
19
+ # - SALVAGE: is_error:true + staged work + bats green (stream-timeout class)
20
+ # - HALT: is_error:true + nothing staged (transient-API-error class — P214)
21
+ # Both branches require the orchestrator to read `is_error` BEFORE the
22
+ # Exit-0 → parse-ITERATION_SUMMARY path; without the explicit check-order
23
+ # the loop silently miscounts and may spawn further subprocesses that fail
24
+ # identically (the AFK-promise-breaking shape P214 reports).
25
+ #
26
+ # The fake-shim below re-creates the production 529 Overloaded shape:
27
+ # is_error:true, total_cost_usd:0, no staged work, .result carrying the
28
+ # upstream error string. The harness re-implements the orchestrator's
29
+ # ordered-check decision contract (faithful to SKILL.md Step 5) and asserts
30
+ # the HALT routing + class-appropriate advisory for each transient class.
31
+ #
32
+ # @problem P214
33
+ # @jtbd JTBD-006
34
+ #
35
+ # Cross-reference:
36
+ # P214 (work-problems Step 5 exit-code rule doesn't handle is_error:true
37
+ # transient API failures) — driver ticket
38
+ # P261 (is_error:true stream-timeout salvage carve-out) — sibling SALVAGE
39
+ # branch; this fixture covers the HALT counterpart
40
+ # ADR-032 (governance skill invocation patterns — is_error:true class
41
+ # taxonomy: SALVAGE = stream-timeout; HALT = transient-API-error) — the
42
+ # amended contract this fixture pins
43
+ # ADR-013 Rule 6 (AFK fail-safe — HALT routing is non-interactive; no
44
+ # AskUserQuestion) — invariant honoured
45
+ # ADR-037 / ADR-052 (skill testing strategy — behavioural default; doc-lint
46
+ # contract assertion is the Permitted Exception, marked above)
47
+
48
+ setup() {
49
+ TEST_TMP="$(mktemp -d)"
50
+ FAKE_BIN="${TEST_TMP}/bin"
51
+ mkdir -p "$FAKE_BIN"
52
+
53
+ # Fake `claude` binary simulating the transient-API-error shape: exits 0,
54
+ # emits an is_error:true JSON envelope with total_cost_usd:0 and the
55
+ # transient-class error string in `.result`. No staged work — the API call
56
+ # never landed; nothing was done.
57
+ cat > "$FAKE_BIN/claude" <<'FAKE_EOF'
58
+ #!/usr/bin/env bash
59
+ # Test fake for work-problems Step 5 P214 transient-API-error halt fixture.
60
+ # Emits is_error:true with total_cost_usd:0 and a class-specific .result string.
61
+ # FAKE_ERROR_CLASS selects the transient class: overloaded | rate-limit | auth-expired
62
+ case "${FAKE_ERROR_CLASS:-overloaded}" in
63
+ overloaded)
64
+ RESULT='API Error (529): Overloaded'
65
+ ;;
66
+ rate-limit)
67
+ RESULT='API Error (429): Rate limit exceeded'
68
+ ;;
69
+ auth-expired)
70
+ RESULT='API Error (401): Authentication expired'
71
+ ;;
72
+ *)
73
+ RESULT='API Error: Unknown'
74
+ ;;
75
+ esac
76
+ printf '%s\n' "{\"is_error\":true,\"result\":\"${RESULT}\",\"total_cost_usd\":0,\"duration_ms\":1500,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}"
77
+ FAKE_EOF
78
+ chmod +x "$FAKE_BIN/claude"
79
+ export PATH="$FAKE_BIN:$PATH"
80
+
81
+ # A throwaway git repo so staged-work detection is real (and empty — no
82
+ # staged work is the load-bearing characteristic of this class).
83
+ REPO="${TEST_TMP}/repo"
84
+ mkdir -p "$REPO"
85
+ git -C "$REPO" init -q
86
+ git -C "$REPO" config user.email "test@example.com"
87
+ git -C "$REPO" config user.name "Test"
88
+ git -C "$REPO" commit -q --allow-empty -m "root"
89
+
90
+ SKILL_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
91
+ SKILL_FILE="${SKILL_DIR}/SKILL.md"
92
+ ADR_FILE="$(cd "${SKILL_DIR}/../../../.." && pwd)/docs/decisions/032-governance-skill-invocation-patterns.proposed.md"
93
+ }
94
+
95
+ teardown() {
96
+ if [ -n "${TEST_TMP:-}" ] && [ -d "$TEST_TMP" ]; then
97
+ rm -rf "$TEST_TMP"
98
+ fi
99
+ }
100
+
101
+ # Faithful re-implementation of SKILL.md Step 5's ORDERED-CHECK decision
102
+ # contract (P214 amendment to the P261 carve-out). The orchestrator reads
103
+ # (1) exit code, (2) is_error, (3) ITERATION_SUMMARY — in that order. On
104
+ # is_error:true + nothing staged, emit a class-appropriate advisory.
105
+ ordered_check_decision() {
106
+ local exit_code="$1"
107
+ local json="$2"
108
+ local repo="$3"
109
+
110
+ # (1) Non-zero exit → halt per the exit-code contract.
111
+ if [ "$exit_code" -ne 0 ]; then
112
+ printf 'DECISION=HALT reason=non-zero-exit\n'
113
+ return 0
114
+ fi
115
+
116
+ # (2) Parse is_error BEFORE attempting to parse ITERATION_SUMMARY (the
117
+ # ordered-check rule P214 amends in).
118
+ local is_error result
119
+ is_error=$(printf '%s' "$json" | python3 -c 'import json,sys; print(str(json.load(sys.stdin).get("is_error")).lower())')
120
+ result=$(printf '%s' "$json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("result",""))')
121
+
122
+ if [ "$is_error" = "true" ]; then
123
+ # is_error:true with staged work → defer to existing P261 SALVAGE branch
124
+ # (covered by sibling fixture work-problems-step-5-stream-timeout-salvage.bats).
125
+ local staged
126
+ staged=$(git -C "$repo" diff --cached --name-only)
127
+ if [ -n "$staged" ]; then
128
+ printf 'DECISION=DEFER_TO_SALVAGE_BRANCH\n'
129
+ return 0
130
+ fi
131
+
132
+ # is_error:true with NO staged work → HALT with class-appropriate advisory.
133
+ local advisory
134
+ case "$result" in
135
+ *"529"*|*"Overloaded"*|*"overloaded"*)
136
+ advisory='API overloaded; retry when service recovers'
137
+ ;;
138
+ *"429"*|*"Rate limit"*|*"rate limit"*|*"rate-limit"*)
139
+ advisory='API rate-limited; retry when limit window resets'
140
+ ;;
141
+ *"401"*|*"Authentication"*|*"auth"*)
142
+ advisory='API auth expired; refresh credentials before resuming'
143
+ ;;
144
+ *)
145
+ advisory='transient API error; inspect .result and resume manually'
146
+ ;;
147
+ esac
148
+ printf 'DECISION=HALT reason=is-error-transient advisory=%s\n' "$advisory"
149
+ return 0
150
+ fi
151
+
152
+ # (3) Exit 0 AND is_error:false → parse ITERATION_SUMMARY.
153
+ printf 'DECISION=PARSE_SUMMARY\n'
154
+ return 0
155
+ }
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Behavioural cases (the load-bearing core per ADR-052).
159
+ # ---------------------------------------------------------------------------
160
+
161
+ @test "P214: is_error:true + 529 Overloaded + no staged work -> HALT with API-overloaded advisory" {
162
+ export FAKE_ERROR_CLASS=overloaded
163
+ local json
164
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
165
+ run ordered_check_decision 0 "$json" "$REPO"
166
+ [ "$status" -eq 0 ]
167
+ [[ "$output" == *"DECISION=HALT"* ]]
168
+ [[ "$output" == *"reason=is-error-transient"* ]]
169
+ [[ "$output" == *"API overloaded"* ]]
170
+ [[ "$output" == *"retry when service recovers"* ]]
171
+ }
172
+
173
+ @test "P214: is_error:true + 429 rate-limit + no staged work -> HALT with rate-limited advisory" {
174
+ export FAKE_ERROR_CLASS=rate-limit
175
+ local json
176
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
177
+ run ordered_check_decision 0 "$json" "$REPO"
178
+ [ "$status" -eq 0 ]
179
+ [[ "$output" == *"DECISION=HALT"* ]]
180
+ [[ "$output" == *"reason=is-error-transient"* ]]
181
+ [[ "$output" == *"rate-limited"* ]]
182
+ }
183
+
184
+ @test "P214: is_error:true + 401 auth-expired + no staged work -> HALT with refresh-credentials advisory" {
185
+ export FAKE_ERROR_CLASS=auth-expired
186
+ local json
187
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
188
+ run ordered_check_decision 0 "$json" "$REPO"
189
+ [ "$status" -eq 0 ]
190
+ [[ "$output" == *"DECISION=HALT"* ]]
191
+ [[ "$output" == *"reason=is-error-transient"* ]]
192
+ [[ "$output" == *"auth expired"* ]]
193
+ [[ "$output" == *"refresh credentials"* ]]
194
+ }
195
+
196
+ @test "P214: is_error MUST be checked BEFORE ITERATION_SUMMARY parse on Exit 0 (ordered-check invariant)" {
197
+ # The load-bearing P214 invariant: when exit 0 AND is_error:true, the
198
+ # decision is HALT, NOT PARSE_SUMMARY. Without the ordered-check rule the
199
+ # loop would silently route to PARSE_SUMMARY and miss the failure.
200
+ export FAKE_ERROR_CLASS=overloaded
201
+ local json
202
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
203
+ run ordered_check_decision 0 "$json" "$REPO"
204
+ [ "$status" -eq 0 ]
205
+ [[ "$output" != *"DECISION=PARSE_SUMMARY"* ]]
206
+ [[ "$output" == *"DECISION=HALT"* ]]
207
+ }
208
+
209
+ @test "P214: non-zero exit takes precedence over is_error check (HALT routing)" {
210
+ # Non-zero exit halts regardless of is_error value — the exit-code rule
211
+ # is check (1) in the ordered sequence.
212
+ export FAKE_ERROR_CLASS=overloaded
213
+ local json
214
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
215
+ run ordered_check_decision 1 "$json" "$REPO"
216
+ [ "$status" -eq 0 ]
217
+ [[ "$output" == *"DECISION=HALT"* ]]
218
+ [[ "$output" == *"reason=non-zero-exit"* ]]
219
+ }
220
+
221
+ @test "P214: is_error:true + staged work -> defers to existing P261 SALVAGE branch (no double-handling)" {
222
+ # When staged work exists, the transient-API-error HALT branch must NOT
223
+ # fire — it MUST defer to the P261 SALVAGE branch. This guards against
224
+ # the new branch swallowing salvage-eligible work.
225
+ export FAKE_ERROR_CLASS=overloaded
226
+ printf 'salvageable work\n' > "$REPO/salvage-me.txt"
227
+ git -C "$REPO" add salvage-me.txt
228
+ local json
229
+ json=$( cd "$REPO" && claude -p --output-format json "TEST" < /dev/null )
230
+ run ordered_check_decision 0 "$json" "$REPO"
231
+ [ "$status" -eq 0 ]
232
+ [[ "$output" == *"DECISION=DEFER_TO_SALVAGE_BRANCH"* ]]
233
+ }
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Doc-lint contract assertions (Permitted Exception per ADR-037; structural
237
+ # slice marked at top of file per ADR-052 Surface 2). These guard the SKILL.md
238
+ # / ADR-032 prose against drift away from the behavioural contract above.
239
+ # ---------------------------------------------------------------------------
240
+
241
+ @test "P214: SKILL.md Step 5 documents the ORDERED check sequence (exit-code, is_error, ITERATION_SUMMARY)" {
242
+ # The ordered-check rule must be explicit in the prose so an implementer
243
+ # reading Step 5 routes is_error:true to HALT before attempting to parse
244
+ # a missing ITERATION_SUMMARY block.
245
+ run grep -niE "(check|read|parse).{0,40}is_error.{0,80}before.{0,80}(ITERATION_SUMMARY|parse|\.result)|ordered check|check.order" "$SKILL_FILE"
246
+ [ "$status" -eq 0 ]
247
+ }
248
+
249
+ @test "P214: SKILL.md Step 5 names the transient-API-error classes (overloaded / rate-limit / auth-expired)" {
250
+ # The HALT advisory must enumerate the known transient classes so the
251
+ # final summary carries an actionable message rather than a generic
252
+ # "loop halted" line.
253
+ run grep -niE "529|Overloaded|overload" "$SKILL_FILE"
254
+ [ "$status" -eq 0 ]
255
+ run grep -niE "429|rate.?limit" "$SKILL_FILE"
256
+ [ "$status" -eq 0 ]
257
+ run grep -niE "401|auth.?expired|auth.*expir" "$SKILL_FILE"
258
+ [ "$status" -eq 0 ]
259
+ }
260
+
261
+ @test "P214: SKILL.md Step 5 cites P214 as the driver of the transient-API-error HALT branch" {
262
+ run grep -nE "P214" "$SKILL_FILE"
263
+ [ "$status" -eq 0 ]
264
+ }
265
+
266
+ @test "P214: SKILL.md HALT branch distinguishes the transient-API-error class from the P261 stream-timeout SALVAGE class" {
267
+ # The two is_error:true branches (SALVAGE vs HALT) must be cross-referenced
268
+ # so adopters reading either branch see the other.
269
+ run grep -niE "P261.{0,200}(transient|HALT|overload|class)|transient.{0,200}P261|salvage.{0,200}(transient|class)" "$SKILL_FILE"
270
+ [ "$status" -eq 0 ]
271
+ }
272
+
273
+ @test "P214: ADR-032 P261 section names the is_error:true class taxonomy (SALVAGE = stream-timeout; HALT = transient-API-error)" {
274
+ # ADR-032's P261 amendment should be extended with the broader class
275
+ # taxonomy so the SKILL prose and the ADR contract stay in sync.
276
+ run grep -niE "P214|transient.?(API.?)?error|class taxonomy|(overload|rate.?limit|auth.?expired).{0,80}HALT|HALT.{0,80}(overload|rate.?limit|auth.?expired)" "$ADR_FILE"
277
+ [ "$status" -eq 0 ]
278
+ }
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bats
2
+ # Step 6.5 Post-release K→V auto-transition callback (P228) — behavioural.
3
+ #
4
+ # Driver: ADR-022 prescribes K→V transition on release, but until P228
5
+ # there was no auto-fire surface to back-fill the transition once a fix
6
+ # ships. The post-release callback enumerator
7
+ # (`packages/itil/lib/enumerate-postrelease-kv-candidates.sh`) is the
8
+ # data source: it walks `docs/problems/known-error/*.md`, invokes the
9
+ # derive-release-vehicle helper per ticket, and emits one
10
+ # `KV_CANDIDATE: P<NNN> | <changeset>` line per ticket whose changeset
11
+ # has shipped (derive exit 0).
12
+ #
13
+ # The 2026-06-08 P220 witness — `## Fix Released` populated but K→V
14
+ # deferred citing a misapplied P143 amendment — is the empirical bug
15
+ # this enumerator wires the surface to close.
16
+ #
17
+ # Cases covered (behavioural — exercise the helper, assert observable
18
+ # output; structural ban per ADR-005 / P081):
19
+ # 1. known-error/ dir absent → KV_CANDIDATES_SUMMARY: total=0
20
+ # 2. known-error/ dir empty → KV_CANDIDATES_SUMMARY: total=0
21
+ # 3. ticket present, derive exit 0 → KV_CANDIDATE emitted; total=1
22
+ # 4. ticket present, derive exit 2 → skipped silently; total=0
23
+ # (no `.changeset/<name>.md` ref in body — legacy ticket)
24
+ # 5. ticket present, derive exit 3 → skipped silently; total=0
25
+ # (changeset still in working tree — unreleased)
26
+ # 6. mixed cohort (3 tickets: shipped + legacy + unreleased)
27
+ # → only the shipped one emitted
28
+ # 7. README.md inside known-error/ is excluded from the enumeration
29
+ # 8. unknown derive exit code → stderr warning + skip; total=0
30
+ #
31
+ # Cross-references:
32
+ # @problem P228 (K→V auto-transition gap)
33
+ # @adr ADR-022 (Verifying lifecycle)
34
+ # @adr ADR-018 (release-cadence host of the callback)
35
+ # @adr ADR-005 (behavioural bats per P081)
36
+ # @jtbd JTBD-006 (Progress the Backlog While I'm Away — primary driver)
37
+
38
+ setup() {
39
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../../.." && pwd)"
40
+ HELPER="$REPO_ROOT/packages/itil/lib/enumerate-postrelease-kv-candidates.sh"
41
+
42
+ FIXTURE="$(mktemp -d)"
43
+ mkdir -p "$FIXTURE/docs/problems"
44
+
45
+ STUB_DIR="$(mktemp -d)"
46
+ }
47
+
48
+ teardown() {
49
+ rm -rf "$FIXTURE" "$STUB_DIR"
50
+ }
51
+
52
+ # Helper — write a stub derive-release-vehicle command that returns a
53
+ # canned exit code (per ticket-id-to-exit-code map encoded in $STUB_MAP).
54
+ # $STUB_MAP shape: `<NNN>:<exit>:<changeset>;<NNN>:<exit>:<changeset>;...`
55
+ # The third field is consumed only when exit=0 (emitted in the RELEASE_VEHICLE
56
+ # block the lib greps for changeset path).
57
+ write_stub_derive() {
58
+ local stub="$STUB_DIR/wr-itil-derive-release-vehicle"
59
+ cat > "$stub" <<'STUB'
60
+ #!/usr/bin/env bash
61
+ # Stub derive-release-vehicle for the enumerator behavioural test. Reads
62
+ # $STUB_MAP from the environment, returns canned exit codes per ticket.
63
+ nnn="$1"
64
+ nnn_norm="$(printf '%03d' "$((10#$nnn))")"
65
+ IFS=';' read -ra entries <<< "${STUB_MAP:-}"
66
+ for entry in "${entries[@]}"; do
67
+ [ -z "$entry" ] && continue
68
+ IFS=':' read -r id ex cs <<< "$entry"
69
+ id_norm="$(printf '%03d' "$((10#$id))")"
70
+ if [ "$id_norm" = "$nnn_norm" ]; then
71
+ if [ "$ex" -eq 0 ]; then
72
+ cat <<EOF
73
+ RELEASE_VEHICLE:
74
+ changeset: $cs
75
+ version-packages-commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef
76
+ pr: #999
77
+ merge-commit: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef
78
+ release-date: 2026-06-08
79
+ EOF
80
+ fi
81
+ exit "$ex"
82
+ fi
83
+ done
84
+ # Default: no entry for this ticket → exit 1 (not found)
85
+ exit 1
86
+ STUB
87
+ chmod +x "$stub"
88
+ }
89
+
90
+ # Helper — write a known-error ticket fixture (body content is opaque to
91
+ # the enumerator; only the filename's leading NNN matters since the
92
+ # derive helper is stubbed).
93
+ write_ke_ticket() {
94
+ local nnn="$1"
95
+ local slug="$2"
96
+ mkdir -p "$FIXTURE/docs/problems/known-error"
97
+ cat > "$FIXTURE/docs/problems/known-error/${nnn}-${slug}.md" <<EOF
98
+ # Problem ${nnn}: ${slug}
99
+
100
+ **Status**: Known Error
101
+
102
+ ## Description
103
+
104
+ Test fixture for the enumerator behavioural bats.
105
+ EOF
106
+ }
107
+
108
+ # Source the lib once for all tests.
109
+ load_lib() {
110
+ # shellcheck source=/dev/null
111
+ source "$HELPER"
112
+ }
113
+
114
+ @test "helper file exists and is sourceable" {
115
+ [ -f "$HELPER" ]
116
+ load_lib
117
+ }
118
+
119
+ @test "case 1: known-error/ dir absent → total=0" {
120
+ load_lib
121
+ rm -rf "$FIXTURE/docs/problems/known-error"
122
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
123
+ [ "$status" -eq 0 ]
124
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
125
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
126
+ }
127
+
128
+ @test "case 2: known-error/ dir empty → total=0" {
129
+ load_lib
130
+ mkdir -p "$FIXTURE/docs/problems/known-error"
131
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
132
+ [ "$status" -eq 0 ]
133
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
134
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
135
+ }
136
+
137
+ @test "case 3: ticket present + derive exit 0 → KV_CANDIDATE emitted, total=1" {
138
+ load_lib
139
+ write_stub_derive
140
+ write_ke_ticket "228" "adr-022-known-error-to-verifying-transition"
141
+ PATH="$STUB_DIR:$PATH" STUB_MAP="228:0:.changeset/p228-fix.md" \
142
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
143
+ [ "$status" -eq 0 ]
144
+ [[ "$output" == *"KV_CANDIDATE: P228 | .changeset/p228-fix.md"* ]]
145
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=1"* ]]
146
+ }
147
+
148
+ @test "case 4: ticket present + derive exit 2 (no vehicle ref) → skipped silently, total=0" {
149
+ load_lib
150
+ write_stub_derive
151
+ write_ke_ticket "100" "legacy-pre-p330-no-vehicle"
152
+ PATH="$STUB_DIR:$PATH" STUB_MAP="100:2:" \
153
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
154
+ [ "$status" -eq 0 ]
155
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
156
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
157
+ }
158
+
159
+ @test "case 5: ticket present + derive exit 3 (unreleased) → skipped silently, total=0" {
160
+ load_lib
161
+ write_stub_derive
162
+ write_ke_ticket "200" "changeset-still-in-tree"
163
+ PATH="$STUB_DIR:$PATH" STUB_MAP="200:3:" \
164
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
165
+ [ "$status" -eq 0 ]
166
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
167
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
168
+ }
169
+
170
+ @test "case 6: mixed cohort — only the shipped ticket is emitted, total=1" {
171
+ load_lib
172
+ write_stub_derive
173
+ write_ke_ticket "228" "shipped"
174
+ write_ke_ticket "100" "legacy-no-vehicle"
175
+ write_ke_ticket "200" "changeset-in-tree"
176
+ PATH="$STUB_DIR:$PATH" \
177
+ STUB_MAP="228:0:.changeset/p228-fix.md;100:2:;200:3:" \
178
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
179
+ [ "$status" -eq 0 ]
180
+ [[ "$output" == *"KV_CANDIDATE: P228 | .changeset/p228-fix.md"* ]]
181
+ [[ "$output" != *"KV_CANDIDATE: P100"* ]]
182
+ [[ "$output" != *"KV_CANDIDATE: P200"* ]]
183
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=1"* ]]
184
+ }
185
+
186
+ @test "case 7: README.md inside known-error/ is excluded from the enumeration" {
187
+ load_lib
188
+ write_stub_derive
189
+ mkdir -p "$FIXTURE/docs/problems/known-error"
190
+ printf '# Known Error Tickets\n' > "$FIXTURE/docs/problems/known-error/README.md"
191
+ PATH="$STUB_DIR:$PATH" STUB_MAP="" \
192
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
193
+ [ "$status" -eq 0 ]
194
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
195
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
196
+ }
197
+
198
+ @test "case 8: unknown derive exit code → stderr warning + skip, total=0" {
199
+ load_lib
200
+ write_stub_derive
201
+ write_ke_ticket "999" "weird-exit"
202
+ PATH="$STUB_DIR:$PATH" STUB_MAP="999:42:" \
203
+ run enumerate_postrelease_kv_candidates "$FIXTURE/docs/problems"
204
+ [ "$status" -eq 0 ]
205
+ [[ "$output" != *"KV_CANDIDATE:"* ]]
206
+ [[ "$output" == *"KV_CANDIDATES_SUMMARY: total=0"* ]]
207
+ # Stderr warning is captured into $output when bats merges stderr; the
208
+ # contract here is "skip silently AND don't lose audit signal".
209
+ }