@windyroad/itil 0.25.0 → 0.26.0-preview.291

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,329 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/reconcile-rfcs.sh
3
+ #
4
+ # Diagnose-only drift detector for docs/rfcs/README.md vs filesystem
5
+ # truth. Reads <rfcs-dir>/RFC-<NNN>-*.<status>.md, parses the README's
6
+ # WSJF Rankings + Verification Queue + Closed tables, and reports each
7
+ # disagreement.
8
+ #
9
+ # Usage:
10
+ # reconcile-rfcs.sh [<rfcs-dir> [<problems-dir>]]
11
+ #
12
+ # Default <rfcs-dir> is ./docs/rfcs.
13
+ # Default <problems-dir> is ./docs/problems (when supplied; absent dir
14
+ # silently skips the reverse-trace pass per backward-compat carve-out).
15
+ #
16
+ # Exit codes:
17
+ # 0 = clean (README matches filesystem)
18
+ # 1 = drift detected (structured diff to stdout)
19
+ # 2 = parse error (README missing or malformed)
20
+ #
21
+ # Output format on drift (one line per drift entry, ≤ 150 bytes per
22
+ # ADR-038 progressive-disclosure budget):
23
+ # DRIFT RFC-<NNN> wsjf-rankings: claims=<status> actual=<status>
24
+ # MISSING RFC-<NNN> wsjf-rankings: actual=<status>
25
+ # STALE RFC-<NNN> verification-queue: actual=<status>
26
+ # MISMATCH RFC-<NNN> closed: actual=<status>
27
+ #
28
+ # Reverse-trace pass (B5.T8 — closes ADR-060 Confirmation criterion 3):
29
+ # When <problems-dir> is provided AND on disk, the reconciler also
30
+ # checks the auto-maintained `## RFCs` section on each problem ticket
31
+ # against the RFC frontmatter `problems:` claims. Three drift kinds:
32
+ # MISSING_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
33
+ # RFC's frontmatter claims P<NNN> but P<NNN>'s ## RFCs table does
34
+ # not list RFC-<NNN>. Skill-side refresh contract was missed.
35
+ # STALE_REVERSE_TRACE RFC-<NNN> in P<NNN> ## RFCs
36
+ # P<NNN>'s ## RFCs lists RFC-<NNN> but the RFC frontmatter no
37
+ # longer claims P<NNN>. Re-trace bookkeeping was missed.
38
+ # STATUS_MISMATCH RFC-<NNN> in P<NNN> ## RFCs claims=<X> actual=<Y>
39
+ # P<NNN>'s ## RFCs row claims status <X> but RFC's filesystem
40
+ # suffix is <Y>. Status-column refresh contract was missed.
41
+ #
42
+ # Read-only — does NOT mutate the README. The /wr-itil:manage-rfc skill
43
+ # applies edits with narrative-aware preservation; this script's only
44
+ # job is to report ground truth.
45
+ #
46
+ # Sibling to packages/itil/scripts/reconcile-readme.sh (P118 / ADR-014):
47
+ # same parse + diff structure, applied at the RFC tier instead of the
48
+ # problems tier. Differences:
49
+ # - Filename pattern: RFC-NNN-*.<status>.md (5 statuses: proposed,
50
+ # accepted, in-progress, verifying, closed)
51
+ # - ID format: RFC-<NNN> (vs P<NNN>)
52
+ # - WSJF Rankings covers proposed/accepted/in-progress (RFC dev-work
53
+ # queue per ADR-060 § Decisions Resolved — RFC-level WSJF, Phase 1)
54
+ # - Verification Queue covers verifying (matches problem tier)
55
+ # - Closed covers closed
56
+ # - No Parked tier (RFCs don't have a Parked status per ADR-060;
57
+ # only Problems do)
58
+ #
59
+ # @problem P170
60
+ # @adr ADR-060 (Problem-RFC-Story framework — Phase 1 item 5)
61
+ # @adr ADR-049 (Plugin script resolution via bin/ on PATH — paired bin shim)
62
+
63
+ set -uo pipefail
64
+
65
+ RFCS_DIR="${1:-docs/rfcs}"
66
+ # Default PROBLEMS_DIR to the sibling of RFCS_DIR (so real-use
67
+ # `docs/rfcs` → `docs/problems`, and fixture-isolated test runs in
68
+ # `/tmp/X` → `/tmp/problems` which won't exist, gracefully skipping
69
+ # reverse-trace). Backward-compat: existing 18-case bats fixtures
70
+ # stay clean because `dirname /tmp/<rand>` = `/tmp`, never colliding
71
+ # with the real repo's `docs/problems`.
72
+ PROBLEMS_DIR="${2:-$(dirname "$RFCS_DIR")/problems}"
73
+ README="${RFCS_DIR}/README.md"
74
+
75
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
76
+
77
+ if [ ! -f "$README" ]; then
78
+ echo "PARSE_ERROR: README not found at ${README}" >&2
79
+ exit 2
80
+ fi
81
+
82
+ if ! grep -q '^## RFC Rankings\|^## WSJF Rankings' "$README"; then
83
+ echo "PARSE_ERROR: '## RFC Rankings' or '## WSJF Rankings' header missing in ${README}" >&2
84
+ exit 2
85
+ fi
86
+
87
+ # ── Build filesystem truth: ID → status ─────────────────────────────────────
88
+
89
+ declare -A FS_STATUS
90
+ shopt -s nullglob
91
+ for f in "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.proposed.md \
92
+ "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.accepted.md \
93
+ "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.in-progress.md \
94
+ "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.verifying.md \
95
+ "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.closed.md; do
96
+ base="$(basename "$f")"
97
+ # Extract NNN from RFC-NNN-...
98
+ num="${base#RFC-}"
99
+ num="${num%%-*}"
100
+ id="RFC-${num}"
101
+ case "$base" in
102
+ *.proposed.md) ticket_status="proposed" ;;
103
+ *.accepted.md) ticket_status="accepted" ;;
104
+ *.in-progress.md) ticket_status="in-progress" ;;
105
+ *.verifying.md) ticket_status="verifying" ;;
106
+ *.closed.md) ticket_status="closed" ;;
107
+ *) continue ;;
108
+ esac
109
+ FS_STATUS["$id"]="$ticket_status"
110
+ done
111
+ shopt -u nullglob
112
+
113
+ # ── Parse README sections into ID buckets ───────────────────────────────────
114
+
115
+ # Accept either '## RFC Rankings' (this README's heading) or
116
+ # '## WSJF Rankings' (problems-tier-style heading) — the structural test
117
+ # is "is there a ranking section?" not "is the heading word-for-word".
118
+ WSJF_START=$(grep -nE '^## (RFC Rankings|WSJF Rankings)' "$README" | head -1 | cut -d: -f1)
119
+ VQ_START=$(grep -n '^## Verification Queue' "$README" | head -1 | cut -d: -f1)
120
+ CLOSED_START=$(grep -n '^## Closed' "$README" | head -1 | cut -d: -f1)
121
+ END_LINE=$(wc -l < "$README")
122
+
123
+ WSJF_END=${VQ_START:-${CLOSED_START:-$END_LINE}}
124
+ VQ_END=${CLOSED_START:-$END_LINE}
125
+ CLOSED_END=$END_LINE
126
+
127
+ extract_section_ids() {
128
+ local start="$1" end="$2"
129
+ [ -z "$start" ] && return 0
130
+ sed -n "${start},${end}p" "$README" \
131
+ | grep -oE '\| *RFC-[0-9]{3} *\|' \
132
+ | grep -oE 'RFC-[0-9]{3}' \
133
+ | sort -u
134
+ }
135
+
136
+ README_WSJF_IDS="$(extract_section_ids "$WSJF_START" "$WSJF_END")"
137
+ README_VQ_IDS="$(extract_section_ids "$VQ_START" "$VQ_END")"
138
+ README_CLOSED_IDS="$(extract_section_ids "$CLOSED_START" "$CLOSED_END")"
139
+
140
+ # ── Diff ─────────────────────────────────────────────────────────────────────
141
+
142
+ DRIFT_LINES=()
143
+
144
+ # (1) Each ID listed in RFC Rankings must be proposed/accepted/in-progress
145
+ # on disk. Other statuses (verifying/closed) → drift.
146
+ while read -r id; do
147
+ [ -z "$id" ] && continue
148
+ actual="${FS_STATUS[$id]:-missing}"
149
+ case "$actual" in
150
+ proposed|accepted|in-progress)
151
+ : # ok
152
+ ;;
153
+ *)
154
+ DRIFT_LINES+=("DRIFT ${id} wsjf-rankings: claims=open actual=${actual}")
155
+ ;;
156
+ esac
157
+ done <<< "$README_WSJF_IDS"
158
+
159
+ # (2) Each ID listed in Verification Queue must be verifying on disk.
160
+ while read -r id; do
161
+ [ -z "$id" ] && continue
162
+ actual="${FS_STATUS[$id]:-missing}"
163
+ case "$actual" in
164
+ verifying)
165
+ : # ok
166
+ ;;
167
+ *)
168
+ DRIFT_LINES+=("STALE ${id} verification-queue: actual=${actual}")
169
+ ;;
170
+ esac
171
+ done <<< "$README_VQ_IDS"
172
+
173
+ # (3) Each ID listed in Closed section must be closed on disk.
174
+ while read -r id; do
175
+ [ -z "$id" ] && continue
176
+ actual="${FS_STATUS[$id]:-missing}"
177
+ case "$actual" in
178
+ closed)
179
+ : # ok
180
+ ;;
181
+ *)
182
+ DRIFT_LINES+=("MISMATCH ${id} closed: actual=${actual}")
183
+ ;;
184
+ esac
185
+ done <<< "$README_CLOSED_IDS"
186
+
187
+ # (4) Each filesystem RFC must appear in the right README section.
188
+ declare -A IN_WSJF
189
+ while read -r id; do
190
+ [ -z "$id" ] && continue
191
+ IN_WSJF["$id"]=1
192
+ done <<< "$README_WSJF_IDS"
193
+
194
+ declare -A IN_VQ
195
+ while read -r id; do
196
+ [ -z "$id" ] && continue
197
+ IN_VQ["$id"]=1
198
+ done <<< "$README_VQ_IDS"
199
+
200
+ for id in "${!FS_STATUS[@]}"; do
201
+ ticket_status="${FS_STATUS[$id]}"
202
+ case "$ticket_status" in
203
+ proposed|accepted|in-progress)
204
+ if [ -z "${IN_WSJF[$id]:-}" ]; then
205
+ DRIFT_LINES+=("MISSING ${id} wsjf-rankings: actual=${ticket_status}")
206
+ fi
207
+ ;;
208
+ verifying)
209
+ if [ -z "${IN_VQ[$id]:-}" ]; then
210
+ DRIFT_LINES+=("MISSING ${id} verification-queue: actual=${ticket_status}")
211
+ fi
212
+ ;;
213
+ # closed: Closed section is curated narrative; absence is soft
214
+ # drift not flagged at this layer (mirrors reconcile-readme).
215
+ esac
216
+ done
217
+
218
+ # ── Reverse-trace pass (B5.T8) ──────────────────────────────────────────────
219
+ # When PROBLEMS_DIR exists, validate that each problem ticket's auto-
220
+ # maintained `## RFCs` section agrees with the corresponding RFC
221
+ # frontmatter `problems:` claims (and vice-versa).
222
+
223
+ if [ -d "$PROBLEMS_DIR" ]; then
224
+ # rfc_problems_claim["RFC-NNN"] = "P168 P169 ..."
225
+ declare -A rfc_problems_claim
226
+ shopt -s nullglob
227
+ for f in "$RFCS_DIR"/RFC-[0-9][0-9][0-9]-*.md; do
228
+ base="$(basename "$f")"
229
+ num="${base#RFC-}"
230
+ num="${num%%-*}"
231
+ rfc_id="RFC-${num}"
232
+ # Parse YAML frontmatter `problems: [P168, P169]` (single line).
233
+ raw=$(awk '/^problems:/ { print; exit }' "$f")
234
+ # Strip everything except inside-brackets bare comma-separated content.
235
+ inner=$(echo "$raw" | sed -E 's/^[[:space:]]*problems:[[:space:]]*\[//; s/\][[:space:]]*$//')
236
+ # Tokenise on commas, normalise to bare P<NNN> tokens.
237
+ pids=""
238
+ if [ -n "$inner" ]; then
239
+ while IFS= read -r tok; do
240
+ tok=$(echo "$tok" | tr -d ' "'\''')
241
+ case "$tok" in
242
+ P[0-9][0-9][0-9]) pids="${pids:+$pids }$tok" ;;
243
+ esac
244
+ done <<< "$(echo "$inner" | tr ',' '\n')"
245
+ fi
246
+ rfc_problems_claim["$rfc_id"]="$pids"
247
+ done
248
+ shopt -u nullglob
249
+
250
+ # problem_rfc_rows["P168 RFC-001"] = "<claimed-status>"
251
+ # problem_rfc_ids["P168"] = "RFC-001 RFC-002 ..."
252
+ declare -A problem_rfc_rows
253
+ declare -A problem_rfc_ids
254
+ shopt -s nullglob
255
+ for pf in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.md; do
256
+ pbase="$(basename "$pf")"
257
+ pnum="${pbase%%-*}"
258
+ pid="P${pnum}"
259
+ # Locate `## RFCs` section start (if any).
260
+ sec_start=$(awk 'BEGIN{flag=0} /^## RFCs[[:space:]]*$/ {print NR; exit}' "$pf")
261
+ [ -z "$sec_start" ] && continue
262
+ # Read until next `## ` header or EOF; extract `| RFC-NNN | <status> | ...|` rows.
263
+ rfcs_in_p=""
264
+ while IFS= read -r line; do
265
+ case "$line" in
266
+ \|*RFC-[0-9][0-9][0-9]*\|*)
267
+ rid=$(echo "$line" | grep -oE 'RFC-[0-9]{3}' | head -1)
268
+ claimed=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$3); print $3}')
269
+ [ -z "$rid" ] && continue
270
+ problem_rfc_rows["${pid} ${rid}"]="$claimed"
271
+ rfcs_in_p="${rfcs_in_p:+$rfcs_in_p }$rid"
272
+ ;;
273
+ esac
274
+ done < <(awk -v start="$sec_start" 'NR>start { if (/^## /) exit; print }' "$pf")
275
+ problem_rfc_ids["$pid"]="$rfcs_in_p"
276
+ done
277
+ shopt -u nullglob
278
+
279
+ # 1. MISSING_REVERSE_TRACE: RFC claims P, P does not list RFC.
280
+ for rfc_id in "${!rfc_problems_claim[@]}"; do
281
+ pids="${rfc_problems_claim[$rfc_id]}"
282
+ [ -z "$pids" ] && continue
283
+ for pid in $pids; do
284
+ listed="${problem_rfc_ids[$pid]:-}"
285
+ case " $listed " in
286
+ *" $rfc_id "*) : ;;
287
+ *)
288
+ DRIFT_LINES+=("MISSING_REVERSE_TRACE ${rfc_id} in ${pid} ## RFCs")
289
+ ;;
290
+ esac
291
+ done
292
+ done
293
+
294
+ # 2. STALE_REVERSE_TRACE: P lists RFC, RFC frontmatter does not claim P.
295
+ # STATUS_MISMATCH: P's row claims status X but RFC suffix is Y.
296
+ for pid in "${!problem_rfc_ids[@]}"; do
297
+ rids="${problem_rfc_ids[$pid]}"
298
+ [ -z "$rids" ] && continue
299
+ for rid in $rids; do
300
+ claimed_pids="${rfc_problems_claim[$rid]:-}"
301
+ case " $claimed_pids " in
302
+ *" $pid "*)
303
+ # Status-mismatch check (only when reverse-trace is itself current).
304
+ claimed_status="${problem_rfc_rows[${pid} ${rid}]:-}"
305
+ actual_status="${FS_STATUS[$rid]:-missing}"
306
+ if [ -n "$claimed_status" ] && [ "$claimed_status" != "$actual_status" ]; then
307
+ DRIFT_LINES+=("STATUS_MISMATCH ${rid} in ${pid} ## RFCs claims=${claimed_status} actual=${actual_status}")
308
+ fi
309
+ ;;
310
+ *)
311
+ DRIFT_LINES+=("STALE_REVERSE_TRACE ${rid} in ${pid} ## RFCs")
312
+ ;;
313
+ esac
314
+ done
315
+ done
316
+ fi
317
+
318
+ # ── Report ──────────────────────────────────────────────────────────────────
319
+
320
+ if [ ${#DRIFT_LINES[@]} -eq 0 ]; then
321
+ exit 0
322
+ fi
323
+
324
+ IFS=$'\n' sorted=($(printf '%s\n' "${DRIFT_LINES[@]}" | sort))
325
+ unset IFS
326
+ for line in "${sorted[@]}"; do
327
+ printf '%s\n' "$line"
328
+ done
329
+ exit 1