@windyroad/itil 0.25.0 → 0.26.0

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,433 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P170 — docs/rfcs/README.md needs a sibling drift-detector to
4
+ # reconcile-readme.sh once `/wr-itil:capture-rfc` and `/wr-itil:manage-rfc`
5
+ # start writing RFC files. P170 Slice 3 task B5.T6 ships this script;
6
+ # B5.T7 ships the `wr-itil-reconcile-rfcs` $PATH shim per ADR-049.
7
+ #
8
+ # Contract: `reconcile-rfcs.sh [<rfcs-dir>]` is a diagnose-only mechanical
9
+ # drift detector. It reads `<rfcs-dir>/RFC-<NNN>-*.<status>.md` files
10
+ # (default `docs/rfcs`), parses the WSJF Rankings + Verification Queue
11
+ # + Closed tables in `<rfcs-dir>/README.md`, and reports each
12
+ # disagreement between README claim and filesystem ground truth.
13
+ #
14
+ # Exit codes:
15
+ # 0 = clean (README matches filesystem for every parsed row)
16
+ # 1 = drift detected (structured diff to stdout, one row per drift)
17
+ # 2 = parse error (README missing or malformed beyond recovery)
18
+ #
19
+ # Drift line format per ADR-038 progressive-disclosure budget (≤150 bytes/row):
20
+ # DRIFT RFC-<NNN> wsjf-rankings: claims=open actual=<status>
21
+ # MISSING RFC-<NNN> wsjf-rankings: actual=<status>
22
+ # STALE RFC-<NNN> verification-queue: actual=<status>
23
+ # MISMATCH RFC-<NNN> closed: actual=<status>
24
+ #
25
+ # Sibling to packages/itil/scripts/reconcile-readme.sh (P118) — same
26
+ # parse + diff structure applied at the RFC tier instead of the
27
+ # problems tier.
28
+ #
29
+ # @jtbd JTBD-008 (Decompose a Fix Into Coordinated Changes — RFC ranking
30
+ # integrity supports the capture-time decomposition surface)
31
+ # @jtbd JTBD-006 (Progress the Backlog While I'm Away — orchestrators
32
+ # composing with RFC-level WSJF rankings need them to match disk truth)
33
+ #
34
+ # Cross-reference:
35
+ # P170: docs/problems/170-...open.md
36
+ # ADR-060 (Problem-RFC-Story framework — Phase 1 item 5)
37
+ # ADR-049 (Plugin script resolution via bin/ on PATH — paired bin shim)
38
+ # ADR-005 — Plugin testing strategy (script-level bats governance)
39
+
40
+ setup() {
41
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
42
+ SCRIPT="$SCRIPTS_DIR/reconcile-rfcs.sh"
43
+ FIXTURE_DIR="$(mktemp -d)"
44
+ PROBLEMS_DIR="$(mktemp -d)"
45
+ }
46
+
47
+ teardown() {
48
+ rm -rf "$FIXTURE_DIR" "$PROBLEMS_DIR"
49
+ }
50
+
51
+ # Helper: write a minimal valid RFC README to fixture dir.
52
+ write_minimal_readme() {
53
+ local body="$1"
54
+ cat > "$FIXTURE_DIR/README.md" <<EOF
55
+ # RFC Backlog
56
+
57
+ > Last reviewed: 2026-05-05
58
+
59
+ ## Status
60
+
61
+ (intro)
62
+
63
+ ## RFC Rankings
64
+
65
+ | WSJF | ID | Title | Severity | Status | Effort | Reported |
66
+ |------|-----|-------|----------|--------|--------|----------|
67
+ ${body}
68
+
69
+ ## Verification Queue
70
+
71
+ | ID | Title | Released | Verification check |
72
+ |----|-------|----------|--------------------|
73
+
74
+ ## Closed
75
+
76
+ | ID | Title | Closed | Driving problems |
77
+ |----|-------|--------|------------------|
78
+ EOF
79
+ }
80
+
81
+ # Helper: write an RFC ticket file with the given status suffix.
82
+ # Optional 4th arg overrides the `problems:` list (default `[P168]`).
83
+ write_rfc() {
84
+ local id="$1" slug="$2" status="$3"
85
+ local problems="${4:-[P168]}"
86
+ cat > "$FIXTURE_DIR/RFC-${id}-${slug}.${status}.md" <<EOF
87
+ ---
88
+ status: ${status}
89
+ rfc-id: ${slug}
90
+ reported: 2026-05-05
91
+ decision-makers: [test]
92
+ problems: ${problems}
93
+ ---
94
+
95
+ # RFC-${id}: ${slug}
96
+
97
+ stub
98
+ EOF
99
+ }
100
+
101
+ # Helper: write a problem ticket file with optional `## RFCs` table rows.
102
+ # Args: <pid-num> <slug> <status> <rfcs-rows-block>
103
+ # rfcs-rows-block is the markdown rows (already pipe-formatted) inserted under
104
+ # the `## RFCs` table header — pass an empty string to omit the section
105
+ # entirely (lazy-empty discipline per JTBD-101 atomic-fix-adopter friction guard).
106
+ write_problem() {
107
+ local num="$1" slug="$2" status="$3" rfcs_rows="${4:-}"
108
+ local file="$PROBLEMS_DIR/${num}-${slug}.${status}.md"
109
+ cat > "$file" <<EOF
110
+ # Problem ${num}: ${slug}
111
+
112
+ **Status**: ${status}
113
+
114
+ ## Description
115
+
116
+ stub
117
+
118
+ ## Related
119
+
120
+ stub
121
+ EOF
122
+ if [ -n "$rfcs_rows" ]; then
123
+ cat >> "$file" <<EOF
124
+
125
+ ## RFCs
126
+
127
+ | RFC | Status | Title |
128
+ |-----|--------|-------|
129
+ ${rfcs_rows}
130
+ EOF
131
+ fi
132
+ }
133
+
134
+ # ── Existence + executable ──────────────────────────────────────────────────
135
+
136
+ @test "reconcile-rfcs: script exists" {
137
+ [ -f "$SCRIPT" ]
138
+ }
139
+
140
+ @test "reconcile-rfcs: script is executable" {
141
+ [ -x "$SCRIPT" ]
142
+ }
143
+
144
+ # ── Parse-error path ────────────────────────────────────────────────────────
145
+
146
+ @test "reconcile-rfcs: missing README → exit 2 (parse error)" {
147
+ run bash "$SCRIPT" "$FIXTURE_DIR"
148
+ [ "$status" -eq 2 ]
149
+ [[ "$output" == *"PARSE_ERROR"* ]]
150
+ }
151
+
152
+ @test "reconcile-rfcs: README without RFC Rankings header → exit 2" {
153
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
154
+ # RFC Backlog
155
+
156
+ (no Rankings section)
157
+ EOF
158
+ run bash "$SCRIPT" "$FIXTURE_DIR"
159
+ [ "$status" -eq 2 ]
160
+ }
161
+
162
+ # ── Clean path ──────────────────────────────────────────────────────────────
163
+
164
+ @test "reconcile-rfcs: empty filesystem + empty README → exit 0 (clean)" {
165
+ write_minimal_readme ""
166
+ run bash "$SCRIPT" "$FIXTURE_DIR"
167
+ [ "$status" -eq 0 ]
168
+ }
169
+
170
+ @test "reconcile-rfcs: README and filesystem agree on one proposed RFC → exit 0" {
171
+ write_rfc "001" "foo" "proposed"
172
+ write_minimal_readme "| 1.5 | RFC-001 | foo | 3 Med | Proposed | M | 2026-05-05 |"
173
+ run bash "$SCRIPT" "$FIXTURE_DIR"
174
+ [ "$status" -eq 0 ]
175
+ }
176
+
177
+ @test "reconcile-rfcs: accepted RFC matches Rankings (proposed/accepted/in-progress are all WSJF queue)" {
178
+ write_rfc "002" "bar" "accepted"
179
+ write_minimal_readme "| 2.0 | RFC-002 | bar | 4 High | Accepted | M | 2026-05-05 |"
180
+ run bash "$SCRIPT" "$FIXTURE_DIR"
181
+ [ "$status" -eq 0 ]
182
+ }
183
+
184
+ @test "reconcile-rfcs: in-progress RFC matches Rankings" {
185
+ write_rfc "003" "baz" "in-progress"
186
+ write_minimal_readme "| 1.5 | RFC-003 | baz | 3 Med | In-Progress | M | 2026-05-05 |"
187
+ run bash "$SCRIPT" "$FIXTURE_DIR"
188
+ [ "$status" -eq 0 ]
189
+ }
190
+
191
+ # ── Drift paths ─────────────────────────────────────────────────────────────
192
+
193
+ @test "reconcile-rfcs: filesystem RFC missing from README → MISSING drift" {
194
+ write_rfc "004" "qux" "proposed"
195
+ write_minimal_readme ""
196
+ run bash "$SCRIPT" "$FIXTURE_DIR"
197
+ [ "$status" -eq 1 ]
198
+ [[ "$output" == *"MISSING RFC-004"* ]]
199
+ [[ "$output" == *"actual=proposed"* ]]
200
+ }
201
+
202
+ @test "reconcile-rfcs: README claims RFC in Rankings but filesystem says verifying → DRIFT" {
203
+ write_rfc "005" "blip" "verifying"
204
+ write_minimal_readme "| 0 | RFC-005 | blip | 3 Med | Proposed | M | 2026-05-05 |"
205
+ run bash "$SCRIPT" "$FIXTURE_DIR"
206
+ [ "$status" -eq 1 ]
207
+ [[ "$output" == *"DRIFT RFC-005"* ]]
208
+ [[ "$output" == *"actual=verifying"* ]]
209
+ }
210
+
211
+ @test "reconcile-rfcs: verifying RFC missing from Verification Queue → MISSING in queue" {
212
+ write_rfc "006" "ver" "verifying"
213
+ write_minimal_readme ""
214
+ run bash "$SCRIPT" "$FIXTURE_DIR"
215
+ [ "$status" -eq 1 ]
216
+ [[ "$output" == *"MISSING RFC-006"* ]]
217
+ [[ "$output" == *"verification-queue"* ]]
218
+ }
219
+
220
+ @test "reconcile-rfcs: closed RFC listed in Verification Queue → STALE" {
221
+ write_rfc "007" "stale" "closed"
222
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
223
+ # RFC Backlog
224
+
225
+ ## RFC Rankings
226
+
227
+ | WSJF | ID | Title | Severity | Status | Effort | Reported |
228
+ |------|-----|-------|----------|--------|--------|----------|
229
+
230
+ ## Verification Queue
231
+
232
+ | ID | Title | Released | Verification check |
233
+ |----|-------|----------|--------------------|
234
+ | RFC-007 | stale | 2026-05-05 | check |
235
+
236
+ ## Closed
237
+
238
+ | ID | Title | Closed | Driving problems |
239
+ |----|-------|--------|------------------|
240
+ EOF
241
+ run bash "$SCRIPT" "$FIXTURE_DIR"
242
+ [ "$status" -eq 1 ]
243
+ [[ "$output" == *"STALE RFC-007"* ]]
244
+ [[ "$output" == *"actual=closed"* ]]
245
+ }
246
+
247
+ @test "reconcile-rfcs: open-shape RFC listed in Closed section → MISMATCH" {
248
+ write_rfc "008" "mis" "proposed"
249
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
250
+ # RFC Backlog
251
+
252
+ ## RFC Rankings
253
+
254
+ | WSJF | ID | Title | Severity | Status | Effort | Reported |
255
+ |------|-----|-------|----------|--------|--------|----------|
256
+ | 1.5 | RFC-008 | mis | 3 Med | Proposed | M | 2026-05-05 |
257
+
258
+ ## Verification Queue
259
+
260
+ | ID | Title | Released | Verification check |
261
+ |----|-------|----------|--------------------|
262
+
263
+ ## Closed
264
+
265
+ | ID | Title | Closed | Driving problems |
266
+ |----|-------|--------|------------------|
267
+ | RFC-008 | mis | 2026-05-05 | P168 |
268
+ EOF
269
+ run bash "$SCRIPT" "$FIXTURE_DIR"
270
+ [ "$status" -eq 1 ]
271
+ [[ "$output" == *"MISMATCH RFC-008"* ]]
272
+ }
273
+
274
+ # ── Output format ───────────────────────────────────────────────────────────
275
+
276
+ @test "reconcile-rfcs: drift output is per-line and ≤150 bytes per line (ADR-038)" {
277
+ write_rfc "010" "byte-budget-test-with-an-extra-long-slug-to-stress-row-width" "proposed"
278
+ write_minimal_readme ""
279
+ run bash "$SCRIPT" "$FIXTURE_DIR"
280
+ [ "$status" -eq 1 ]
281
+ while IFS= read -r line; do
282
+ [ ${#line} -le 150 ] || { echo "row exceeds 150 bytes: '$line' (${#line} bytes)"; return 1; }
283
+ done <<< "$output"
284
+ }
285
+
286
+ @test "reconcile-rfcs: stable sort order (deterministic for snapshot diffing)" {
287
+ write_rfc "020" "second" "proposed"
288
+ write_rfc "010" "first" "proposed"
289
+ write_minimal_readme ""
290
+ run bash "$SCRIPT" "$FIXTURE_DIR"
291
+ [ "$status" -eq 1 ]
292
+ # Both should appear; lower ID first per sort.
293
+ first_line=$(echo "$output" | head -1)
294
+ second_line=$(echo "$output" | sed -n '2p')
295
+ [[ "$first_line" == *"RFC-010"* ]]
296
+ [[ "$second_line" == *"RFC-020"* ]]
297
+ }
298
+
299
+ # ── Reverse-trace drift detection (B5.T8 — closes ADR-060 Confirmation criterion 3) ──
300
+ #
301
+ # Per architect Q5 verdict: when a problems-dir is provided as second positional
302
+ # arg, reconcile-rfcs.sh extends to detect drift in the `## RFCs` reverse-trace
303
+ # section on driving problem tickets. Three new drift conditions:
304
+ #
305
+ # MISSING_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
306
+ # RFC frontmatter `problems:` claims P<NNN> but P<NNN>'s `## RFCs` table
307
+ # does not list RFC-<NNN>.
308
+ #
309
+ # STALE_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
310
+ # P<NNN>'s `## RFCs` table lists RFC-<NNN> but the RFC's frontmatter
311
+ # `problems:` no longer claims P<NNN>.
312
+ #
313
+ # STATUS_MISMATCH RFC-<NNN> in P<NNN> ## RFCs claims=<X> actual=<Y>
314
+ # P<NNN>'s `## RFCs` row claims status <X> but RFC's filesystem suffix is <Y>.
315
+ #
316
+ # Backward-compat: when no problems-dir arg is supplied (or the dir is absent),
317
+ # the script preserves the single-arg behaviour from B5.T6 (existing 18 cases
318
+ # above pass unchanged).
319
+
320
+ @test "reverse-trace: clean — RFC traces P, P has matching ## RFCs row → no drift" {
321
+ write_rfc "001" "foo" "accepted"
322
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
323
+ write_problem "168" "p168" "verifying" "| RFC-001 | accepted | foo |"
324
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
325
+ [ "$status" -eq 0 ]
326
+ }
327
+
328
+ @test "reverse-trace: MISSING_REVERSE_TRACE — RFC claims P, P has no ## RFCs section" {
329
+ write_rfc "001" "foo" "accepted"
330
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
331
+ write_problem "168" "p168" "verifying" ""
332
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
333
+ [ "$status" -eq 1 ]
334
+ [[ "$output" == *"MISSING_REVERSE_TRACE"* ]]
335
+ [[ "$output" == *"RFC-001"* ]]
336
+ [[ "$output" == *"P168"* ]]
337
+ }
338
+
339
+ @test "reverse-trace: MISSING_REVERSE_TRACE — RFC claims P, P has ## RFCs but RFC absent from table" {
340
+ write_rfc "001" "foo" "accepted"
341
+ write_rfc "002" "bar" "proposed"
342
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
343
+ # P168 has ## RFCs section listing RFC-002 but not RFC-001
344
+ write_problem "168" "p168" "verifying" "| RFC-002 | proposed | bar |"
345
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
346
+ [ "$status" -eq 1 ]
347
+ [[ "$output" == *"MISSING_REVERSE_TRACE"* ]]
348
+ [[ "$output" == *"RFC-001"* ]]
349
+ [[ "$output" == *"P168"* ]]
350
+ }
351
+
352
+ @test "reverse-trace: STALE_REVERSE_TRACE — P lists RFC, RFC frontmatter no longer claims P" {
353
+ # RFC-001 traces P169 only; P168's ## RFCs table still lists RFC-001
354
+ write_rfc "001" "foo" "accepted" "[P169]"
355
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
356
+ write_problem "168" "p168" "verifying" "| RFC-001 | accepted | foo |"
357
+ write_problem "169" "p169" "open" "| RFC-001 | accepted | foo |"
358
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
359
+ [ "$status" -eq 1 ]
360
+ [[ "$output" == *"STALE_REVERSE_TRACE"* ]]
361
+ [[ "$output" == *"RFC-001"* ]]
362
+ [[ "$output" == *"P168"* ]]
363
+ }
364
+
365
+ @test "reverse-trace: STATUS_MISMATCH — P ## RFCs row claims status X but RFC suffix is Y" {
366
+ write_rfc "001" "foo" "in-progress"
367
+ write_minimal_readme "| 1.5 | RFC-001 | foo | 3 Med | In-Progress | M | 2026-05-05 |"
368
+ # P168's ## RFCs table claims RFC-001 is `accepted` but the on-disk suffix is in-progress
369
+ write_problem "168" "p168" "verifying" "| RFC-001 | accepted | foo |"
370
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
371
+ [ "$status" -eq 1 ]
372
+ [[ "$output" == *"STATUS_MISMATCH"* ]]
373
+ [[ "$output" == *"RFC-001"* ]]
374
+ [[ "$output" == *"P168"* ]]
375
+ [[ "$output" == *"claims=accepted"* ]]
376
+ [[ "$output" == *"actual=in-progress"* ]]
377
+ }
378
+
379
+ @test "reverse-trace: backward-compat — single-arg invocation skips reverse-trace check" {
380
+ # No problems-dir → existing 18-case behaviour
381
+ write_rfc "001" "foo" "accepted"
382
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
383
+ run bash "$SCRIPT" "$FIXTURE_DIR"
384
+ [ "$status" -eq 0 ]
385
+ }
386
+
387
+ @test "reverse-trace: problems-dir absent on disk → reverse-trace check skipped (warn-only)" {
388
+ write_rfc "001" "foo" "accepted"
389
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
390
+ rm -rf "$PROBLEMS_DIR"
391
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
392
+ # absent problems-dir does not promote to drift; treat as backward-compat
393
+ [ "$status" -eq 0 ]
394
+ }
395
+
396
+ @test "reverse-trace: RFC has no problems frontmatter → reverse-trace skipped for that RFC" {
397
+ # Empty problems list; RFC-001 frontmatter has no claims to validate
398
+ write_rfc "001" "foo" "accepted" "[]"
399
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
400
+ write_problem "168" "p168" "verifying" ""
401
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
402
+ [ "$status" -eq 0 ]
403
+ }
404
+
405
+ @test "reverse-trace: drift output is per-line and ≤150 bytes per line (ADR-038)" {
406
+ write_rfc "001" "byte-budget-test-with-an-extra-long-slug-to-stress-row-width" "accepted"
407
+ write_minimal_readme "| 2.0 | RFC-001 | byte-budget-test-with-an-extra-long-slug-to-stress-row-width | 3 Med | Accepted | M | 2026-05-05 |"
408
+ write_problem "168" "p168" "verifying" ""
409
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
410
+ [ "$status" -eq 1 ]
411
+ while IFS= read -r line; do
412
+ [ ${#line} -le 150 ] || { echo "row exceeds 150 bytes: '$line' (${#line} bytes)"; return 1; }
413
+ done <<< "$output"
414
+ }
415
+
416
+ # ── ADR-049 bin shim contract ───────────────────────────────────────────────
417
+
418
+ @test "wr-itil-reconcile-rfcs bin shim exists" {
419
+ BIN_SHIM="$(cd "$SCRIPTS_DIR/../bin" && pwd)/wr-itil-reconcile-rfcs"
420
+ [ -f "$BIN_SHIM" ]
421
+ }
422
+
423
+ @test "wr-itil-reconcile-rfcs bin shim is executable" {
424
+ BIN_SHIM="$(cd "$SCRIPTS_DIR/../bin" && pwd)/wr-itil-reconcile-rfcs"
425
+ [ -x "$BIN_SHIM" ]
426
+ }
427
+
428
+ @test "wr-itil-reconcile-rfcs bin shim dispatches to canonical script" {
429
+ BIN_SHIM="$(cd "$SCRIPTS_DIR/../bin" && pwd)/wr-itil-reconcile-rfcs"
430
+ write_minimal_readme ""
431
+ run bash "$BIN_SHIM" "$FIXTURE_DIR"
432
+ [ "$status" -eq 0 ]
433
+ }
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P170 — Slice 3 second half (B5.T8): the auto-maintained
4
+ # `## RFCs` section refresh contract on problem ticket bodies. Library
5
+ # helper called inline by /wr-itil:capture-rfc Step 6 and
6
+ # /wr-itil:manage-rfc Step 7+9 so the cross-tier reverse-trace stays
7
+ # current at every commit per ADR-014 single-commit grain.
8
+ #
9
+ # Behavioural per ADR-052: assert on file output state (idempotent
10
+ # table; lazy empty discipline; placement) — not on script source
11
+ # content (no structural greps per P081).
12
+ #
13
+ # Contract (per architect Q3 verdict):
14
+ # - Section position: between `## Related` and `## Fix Released`
15
+ # (or at EOF if neither sentinel present).
16
+ # - Table format: `| RFC | Status | Title |` with separator row.
17
+ # - Sort: RFC ID asc.
18
+ # - Lazy empty: zero traced RFCs → section absent (no header,
19
+ # no `_None._` prose).
20
+ # - Idempotent: re-run over current state is a no-op (cmp -s holds).
21
+ #
22
+ # @adr ADR-060 (Phase 1 item 10 + Confirmation criterion 3)
23
+ # @adr ADR-052 (behavioural bats default)
24
+ # @adr ADR-022 (`## Fix Released` is the trailing closure section)
25
+ # @jtbd JTBD-008 (Decompose a Fix Into Coordinated Changes — reverse
26
+ # trace surface)
27
+ # @jtbd JTBD-101 (atomic-fix-adopter friction guard — lazy empty)
28
+
29
+ setup() {
30
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
31
+ HELPER="$SCRIPTS_DIR/update-problem-rfcs-section.sh"
32
+ RFCS_DIR="$(mktemp -d)"
33
+ PROBLEMS_DIR="$(mktemp -d)"
34
+ }
35
+
36
+ teardown() {
37
+ rm -rf "$RFCS_DIR" "$PROBLEMS_DIR"
38
+ }
39
+
40
+ write_rfc() {
41
+ local id="$1" slug="$2" status="$3"
42
+ local problems="${4:-[P168]}"
43
+ cat > "$RFCS_DIR/RFC-${id}-${slug}.${status}.md" <<EOF
44
+ ---
45
+ status: ${status}
46
+ rfc-id: ${slug}
47
+ reported: 2026-05-05
48
+ decision-makers: [test]
49
+ problems: ${problems}
50
+ ---
51
+
52
+ # RFC-${id}: ${slug}
53
+
54
+ stub
55
+ EOF
56
+ }
57
+
58
+ write_problem() {
59
+ local num="$1" slug="$2" trailing="${3:-}"
60
+ local file="$PROBLEMS_DIR/${num}-${slug}.open.md"
61
+ cat > "$file" <<EOF
62
+ # Problem ${num}: ${slug}
63
+
64
+ **Status**: Open
65
+
66
+ ## Description
67
+
68
+ stub
69
+
70
+ ## Related
71
+
72
+ stub
73
+ EOF
74
+ if [ -n "$trailing" ]; then
75
+ printf '\n%s\n' "$trailing" >> "$file"
76
+ fi
77
+ echo "$file"
78
+ }
79
+
80
+ # ── Existence + executable ──────────────────────────────────────────────────
81
+
82
+ @test "helper exists" {
83
+ [ -f "$HELPER" ]
84
+ }
85
+
86
+ @test "helper is executable" {
87
+ [ -x "$HELPER" ]
88
+ }
89
+
90
+ # ── Lazy-empty discipline (JTBD-101 friction guard) ─────────────────────────
91
+
92
+ @test "no RFCs trace → section absent (lazy empty)" {
93
+ pf=$(write_problem "168" "p168")
94
+ bash "$HELPER" "$pf" "$RFCS_DIR"
95
+ ! grep -q '^## RFCs' "$pf"
96
+ }
97
+
98
+ @test "no RFCs trace and section exists → section removed" {
99
+ # A pre-existing stale section gets cleaned out when zero RFCs claim.
100
+ pf=$(write_problem "168" "p168" $'## RFCs\n\n| RFC | Status | Title |\n|-----|--------|-------|\n| RFC-001 | accepted | foo |')
101
+ bash "$HELPER" "$pf" "$RFCS_DIR"
102
+ ! grep -q '^## RFCs' "$pf"
103
+ }
104
+
105
+ # ── Single-RFC trace ────────────────────────────────────────────────────────
106
+
107
+ @test "one RFC traces P → section appears with one row" {
108
+ write_rfc "001" "foo" "accepted"
109
+ pf=$(write_problem "168" "p168")
110
+ bash "$HELPER" "$pf" "$RFCS_DIR"
111
+ grep -q '^## RFCs' "$pf"
112
+ grep -q '| RFC-001 | accepted | foo |' "$pf"
113
+ }
114
+
115
+ @test "table includes header + separator + data row" {
116
+ write_rfc "001" "foo" "accepted"
117
+ pf=$(write_problem "168" "p168")
118
+ bash "$HELPER" "$pf" "$RFCS_DIR"
119
+ grep -q '| RFC | Status | Title |' "$pf"
120
+ grep -q '|-----|--------|-------|' "$pf"
121
+ grep -q '| RFC-001 | accepted | foo |' "$pf"
122
+ }
123
+
124
+ # ── Multi-RFC trace + sort order ─────────────────────────────────────────────
125
+
126
+ @test "multiple RFCs trace P → all rows present, sorted by RFC ID asc" {
127
+ write_rfc "002" "second" "proposed"
128
+ write_rfc "001" "first" "accepted"
129
+ pf=$(write_problem "168" "p168")
130
+ bash "$HELPER" "$pf" "$RFCS_DIR"
131
+ # Both rows present.
132
+ grep -q '| RFC-001 | accepted | first |' "$pf"
133
+ grep -q '| RFC-002 | proposed | second |' "$pf"
134
+ # RFC-001 appears before RFC-002.
135
+ rfc_001_line=$(grep -n '| RFC-001 |' "$pf" | head -1 | cut -d: -f1)
136
+ rfc_002_line=$(grep -n '| RFC-002 |' "$pf" | head -1 | cut -d: -f1)
137
+ [ "$rfc_001_line" -lt "$rfc_002_line" ]
138
+ }
139
+
140
+ # ── Idempotency ─────────────────────────────────────────────────────────────
141
+
142
+ @test "re-running with no claim change is a no-op (idempotent)" {
143
+ write_rfc "001" "foo" "accepted"
144
+ pf=$(write_problem "168" "p168")
145
+ bash "$HELPER" "$pf" "$RFCS_DIR"
146
+ hash1=$(shasum "$pf" | cut -d' ' -f1)
147
+ bash "$HELPER" "$pf" "$RFCS_DIR"
148
+ hash2=$(shasum "$pf" | cut -d' ' -f1)
149
+ [ "$hash1" = "$hash2" ]
150
+ }
151
+
152
+ # ── Status update on existing row ────────────────────────────────────────────
153
+
154
+ @test "RFC status changes (proposed → accepted) → table row reflects new status" {
155
+ write_rfc "001" "foo" "proposed"
156
+ pf=$(write_problem "168" "p168")
157
+ bash "$HELPER" "$pf" "$RFCS_DIR"
158
+ grep -q '| RFC-001 | proposed | foo |' "$pf"
159
+ # Now transition the RFC to accepted (simulate by renaming on disk).
160
+ mv "$RFCS_DIR/RFC-001-foo.proposed.md" "$RFCS_DIR/RFC-001-foo.accepted.md"
161
+ bash "$HELPER" "$pf" "$RFCS_DIR"
162
+ grep -q '| RFC-001 | accepted | foo |' "$pf"
163
+ # Old status should no longer be present for that RFC row.
164
+ ! grep -q '| RFC-001 | proposed |' "$pf"
165
+ }
166
+
167
+ # ── Re-trace add/remove ─────────────────────────────────────────────────────
168
+
169
+ @test "RFC newly added trace → row appended" {
170
+ write_rfc "001" "foo" "accepted"
171
+ pf=$(write_problem "168" "p168")
172
+ bash "$HELPER" "$pf" "$RFCS_DIR"
173
+ grep -q '| RFC-001 |' "$pf"
174
+
175
+ # Add a second RFC traced to P168.
176
+ write_rfc "002" "bar" "proposed"
177
+ bash "$HELPER" "$pf" "$RFCS_DIR"
178
+ grep -q '| RFC-001 |' "$pf"
179
+ grep -q '| RFC-002 |' "$pf"
180
+ }
181
+
182
+ @test "RFC trace removed (frontmatter no longer claims P) → row drops" {
183
+ write_rfc "001" "foo" "accepted" "[P168]"
184
+ write_rfc "002" "bar" "proposed" "[P168]"
185
+ pf=$(write_problem "168" "p168")
186
+ bash "$HELPER" "$pf" "$RFCS_DIR"
187
+ grep -q '| RFC-001 |' "$pf"
188
+ grep -q '| RFC-002 |' "$pf"
189
+
190
+ # Re-trace RFC-002 to P169 only.
191
+ rm -f "$RFCS_DIR/RFC-002-bar.proposed.md"
192
+ write_rfc "002" "bar" "proposed" "[P169]"
193
+ bash "$HELPER" "$pf" "$RFCS_DIR"
194
+ grep -q '| RFC-001 |' "$pf"
195
+ ! grep -q '| RFC-002 |' "$pf"
196
+ }
197
+
198
+ # ── Section placement ───────────────────────────────────────────────────────
199
+
200
+ @test "## RFCs section sits before ## Fix Released when present" {
201
+ write_rfc "001" "foo" "verifying"
202
+ pf=$(write_problem "168" "p168" $'## Fix Released\n\nReleased trailer prose.')
203
+ bash "$HELPER" "$pf" "$RFCS_DIR"
204
+ rfcs_line=$(grep -n '^## RFCs' "$pf" | head -1 | cut -d: -f1)
205
+ fix_released_line=$(grep -n '^## Fix Released' "$pf" | head -1 | cut -d: -f1)
206
+ [ "$rfcs_line" -lt "$fix_released_line" ]
207
+ }
208
+
209
+ @test "## RFCs section appears at EOF when no ## Fix Released section" {
210
+ write_rfc "001" "foo" "accepted"
211
+ pf=$(write_problem "168" "p168")
212
+ bash "$HELPER" "$pf" "$RFCS_DIR"
213
+ # ## RFCs is the last `## ` heading in the file.
214
+ last_section=$(grep -E '^## ' "$pf" | tail -1)
215
+ [[ "$last_section" == "## RFCs" ]]
216
+ }
217
+
218
+ # ── Multi-problem RFC composition ────────────────────────────────────────────
219
+
220
+ @test "RFC tracing two problems updates both ## RFCs sections" {
221
+ write_rfc "001" "foo" "accepted" "[P168, P169]"
222
+ pf168=$(write_problem "168" "p168")
223
+ pf169=$(write_problem "169" "p169")
224
+ bash "$HELPER" "$pf168" "$RFCS_DIR"
225
+ bash "$HELPER" "$pf169" "$RFCS_DIR"
226
+ grep -q '| RFC-001 |' "$pf168"
227
+ grep -q '| RFC-001 |' "$pf169"
228
+ }
229
+
230
+ # ── ## Related preservation ─────────────────────────────────────────────────
231
+
232
+ @test "existing ## Related section is preserved when ## RFCs is added" {
233
+ write_rfc "001" "foo" "accepted"
234
+ pf=$(write_problem "168" "p168")
235
+ bash "$HELPER" "$pf" "$RFCS_DIR"
236
+ grep -q '^## Related' "$pf"
237
+ grep -q '^## RFCs' "$pf"
238
+ related_line=$(grep -n '^## Related' "$pf" | head -1 | cut -d: -f1)
239
+ rfcs_line=$(grep -n '^## RFCs' "$pf" | head -1 | cut -d: -f1)
240
+ # ## Related comes before ## RFCs.
241
+ [ "$related_line" -lt "$rfcs_line" ]
242
+ }