@windyroad/itil 0.47.2 → 0.47.4-preview.539

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.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "0.47.2"
500
+ "version": "0.47.4"
501
501
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.47.2",
3
+ "version": "0.47.4-preview.539",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -75,23 +75,78 @@ if [ -z "$DRIFT_IDS" ]; then
75
75
  exit 2
76
76
  fi
77
77
 
78
- # ── Build set of IDs covered by staged renames in the working tree ──────────
79
- # `git status --porcelain` v1 emits rename lines as:
80
- # R <old-path> -> <new-path>
81
- # RM <old-path> -> <new-path> (rename + unstaged modification)
78
+ # ── Build set of IDs covered by in-flight ticket renames in the working tree
79
+ # `git status --porcelain` v1 emits same-session rename evidence in two shapes:
80
+ #
81
+ # 1. R / RM rename entries (git's rename-detection matched the old + new path)
82
+ # R <old-path> -> <new-path>
83
+ # RM <old-path> -> <new-path> (rename + unstaged modification)
84
+ #
85
+ # 2. Same-ID D + (A or ??) pair (substantial-body edit defeated rename-
86
+ # detection so the move renders as delete + add for the same ticket ID
87
+ # — P306). We treat such a pair as equivalent to an R entry for drift-
88
+ # coverage purposes: the in-flow P094/P062 refresh will reconcile the
89
+ # README in the upcoming commit per ADR-014.
90
+ #
82
91
  # We match the destination path's ticket ID — the post-rename status is what
83
- # the in-flow P094/P062 refresh will reconcile in the upcoming commit.
92
+ # the in-flow refresh will reconcile.
84
93
  RENAMED_IDS=""
85
94
  if git rev-parse --git-dir >/dev/null 2>&1; then
86
- RENAMED_IDS="$(
87
- git status --porcelain "$PROBLEMS_DIR" 2>/dev/null \
95
+ # `-u` (= `--untracked-files=all`) expands untracked directories so the
96
+ # `??` side of a D + `??` rename pair lists individual files, not the
97
+ # collapsed parent directory.
98
+ STATUS_LINES="$(git status --porcelain -u "$PROBLEMS_DIR" 2>/dev/null)"
99
+
100
+ # Shape 1: R / RM rename entries — extract destination-path ticket ID.
101
+ R_IDS="$(
102
+ printf '%s\n' "$STATUS_LINES" \
88
103
  | awk '/^R/' \
89
104
  | sed 's|.*-> ||' \
90
105
  | sed "s|^${PROBLEMS_DIR}/||" \
91
106
  | grep -oE '^[0-9]{3}' \
107
+ | awk '{ printf "P%s\n", $0 }'
108
+ )"
109
+
110
+ # Shape 2: same-ID D + (A or ??) pair — intersect deleted-IDs with added-
111
+ # IDs. `git status --porcelain` emits one entry per file:
112
+ # ` D <path>` staged delete (note: column 1 is space, column 2 is `D`)
113
+ # `D <path>` staged delete (alternate form when also touched in index)
114
+ # `A <path>` staged add
115
+ # `?? <path>` untracked add
116
+ # We treat any 3-char prefix line whose 1st-or-2nd column is `D`/`A`/`?`
117
+ # as candidate, then extract the leading `<NNN>` from the basename.
118
+ DELETED_IDS="$(
119
+ printf '%s\n' "$STATUS_LINES" \
120
+ | awk 'substr($0,1,2) ~ /D / || substr($0,1,2) ~ /^ D/' \
121
+ | sed 's|^...||' \
122
+ | sed "s|^${PROBLEMS_DIR}/||" \
123
+ | grep -oE '^[0-9]{3}' \
92
124
  | awk '{ printf "P%s\n", $0 }' \
93
125
  | sort -u
94
126
  )"
