@windyroad/itil 0.47.3 → 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.3"
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.3",
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 ───────────────────
@@ -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" {