@windyroad/itil 0.26.0 → 0.27.0-preview.296

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,320 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P170 — Slice 4 B7.T4 (item 8d): I2 (uniform problem
4
+ # ontology) load-bearing behavioural enforcement. ADR-060 architect
5
+ # finding 2: "I2 needs load-bearing behavioural test, not prose
6
+ # prohibition" — without this test ADR-060 ships I2 in name only.
7
+ #
8
+ # Contract: the type-tag is a CLASSIFICATION facet, never a workflow
9
+ # split. No skill or supporting script branches on the `type` field.
10
+ # This test asserts the property behaviourally for the pure-bash
11
+ # supporting-script surface — for each script that reads problem-
12
+ # ticket frontmatter, observable outputs (stdout / exit code / file
13
+ # mutations) are isomorphic across two synthetic ticket-set variants
14
+ # (one `type: technical`, one `type: user-business`).
15
+ #
16
+ # Coverage SCOPE — pure-bash supporting scripts only:
17
+ # - `reconcile-readme.sh`
18
+ # - `update-problem-rfcs-section.sh`
19
+ # - `classify-readme-drift.sh`
20
+ # - `reconcile-rfcs.sh`
21
+ # - `migrate-problems-add-type.sh` (idempotency on already-migrated
22
+ # tickets, regardless of type value)
23
+ #
24
+ # Coverage GAP — agent-driven SKILL.md surface:
25
+ # The SKILL.md files (`/wr-itil:capture-problem`,
26
+ # `/wr-itil:manage-problem`, `/wr-itil:work-problems`,
27
+ # `/wr-itil:review-problems`, `/wr-itil:transition-problem(s)`) are
28
+ # agent-driven instructions, not scripts. Behaviourally testing them
29
+ # requires invoking each skill against two type-variant ticket sets
30
+ # and asserting observable agent action is isomorphic — that
31
+ # primitive does not yet exist. **Tracked as P176** (agent-side I2
32
+ # coverage gap; descendant of P012 skill testing harness scope).
33
+ # Per ADR-052 § Surface 2 (in-file justification with cited harness-
34
+ # gap ticket): the deferral is named, ticketed, audit-trailed — NOT
35
+ # silent.
36
+ #
37
+ # P081 protection: this test is behavioural per ADR-052 (observable
38
+ # input → output assertions on script invocations). It does NOT grep
39
+ # SKILL.md / agent.md content for `type` token references — that
40
+ # would be the structural-test-disguised-as-behavioural anti-pattern
41
+ # P081 forbids.
42
+ #
43
+ # @adr ADR-060 (Phase 1 invariant I2; architect finding 2 + finding 10
44
+ # item 8d; Confirmation criterion 8)
45
+ # @adr ADR-051 (load-bearing-from-the-start — I2 ships at the same
46
+ # time as the type-tag, NOT later by graceful drift)
47
+ # @adr ADR-052 (behavioural-bats default; § Surface 2 escape-hatch
48
+ # for the agent-side coverage gap)
49
+ # @adr ADR-014 (single-purpose; one mechanical invariant guard)
50
+ # @problem P081 (no structural grep on SKILL.md content — protects
51
+ # against quick-fix workarounds that would re-introduce drift)
52
+ # @problem P176 (agent-side I2 coverage gap follow-up — covers what
53
+ # this bats explicitly does not)
54
+ # @jtbd JTBD-001 (extended scope: multi-commit coordinated-change
55
+ # governance at the change-set level — this test gates a CLASS of
56
+ # future changes, not just a single edit)
57
+ # @jtbd JTBD-008 (decompose-fix-into-coordinated-changes — preserves
58
+ # uniform mechanism across the type facet)
59
+
60
+ setup() {
61
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
62
+ TECH_DIR="$(mktemp -d)"
63
+ USER_DIR="$(mktemp -d)"
64
+ RFCS_TECH="$(mktemp -d)"
65
+ RFCS_USER="$(mktemp -d)"
66
+ }
67
+
68
+ teardown() {
69
+ rm -rf "$TECH_DIR" "$USER_DIR" "$RFCS_TECH" "$RFCS_USER"
70
+ }
71
+
72
+ # Build twin ticket-set fixtures: identical content except for the
73
+ # `**Type**:` field value. Three tickets per fixture (Open / Verifying
74
+ # / Closed) so each script's branching surface gets exercised.
75
+ build_twin_fixtures() {
76
+ for type_pair in "technical:$TECH_DIR" "user-business:$USER_DIR"; do
77
+ typev="${type_pair%%:*}"
78
+ dir="${type_pair##*:}"
79
+ cat > "$dir/100-foo.open.md" <<EOF
80
+ # Problem 100: Foo
81
+
82
+ **Status**: Open
83
+ **WSJF**: 5.0
84
+ **Type**: ${typev}
85
+
86
+ ## Description
87
+
88
+ stub
89
+
90
+ ## Related
91
+
92
+ stub
93
+ EOF
94
+ cat > "$dir/101-bar.verifying.md" <<EOF
95
+ # Problem 101: Bar
96
+
97
+ **Status**: Verification Pending
98
+ **WSJF**: 0
99
+ **Type**: ${typev}
100
+
101
+ ## Description
102
+
103
+ stub
104
+ EOF
105
+ cat > "$dir/102-baz.closed.md" <<EOF
106
+ # Problem 102: Baz
107
+
108
+ **Status**: Closed
109
+ **Type**: ${typev}
110
+
111
+ ## Description
112
+
113
+ stub
114
+ EOF
115
+ done
116
+
117
+ for type_pair in "technical:$TECH_DIR" "user-business:$USER_DIR"; do
118
+ dir="${type_pair##*:}"
119
+ cat > "$dir/README.md" <<'EOF'
120
+ # Problem Backlog
121
+
122
+ > Last reviewed: 2026-01-01.
123
+
124
+ ## WSJF Rankings
125
+
126
+ | WSJF | ID | Title | Severity | Status | Effort |
127
+ |------|-----|-------|----------|--------|--------|
128
+ | 5.0 | P100 | Foo | 12 High | Open | M |
129
+
130
+ ## Verification Queue
131
+
132
+ | ID | Title | Released | Likely verified? |
133
+ |----|-------|----------|------------------|
134
+ | P101 | Bar | 2026-01-01 | no (0 days) |
135
+
136
+ ## Closed
137
+
138
+ | ID | Title | Closed |
139
+ |----|-------|--------|
140
+ | P102 | Baz | 2026-01-01 |
141
+ EOF
142
+ done
143
+ }
144
+
145
+ # Build twin RFC-set fixtures alongside the problem fixtures, so
146
+ # `update-problem-rfcs-section.sh` and `reconcile-rfcs.sh` get exercised
147
+ # on traced-RFCs across both type variants.
148
+ build_twin_rfc_fixtures() {
149
+ for type_pair in "technical:$RFCS_TECH" "user-business:$RFCS_USER"; do
150
+ dir="${type_pair##*:}"
151
+ cat > "$dir/RFC-001-foo.accepted.md" <<EOF
152
+ ---
153
+ status: accepted
154
+ rfc-id: foo
155
+ reported: 2026-01-01
156
+ decision-makers: [test]
157
+ problems: [P100]
158
+ ---
159
+
160
+ # RFC-001: foo
161
+
162
+ stub
163
+ EOF
164
+ cat > "$dir/README.md" <<'EOF'
165
+ # RFC Backlog
166
+
167
+ ## WSJF Rankings
168
+
169
+ | WSJF | ID | Title | Status |
170
+ |------|-----|-------|--------|
171
+ | 5.0 | RFC-001 | foo | accepted |
172
+
173
+ ## Verification Queue
174
+
175
+ | ID | Title | Released | Likely verified? |
176
+ |----|-------|----------|------------------|
177
+
178
+ ## Closed
179
+
180
+ | ID | Title | Closed |
181
+ |----|-------|--------|
182
+ EOF
183
+ done
184
+ }
185
+
186
+ # Strip `**Type**:` lines and `type:` YAML lines from a stream so
187
+ # isomorphism comparison ignores the SOLE legitimate point of
188
+ # differentiation. Anything else differing between the two variants
189
+ # is an I2 violation.
190
+ strip_type_lines() {
191
+ grep -v -E '^(\*\*Type\*\*:|type: )' || true
192
+ }
193
+
194
+ # Diff two files modulo type lines; expect empty diff (= isomorphic).
195
+ assert_isomorphic_files() {
196
+ local a="$1" b="$2"
197
+ diff <(strip_type_lines < "$a") <(strip_type_lines < "$b")
198
+ }
199
+
200
+ # ── reconcile-readme.sh: exit code + stdout invariant across types ───────────
201
+
202
+ @test "I2: reconcile-readme.sh exits identically across type variants" {
203
+ build_twin_fixtures
204
+ set +e
205
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$TECH_DIR" > /tmp/i2-rrt-tech.out 2>&1
206
+ ec_tech=$?
207
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$USER_DIR" > /tmp/i2-rrt-user.out 2>&1
208
+ ec_user=$?
209
+ set -e
210
+ [ "$ec_tech" = "$ec_user" ]
211
+ }
212
+
213
+ @test "I2: reconcile-readme.sh stdout isomorphic modulo type field" {
214
+ build_twin_fixtures
215
+ set +e
216
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$TECH_DIR" > /tmp/i2-rrs-tech.out 2>&1 || true
217
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$USER_DIR" > /tmp/i2-rrs-user.out 2>&1 || true
218
+ set -e
219
+ assert_isomorphic_files /tmp/i2-rrs-tech.out /tmp/i2-rrs-user.out
220
+ }
221
+
222
+ # ── update-problem-rfcs-section.sh: file mutation invariant across types ─────
223
+
224
+ @test "I2: update-problem-rfcs-section.sh mutates ticket files identically across type variants" {
225
+ build_twin_fixtures
226
+ build_twin_rfc_fixtures
227
+ bash "$SCRIPTS_DIR/update-problem-rfcs-section.sh" \
228
+ "$TECH_DIR/100-foo.open.md" "$RFCS_TECH"
229
+ bash "$SCRIPTS_DIR/update-problem-rfcs-section.sh" \
230
+ "$USER_DIR/100-foo.open.md" "$RFCS_USER"
231
+ assert_isomorphic_files \
232
+ "$TECH_DIR/100-foo.open.md" \
233
+ "$USER_DIR/100-foo.open.md"
234
+ }
235
+
236
+ @test "I2: update-problem-rfcs-section.sh produces same ## RFCs section regardless of type" {
237
+ build_twin_fixtures
238
+ build_twin_rfc_fixtures
239
+ bash "$SCRIPTS_DIR/update-problem-rfcs-section.sh" \
240
+ "$TECH_DIR/100-foo.open.md" "$RFCS_TECH"
241
+ bash "$SCRIPTS_DIR/update-problem-rfcs-section.sh" \
242
+ "$USER_DIR/100-foo.open.md" "$RFCS_USER"
243
+ # Both files now carry the SAME ## RFCs table row.
244
+ grep -q '| RFC-001 | accepted | foo |' "$TECH_DIR/100-foo.open.md"
245
+ grep -q '| RFC-001 | accepted | foo |' "$USER_DIR/100-foo.open.md"
246
+ }
247
+
248
+ # ── classify-readme-drift.sh: exit code + stdout invariant across types ──────
249
+
250
+ @test "I2: classify-readme-drift.sh exits identically across type variants" {
251
+ build_twin_fixtures
252
+ # Manufacture a drift output file (the script reads stdout from
253
+ # reconcile-readme; here we build a minimal canned drift line).
254
+ drift_in="$(mktemp)"
255
+ echo "DRIFT P100 wsjf-rankings: claims=open actual=open" > "$drift_in"
256
+ set +e
257
+ bash "$SCRIPTS_DIR/classify-readme-drift.sh" "$drift_in" "$TECH_DIR" > /tmp/i2-cdt-tech.out 2>&1
258
+ ec_tech=$?
259
+ bash "$SCRIPTS_DIR/classify-readme-drift.sh" "$drift_in" "$USER_DIR" > /tmp/i2-cdt-user.out 2>&1
260
+ ec_user=$?
261
+ set -e
262
+ rm -f "$drift_in"
263
+ [ "$ec_tech" = "$ec_user" ]
264
+ assert_isomorphic_files /tmp/i2-cdt-tech.out /tmp/i2-cdt-user.out
265
+ }
266
+
267
+ # ── reconcile-rfcs.sh: exit code + stdout invariant across types ─────────────
268
+
269
+ @test "I2: reconcile-rfcs.sh exits identically across problem-type variants" {
270
+ build_twin_fixtures
271
+ build_twin_rfc_fixtures
272
+ set +e
273
+ bash "$SCRIPTS_DIR/reconcile-rfcs.sh" "$RFCS_TECH" "$TECH_DIR" > /tmp/i2-rrf-tech.out 2>&1
274
+ ec_tech=$?
275
+ bash "$SCRIPTS_DIR/reconcile-rfcs.sh" "$RFCS_USER" "$USER_DIR" > /tmp/i2-rrf-user.out 2>&1
276
+ ec_user=$?
277
+ set -e
278
+ [ "$ec_tech" = "$ec_user" ]
279
+ assert_isomorphic_files /tmp/i2-rrf-tech.out /tmp/i2-rrf-user.out
280
+ }
281
+
282
+ # ── migrate-problems-add-type.sh: idempotency invariant across both type values ──
283
+
284
+ @test "I2: migrate-problems-add-type.sh is no-op on already-typed tickets, both type values" {
285
+ build_twin_fixtures
286
+ hash_tech_before=$(shasum "$TECH_DIR"/*.md | shasum | cut -d' ' -f1)
287
+ hash_user_before=$(shasum "$USER_DIR"/*.md | shasum | cut -d' ' -f1)
288
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" --apply "$TECH_DIR"
289
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" --apply "$USER_DIR"
290
+ hash_tech_after=$(shasum "$TECH_DIR"/*.md | shasum | cut -d' ' -f1)
291
+ hash_user_after=$(shasum "$USER_DIR"/*.md | shasum | cut -d' ' -f1)
292
+ [ "$hash_tech_before" = "$hash_tech_after" ]
293
+ [ "$hash_user_before" = "$hash_user_after" ]
294
+ }
295
+
296
+ @test "I2: migrate-problems-add-type.sh diagnose-mode exit code identical across type variants" {
297
+ build_twin_fixtures
298
+ set +e
299
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" "$TECH_DIR" > /tmp/i2-mig-tech.out 2>&1
300
+ ec_tech=$?
301
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" "$USER_DIR" > /tmp/i2-mig-user.out 2>&1
302
+ ec_user=$?
303
+ set -e
304
+ [ "$ec_tech" = "$ec_user" ]
305
+ assert_isomorphic_files /tmp/i2-mig-tech.out /tmp/i2-mig-user.out
306
+ }
307
+
308
+ # ── Cross-script invariant: full-pipeline behaviour identical across types ───
309
+
310
+ @test "I2: full diagnose pipeline (migrate diagnose + reconcile-readme) invariant across types" {
311
+ build_twin_fixtures
312
+ set +e
313
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" "$TECH_DIR" > /tmp/i2-pipe-tech-mig.out 2>&1
314
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$TECH_DIR" > /tmp/i2-pipe-tech-rec.out 2>&1
315
+ bash "$SCRIPTS_DIR/migrate-problems-add-type.sh" "$USER_DIR" > /tmp/i2-pipe-user-mig.out 2>&1
316
+ bash "$SCRIPTS_DIR/reconcile-readme.sh" "$USER_DIR" > /tmp/i2-pipe-user-rec.out 2>&1
317
+ set -e
318
+ assert_isomorphic_files /tmp/i2-pipe-tech-mig.out /tmp/i2-pipe-user-mig.out
319
+ assert_isomorphic_files /tmp/i2-pipe-tech-rec.out /tmp/i2-pipe-user-rec.out
320
+ }
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P170 — Slice 4 B7.T2 (item 8b): bulk-migrate existing
4
+ # problem tickets to the type-tag schema with default `type: technical`.
5
+ # Script-driven, idempotent, no per-ticket judgement (per ADR-060
6
+ # § Type-tag schema migration line; architect finding 10 split).
7
+ #
8
+ # @adr ADR-060 (type-tag schema introduction; Phase 1 item 8b)
9
+ # @adr ADR-052 (behavioural bats default; observable file-state
10
+ # assertions, not source greps per P081)
11
+ # @adr ADR-014 (single-purpose script; one mechanical migration)
12
+ #
13
+ # Contract:
14
+ # - Input: a problems-dir containing `<NNN>-*.<status>.md` files.
15
+ # - Diagnose mode (default): exit 0 if every ticket carries
16
+ # `**Type**: <value>` body field; exit 1 if any ticket needs
17
+ # migration (tickets needing migration listed on stdout, one per
18
+ # line, ≤150 bytes per ADR-038 progressive disclosure budget).
19
+ # - Apply mode (--apply flag): inserts `**Type**: technical` after
20
+ # the LAST present body field marker among
21
+ # `**Status**` / `**Reported**` / `**Priority**` / `**Effort**` /
22
+ # `**WSJF**`. Idempotent (re-running is no-op).
23
+ # - Tickets with no recognisable header field block are skipped with
24
+ # a `SKIP` line on stderr (does not fail the run).
25
+ # - Default value: `technical` per ADR-060 line 92.
26
+
27
+ setup() {
28
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
29
+ SCRIPT="$SCRIPTS_DIR/migrate-problems-add-type.sh"
30
+ FIXTURE_DIR="$(mktemp -d)"
31
+ }
32
+
33
+ teardown() {
34
+ rm -rf "$FIXTURE_DIR"
35
+ }
36
+
37
+ write_ticket() {
38
+ local id="$1" slug="$2" status="$3" body="$4"
39
+ local file="$FIXTURE_DIR/${id}-${slug}.${status}.md"
40
+ printf '%s' "$body" > "$file"
41
+ echo "$file"
42
+ }
43
+
44
+ # ── Existence + executable ──────────────────────────────────────────────────
45
+
46
+ @test "migrate-problems-add-type: script exists" {
47
+ [ -f "$SCRIPT" ]
48
+ }
49
+
50
+ @test "migrate-problems-add-type: script is executable" {
51
+ [ -x "$SCRIPT" ]
52
+ }
53
+
54
+ # ── Diagnose mode (default) ──────────────────────────────────────────────────
55
+
56
+ @test "diagnose: exit 0 when every ticket already carries Type field" {
57
+ write_ticket "100" "foo" "open" "$(cat <<'EOF'
58
+ # Problem 100: Foo
59
+
60
+ **Status**: Open
61
+ **Type**: technical
62
+ EOF
63
+ )"
64
+ run bash "$SCRIPT" "$FIXTURE_DIR"
65
+ [ "$status" -eq 0 ]
66
+ }
67
+
68
+ @test "diagnose: exit 1 when at least one ticket lacks Type field" {
69
+ write_ticket "100" "foo" "open" "$(cat <<'EOF'
70
+ # Problem 100: Foo
71
+
72
+ **Status**: Open
73
+ **WSJF**: 5.0
74
+ EOF
75
+ )"
76
+ run bash "$SCRIPT" "$FIXTURE_DIR"
77
+ [ "$status" -eq 1 ]
78
+ }
79
+
80
+ @test "diagnose: lists each ticket needing migration on stdout" {
81
+ write_ticket "100" "foo" "open" "$(cat <<'EOF'
82
+ # Problem 100: Foo
83
+
84
+ **Status**: Open
85
+ **WSJF**: 5.0
86
+ EOF
87
+ )"
88
+ write_ticket "101" "bar" "closed" "$(cat <<'EOF'
89
+ # Problem 101: Bar
90
+
91
+ **Status**: Closed
92
+ **Priority**: 6
93
+ EOF
94
+ )"
95
+ run bash "$SCRIPT" "$FIXTURE_DIR"
96
+ [ "$status" -eq 1 ]
97
+ echo "$output" | grep -q '100-foo'
98
+ echo "$output" | grep -q '101-bar'
99
+ }
100
+
101
+ @test "diagnose: read-only — does not mutate any ticket file" {
102
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
103
+ # Problem 100: Foo
104
+
105
+ **Status**: Open
106
+ **WSJF**: 5.0
107
+ EOF
108
+ )")
109
+ hash_before=$(shasum "$ticket" | cut -d' ' -f1)
110
+ run bash "$SCRIPT" "$FIXTURE_DIR"
111
+ hash_after=$(shasum "$ticket" | cut -d' ' -f1)
112
+ [ "$hash_before" = "$hash_after" ]
113
+ }
114
+
115
+ # ── Apply mode (--apply) ─────────────────────────────────────────────────────
116
+
117
+ @test "apply: inserts Type: technical after WSJF line when WSJF present" {
118
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
119
+ # Problem 100: Foo
120
+
121
+ **Status**: Open
122
+ **WSJF**: 5.0
123
+
124
+ ## Description
125
+ EOF
126
+ )")
127
+ run bash "$SCRIPT" --apply "$FIXTURE_DIR"
128
+ [ "$status" -eq 0 ]
129
+ grep -q '^\*\*Type\*\*: technical$' "$ticket"
130
+ # Type sits AFTER WSJF (highest field marker present).
131
+ wsjf_line=$(grep -n '^\*\*WSJF\*\*:' "$ticket" | head -1 | cut -d: -f1)
132
+ type_line=$(grep -n '^\*\*Type\*\*:' "$ticket" | head -1 | cut -d: -f1)
133
+ [ "$type_line" -gt "$wsjf_line" ]
134
+ }
135
+
136
+ @test "apply: inserts Type after Effort when WSJF absent" {
137
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
138
+ # Problem 100: Foo
139
+
140
+ **Status**: Open
141
+ **Reported**: 2026-01-01
142
+ **Priority**: 6
143
+ **Effort**: M
144
+
145
+ ## Description
146
+ EOF
147
+ )")
148
+ run bash "$SCRIPT" --apply "$FIXTURE_DIR"
149
+ [ "$status" -eq 0 ]
150
+ effort_line=$(grep -n '^\*\*Effort\*\*:' "$ticket" | head -1 | cut -d: -f1)
151
+ type_line=$(grep -n '^\*\*Type\*\*:' "$ticket" | head -1 | cut -d: -f1)
152
+ [ "$type_line" -gt "$effort_line" ]
153
+ }
154
+
155
+ @test "apply: inserts Type after Priority when WSJF + Effort absent" {
156
+ ticket=$(write_ticket "100" "foo" "closed" "$(cat <<'EOF'
157
+ # Problem 100: Foo
158
+
159
+ **Status**: Closed
160
+ **Priority**: 6
161
+
162
+ ## Description
163
+ EOF
164
+ )")
165
+ run bash "$SCRIPT" --apply "$FIXTURE_DIR"
166
+ [ "$status" -eq 0 ]
167
+ priority_line=$(grep -n '^\*\*Priority\*\*:' "$ticket" | head -1 | cut -d: -f1)
168
+ type_line=$(grep -n '^\*\*Type\*\*:' "$ticket" | head -1 | cut -d: -f1)
169
+ [ "$type_line" -gt "$priority_line" ]
170
+ }
171
+
172
+ @test "apply: idempotent — re-running with Type already present is no-op" {
173
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
174
+ # Problem 100: Foo
175
+
176
+ **Status**: Open
177
+ **WSJF**: 5.0
178
+
179
+ ## Description
180
+ EOF
181
+ )")
182
+ bash "$SCRIPT" --apply "$FIXTURE_DIR"
183
+ hash1=$(shasum "$ticket" | cut -d' ' -f1)
184
+ bash "$SCRIPT" --apply "$FIXTURE_DIR"
185
+ hash2=$(shasum "$ticket" | cut -d' ' -f1)
186
+ [ "$hash1" = "$hash2" ]
187
+ }
188
+
189
+ @test "apply: default value is 'technical'" {
190
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
191
+ # Problem 100: Foo
192
+
193
+ **Status**: Open
194
+ **WSJF**: 5.0
195
+ EOF
196
+ )")
197
+ bash "$SCRIPT" --apply "$FIXTURE_DIR"
198
+ grep -q '^\*\*Type\*\*: technical$' "$ticket"
199
+ ! grep -q '^\*\*Type\*\*: user-business$' "$ticket"
200
+ }
201
+
202
+ @test "apply: handles all five lifecycle file extensions" {
203
+ i=100
204
+ for state in open known-error verifying parked closed; do
205
+ write_ticket "$i" "t-${state}" "$state" "$(cat <<EOF
206
+ # Problem $i: stub
207
+
208
+ **Status**: stub
209
+ **WSJF**: 5.0
210
+ EOF
211
+ )" > /dev/null
212
+ i=$((i+1))
213
+ done
214
+ bash "$SCRIPT" --apply "$FIXTURE_DIR"
215
+ count=$(grep -l '^\*\*Type\*\*: technical$' "$FIXTURE_DIR"/*.md | wc -l | tr -d ' ')
216
+ [ "$count" -eq 5 ]
217
+ }
218
+
219
+ @test "apply: preserves all original content (line-count grows by exactly 1 per migrated ticket)" {
220
+ # Use printf with explicit trailing newline to mirror real ticket-file
221
+ # convention (POSIX text-file trailing newline). awk normalizes EOF
222
+ # to a trailing newline; fixture must match so the +1-line invariant
223
+ # holds.
224
+ ticket="$FIXTURE_DIR/100-foo.open.md"
225
+ printf '%s\n' \
226
+ '# Problem 100: Foo' \
227
+ '' \
228
+ '**Status**: Open' \
229
+ '**WSJF**: 5.0' \
230
+ '' \
231
+ '## Description' \
232
+ '' \
233
+ 'Body text line.' \
234
+ '' \
235
+ '## Related' \
236
+ '' \
237
+ 'stub' > "$ticket"
238
+ before=$(wc -l < "$ticket" | tr -d ' ')
239
+ bash "$SCRIPT" --apply "$FIXTURE_DIR"
240
+ after=$(wc -l < "$ticket" | tr -d ' ')
241
+ [ "$after" -eq "$((before + 1))" ]
242
+ # Original content sentinels survive.
243
+ grep -q '^# Problem 100: Foo$' "$ticket"
244
+ grep -q '^## Description$' "$ticket"
245
+ grep -q '^Body text line\.$' "$ticket"
246
+ grep -q '^## Related$' "$ticket"
247
+ }
248
+
249
+ # ── Skip / malformed handling ────────────────────────────────────────────────
250
+
251
+ @test "apply: skips ticket with no recognisable header field block (warning to stderr)" {
252
+ ticket=$(write_ticket "100" "foo" "open" "$(cat <<'EOF'
253
+ # Problem 100: Foo
254
+
255
+ Just a body, no field markers at all.
256
+ EOF
257
+ )")
258
+ hash_before=$(shasum "$ticket" | cut -d' ' -f1)
259
+ run bash "$SCRIPT" --apply "$FIXTURE_DIR"
260
+ [ "$status" -eq 0 ]
261
+ hash_after=$(shasum "$ticket" | cut -d' ' -f1)
262
+ [ "$hash_before" = "$hash_after" ]
263
+ # SKIP marker on stderr.
264
+ echo "$output" | grep -qi 'skip'
265
+ }
266
+
267
+ # ── Default problems-dir ─────────────────────────────────────────────────────
268
+
269
+ @test "diagnose: defaults problems-dir to ./docs/problems when no arg given" {
270
+ cd "$FIXTURE_DIR"
271
+ mkdir -p docs/problems
272
+ cat > docs/problems/100-foo.open.md <<'EOF'
273
+ # Problem 100: Foo
274
+
275
+ **Status**: Open
276
+ **Type**: technical
277
+ EOF
278
+ run bash "$SCRIPT"
279
+ [ "$status" -eq 0 ]
280
+ }