127
+ ADDED_IDS="$(
128
+ printf '%s\n' "$STATUS_LINES" \
129
+ | awk 'substr($0,1,2) ~ /A / || substr($0,1,2) == "??"' \
130
+ | sed 's|^...||' \
131
+ | sed "s|^${PROBLEMS_DIR}/||" \
132
+ | grep -oE '^[0-9]{3}' \
133
+ | awk '{ printf "P%s\n", $0 }' \
134
+ | sort -u
135
+ )"
136
+ DA_PAIR_IDS=""
137
+ if [ -n "$DELETED_IDS" ] && [ -n "$ADDED_IDS" ]; then
138
+ DA_PAIR_IDS="$(
139
+ comm -12 \
140
+ <(printf '%s\n' "$DELETED_IDS") \
141
+ <(printf '%s\n' "$ADDED_IDS")
142
+ )"
143
+ fi
144
+
145
+ RENAMED_IDS="$(
146
+ { printf '%s\n' "$R_IDS"; printf '%s\n' "$DA_PAIR_IDS"; } \
147
+ | grep -v '^$' \
148
+ | sort -u
149
+ )"
95
150
  fi
96
151
 
97
152
  # ── Cross-reference each drift ID against the renamed set ───────────────────
@@ -251,8 +251,16 @@ if [ -d "$PROBLEMS_DIR" ]; then
251
251
  # problem_rfc_ids["P168"] = "RFC-001 RFC-002 ..."
252
252
  declare -A problem_rfc_rows
253
253
  declare -A problem_rfc_ids
254
+ # P312 / ADR-031: scan both flat docs/problems/<NNN>-*.md AND per-state
255
+ # subdir docs/problems/<state>/<NNN>-*.md layouts so the reverse-trace
256
+ # remains valid post-migration. Mirrors reconcile-readme.sh lines 74-110.
254
257
  shopt -s nullglob
255
- for pf in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.md; do
258
+ problem_files=( "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.md )
259
+ for ticket_status in open known-error verifying closed parked; do
260
+ problem_files+=( "$PROBLEMS_DIR"/"$ticket_status"/[0-9][0-9][0-9]-*.md )
261
+ done
262
+ shopt -u nullglob
263
+ for pf in "${problem_files[@]}"; do
256
264
  pbase="$(basename "$pf")"
257
265
  pnum="${pbase%%-*}"
258
266
  pid="P${pnum}"
@@ -274,7 +282,6 @@ if [ -d "$PROBLEMS_DIR" ]; then
274
282
  done < <(awk -v start="$sec_start" 'NR>start { if (/^## /) exit; print }' "$pf")
275
283
  problem_rfc_ids["$pid"]="$rfcs_in_p"
276
284
  done
277
- shopt -u nullglob
278
285
 
279
286
  # 1. MISSING_REVERSE_TRACE: RFC claims P, P does not list RFC.
280
287
  for rfc_id in "${!rfc_problems_claim[@]}"; do
@@ -132,6 +132,136 @@ EOF
132
132
  echo "$output" | grep -q "covered=1"
133
133
  }
134
134
 
135
+ # ── P306: same-ID D+A pair coverage (substantial-body in-flight rename) ─────
136
+ # When `git mv` is followed by a substantial body edit, git's rename-detection
137
+ # may not match the old/new paths and emits a delete+add pair instead of an R
138
+ # entry. The classifier must recognise a same-ID D+A pair as same-session
139
+ # coverage — equivalent to an R/RM entry — and return INLINE_REFRESH.
140
+
141
+ @test "classify-readme-drift: same-ID D+A pair (substantial-body rename) → INLINE_REFRESH" {
142
+ # Seed an open ticket, commit, then `git mv` + substantial-body edit. With
143
+ # rename-detection thresholds, git may emit `D ` + `A ` (or `??`) rather
144
+ # than `R `. We force the D+A shape by writing wholly different content to
145
+ # the destination path.
146
+ cat > docs/problems/306-foo.open.md <<'EOF'
147
+ # Problem 306: Foo
148
+
149
+ **Status**: Open
150
+
151
+ Original body — short.
152
+ EOF
153
+ git add docs/problems/306-foo.open.md
154
+ git commit -q -m "init"
155
+
156
+ # Create the new file at the verifying path with substantially different
157
+ # body BEFORE removing the old path (avoid empty-dir prune). git's
158
+ # rename-detection then sees delete + add as distinct entries rather than
159
+ # an R-rename, because the bodies are wholly different.
160
+ cat > docs/problems/306-foo.verifying.md <<'EOF'
161
+ # Problem 306: Foo
162
+
163
+ **Status**: Verification Pending
164
+
165
+ Wholly rewritten body so git rename-detection does not match the source.
166
+ This is a multi-paragraph substantive rewrite that exercises the D+A path.
167
+
168
+ ## Fix Released
169
+
170
+ Deployed in vX.Y.Z. Adds a behavioural fixture that exercises the
171
+ classifier across the D+A coverage gap that P306 captured.
172
+ EOF
173
+ git rm -q docs/problems/306-foo.open.md
174
+ git add docs/problems/306-foo.verifying.md
175
+
176
+ cat > drift.txt <<'EOF'
177
+ DRIFT P306 wsjf-rankings: claims=open actual=verifying
178
+ MISSING P306 verification-queue: actual=verifying
179
+ EOF
180
+
181
+ run "$SCRIPT" drift.txt docs/problems
182
+ [ "$status" -eq 0 ]
183
+ echo "$output" | grep -q "INLINE_REFRESH"
184
+ echo "$output" | grep -q "covered=1"
185
+ }
186
+
187
+ @test "classify-readme-drift: same-ID D+A pair with untracked add (D + ??) → INLINE_REFRESH" {
188
+ # Variant: the new path is untracked (not yet `git add`-ed). git status
189
+ # emits `D ` for the old path and `??` for the new path. The classifier
190
+ # must still recognise the same-ID pair as same-session coverage.
191
+ cat > docs/problems/307-bar.open.md <<'EOF'
192
+ # Problem 307: Bar
193
+ **Status**: Open
194
+ EOF
195
+ git add docs/problems/307-bar.open.md
196
+ git commit -q -m "init"
197
+
198
+ cat > docs/problems/307-bar.verifying.md <<'EOF'
199
+ # Problem 307: Bar
200
+
201
+ **Status**: Verification Pending
202
+
203
+ Untracked add side of the D+?? pair.
204
+ EOF
205
+ git rm -q docs/problems/307-bar.open.md
206
+
207
+ cat > drift.txt <<'EOF'
208
+ DRIFT P307 wsjf-rankings: claims=open actual=verifying
209
+ EOF
210
+
211
+ run "$SCRIPT" drift.txt docs/problems
212
+ [ "$status" -eq 0 ]
213
+ echo "$output" | grep -q "INLINE_REFRESH"
214
+ echo "$output" | grep -q "covered=1"
215
+ }
216
+
217
+ @test "classify-readme-drift: D-only (no matching A for same ID) → HALT_ROUTE_RECONCILE" {
218
+ # Negative case: a delete without a corresponding add for the same ID is
219
+ # NOT a rename — it is a genuine deletion. Must HALT.
220
+ cat > docs/problems/308-baz.open.md <<'EOF'
221
+ # Problem 308: Baz
222
+ **Status**: Open
223
+ EOF
224
+ git add docs/problems/308-baz.open.md
225
+ git commit -q -m "init"
226
+
227
+ git rm -q docs/problems/308-baz.open.md
228
+
229
+ cat > drift.txt <<'EOF'
230
+ DRIFT P308 wsjf-rankings: claims=open actual=verifying
231
+ EOF
232
+
233
+ run "$SCRIPT" drift.txt docs/problems
234
+ [ "$status" -eq 1 ]
235
+ echo "$output" | grep -q "HALT_ROUTE_RECONCILE"
236
+ echo "$output" | grep -q "uncovered=1"
237
+ }
238
+
239
+ @test "classify-readme-drift: mismatched D + A (different IDs) → HALT_ROUTE_RECONCILE" {
240
+ # Negative case: a delete for one ID + an add for a different ID is NOT a
241
+ # rename of either ticket — both are uncovered.
242
+ cat > docs/problems/309-alpha.open.md <<'EOF'
243
+ # Problem 309: Alpha
244
+ **Status**: Open
245
+ EOF
246
+ git add docs/problems/309-alpha.open.md
247
+ git commit -q -m "init"
248
+
249
+ cat > docs/problems/310-beta.open.md <<'EOF'
250
+ # Problem 310: Beta
251
+ **Status**: Open
252
+ EOF
253
+ git rm -q docs/problems/309-alpha.open.md
254
+ git add docs/problems/310-beta.open.md
255
+
256
+ cat > drift.txt <<'EOF'
257
+ DRIFT P309 wsjf-rankings: claims=open actual=verifying
258
+ EOF
259
+
260
+ run "$SCRIPT" drift.txt docs/problems
261
+ [ "$status" -eq 1 ]
262
+ echo "$output" | grep -q "HALT_ROUTE_RECONCILE"
263
+ }
264
+
135
265
  # ── Exit 1 (HALT_ROUTE_RECONCILE): committed cross-session drift ────────────
136
266
 
137
267
  @test "classify-readme-drift: single drift ID not covered by any rename → HALT_ROUTE_RECONCILE" {
@@ -131,6 +131,40 @@ EOF
131
131
  fi
132
132
  }
133
133
 
134
+ # Helper: write a problem ticket under the per-state subdir layout per
135
+ # ADR-031 (state is the parent directory; filename has NO `.state.md` suffix).
136
+ # Args: <pid-num> <slug> <state> <rfcs-rows-block>
137
+ # Used to regression-test P312 — reconcile-rfcs reverse-trace must traverse
138
+ # docs/problems/<state>/<NNN>-*.md, not just flat docs/problems/<NNN>-*.<state>.md.
139
+ write_problem_subdir() {
140
+ local num="$1" slug="$2" state="$3" rfcs_rows="${4:-}"
141
+ mkdir -p "$PROBLEMS_DIR/$state"
142
+ local file="$PROBLEMS_DIR/$state/${num}-${slug}.md"
143
+ cat > "$file" <<EOF
144
+ # Problem ${num}: ${slug}
145
+
146
+ **Status**: ${state}
147
+
148
+ ## Description
149
+
150
+ stub
151
+
152
+ ## Related
153
+
154
+ stub
155
+ EOF
156
+ if [ -n "$rfcs_rows" ]; then
157
+ cat >> "$file" <<EOF
158
+
159
+ ## RFCs
160
+
161
+ | RFC | Status | Title |
162
+ |-----|--------|-------|
163
+ ${rfcs_rows}
164
+ EOF
165
+ fi
166
+ }
167
+
134
168
  # ── Existence + executable ──────────────────────────────────────────────────
135
169
 
136
170
  @test "reconcile-rfcs: script exists" {
@@ -413,6 +447,35 @@ EOF
413
447
  done <<< "$output"
414
448
  }
415
449
 
450
+ # ── P312: per-state subdir reverse-trace (ADR-031 layout) ───────────────────
451
+ # Closes P312 — reconcile-rfcs reported spurious MISSING_REVERSE_TRACE for
452
+ # tickets that live under docs/problems/<state>/<NNN>-*.md because the
453
+ # reverse-trace pass only globbed the flat docs/problems/<NNN>-*.md layout.
454
+ # RFC-002-class dual-tolerant-glob fix mirroring the sibling already shipped
455
+ # in reconcile-readme.sh (P118).
456
+
457
+ @test "P312: reverse-trace clean when problem ticket lives in per-state subdir" {
458
+ write_rfc "001" "foo" "accepted"
459
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
460
+ # Ticket lives under docs/problems/verifying/168-p168.md (no .state suffix).
461
+ write_problem_subdir "168" "p168" "verifying" "| RFC-001 | accepted | foo |"
462
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
463
+ [ "$status" -eq 0 ]
464
+ [[ "$output" != *"MISSING_REVERSE_TRACE"* ]]
465
+ }
466
+
467
+ @test "P312: reverse-trace detects missing trace when problem ticket lives in per-state subdir" {
468
+ write_rfc "001" "foo" "accepted"
469
+ write_minimal_readme "| 2.0 | RFC-001 | foo | 3 Med | Accepted | M | 2026-05-05 |"
470
+ # Subdir ticket WITHOUT a `## RFCs` section → MISSING_REVERSE_TRACE must fire.
471
+ write_problem_subdir "168" "p168" "verifying" ""
472
+ run bash "$SCRIPT" "$FIXTURE_DIR" "$PROBLEMS_DIR"
473
+ [ "$status" -eq 1 ]
474
+ [[ "$output" == *"MISSING_REVERSE_TRACE"* ]]
475
+ [[ "$output" == *"RFC-001"* ]]
476
+ [[ "$output" == *"P168"* ]]
477
+ }
478
+
416
479
  # ── ADR-049 bin shim contract ───────────────────────────────────────────────
417
480
 
418
481
  @test "wr-itil-reconcile-rfcs bin shim exists" {