@windyroad/itil 0.54.2 → 0.54.3-preview.792

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.54.2"
500
+ "version": "0.54.3"
501
501
  }
@@ -97,8 +97,10 @@ command_invokes_git_commit "$COMMAND" || exit 0
97
97
 
98
98
  # Run detection. Helper echoes offending plugin slug on stdout when
99
99
  # detected; returns 1 in that case. Returns 0 (allow) on no-trap,
100
- # bypass env, or fail-open (non-git tree, parse error).
101
- TRAPPED_SLUG=$(detect_changeset_required 2>/dev/null) && exit 0
100
+ # bypass env, or fail-open (non-git tree, parse error). The COMMAND is
101
+ # passed so Check 2b can change-scope to the commit's work-item ID (P387):
102
+ # an in-scope changeset for an UNRELATED ticket no longer covers this commit.
103
+ TRAPPED_SLUG=$(detect_changeset_required "$COMMAND" 2>/dev/null) && exit 0
102
104
 
103
105
  # Trap detected — emit deny with terse recovery.
104
106
  # Voice-tone budget per ADR-045 deny-band ≤300 bytes total. Names the
@@ -118,12 +118,13 @@
118
118
  # repo with no remotes), Check 2b returns 1 (no in-range scope to
119
119
  # inspect) — Phase 1 strict-deny behaviour is preserved.
120
120
  #
121
- # Returns: 0 (an in-scope changeset covers the plugin → allow)
121
+ # Returns: 0 (≥1 in-scope changeset targets the plugin → paths echoed on
122
+ # stdout, newline-separated, for the caller's change-scope check)
122
123
  # 1 (no covering changeset found → caller falls through)
123
124
  _changeset_in_scope_covers_plugin() {
124
125
  local slug="$1"
125
126
  local base
126
- local candidates path
127
+ local candidates path found=""
127
128
 
128
129
  base=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) \
129
130
  || base="origin/main"
@@ -159,25 +160,55 @@ _changeset_in_scope_covers_plugin() {
159
160
  # awk scoping prevents false positives from prose body mentions.
160
161
  if awk '/^---[[:space:]]*$/ { c++; if (c == 1) next; if (c == 2) exit } c == 1 { print }' "$path" 2>/dev/null \
161
162
  | grep -qE "^\"@windyroad/${slug}\":[[:space:]]"; then
162
- return 0
163
+ found="${found}${path}
164
+ "
163
165
  fi
164
166
  done <<EOF
165
167
  $candidates
166
168
  EOF
167
169
 
168
- return 1
170
+ [ -n "$found" ] || return 1
171
+ printf '%s' "$found"
172
+ return 0
173
+ }
174
+
175
+ # P387 helper — echo the space-separated, upper-cased, de-duplicated set of
176
+ # work-item IDs found in the text passed as $1. Work-item identity = problem
177
+ # ticket (`P<NNN>`), RFC (`RFC-<NNN>`), or story (`STORY-<NNN>`). ADR refs are
178
+ # deliberately excluded — an ADR is cross-cutting context cited in passing, not
179
+ # the identity of the change a changeset documents.
180
+ #
181
+ # Used to compare a committing change's ticket reference(s) (from the
182
+ # git-commit COMMAND string) against an in-scope changeset's reference(s)
183
+ # (filename + body). Matching is inclusive and case-insensitive: extracting a
184
+ # spurious extra ID only widens the overlap (the allow direction) and can never
185
+ # manufacture a false deny, which keeps Check 2b conservative.
186
+ _work_item_ids() {
187
+ printf '%s' "$1" \
188
+ | grep -oiE '\b(P[0-9]+|RFC-[0-9]+|STORY-[0-9]+)\b' 2>/dev/null \
189
+ | tr '[:lower:]' '[:upper:]' \
190
+ | sort -u \
191
+ | tr '\n' ' '
169
192
  }
170
193
 
171
194
  # Detect whether the current staged set requires a changeset that is
172
195
  # not satisfied by either staged Check 2a or in-scope Check 2b.
173
196
  #
197
+ # $1 (optional) — the git-commit COMMAND string (or commit message). Its
198
+ # work-item ID(s) are matched against in-scope changesets for the P387
199
+ # change-scoped Check 2b. Empty / omitted → Check 2b falls back to the
200
+ # pre-P387 plugin-scoped behaviour (any covering changeset allows), which
201
+ # is the conservative choice when no commit context is available.
202
+ #
174
203
  # Echoes the offending plugin slug on stdout when detected.
175
204
  #
176
205
  # Returns:
177
206
  # 0 — no change required, BYPASS env set, fail-open, or an in-scope
178
- # changeset covers the plugin (Phase 2 Check 2b)
179
- # 1 — change required + no covering changeset (caller should deny)
207
+ # changeset covers the plugin AND is change-scoped to it (Check 2b)
208
+ # 1 — change required + no covering (or only unrelated-sibling) changeset
209
+ # (caller should deny)
180
210
  detect_changeset_required() {
211
+ local commit_msg="${1:-}"
181
212
  # Bypass via env var — single most-common legitimate escape.
182
213
  if [ "${BYPASS_CHANGESET_GATE:-}" = "1" ]; then
183
214
  return 0
@@ -270,12 +301,54 @@ EOF
270
301
  return 0
271
302
  fi
272
303
 
273
- # Check 2b (P141 Phase 2) — in-scope changeset targeting the plugin
274
- # satisfies. Scope = unpushed-range commits + untracked + modified-
275
- # not-staged working-tree files. Once consumed onto origin, the
276
- # changeset is gone and a fresh one is required.
277
- if _changeset_in_scope_covers_plugin "$plugin_source_slug"; then
278
- return 0
304
+ # Check 2b (P141 Phase 2 + P387 change-scoped) — an in-scope changeset
305
+ # targeting the plugin satisfies, but only when it is change-scoped to
306
+ # THIS commit. Scope = unpushed-range commits + untracked + modified-
307
+ # not-staged working-tree files. Once consumed onto origin, the changeset
308
+ # is gone and a fresh one is required.
309
+ local covering
310
+ if covering=$(_changeset_in_scope_covers_plugin "$plugin_source_slug"); then
311
+ # P387: tighten plugin-scoped → change-scoped. A plugin can carry a
312
+ # changeset for an unrelated change; before P387 that wrongly covered
313
+ # THIS commit, shipping it to npm with no CHANGELOG record of its own
314
+ # (witnessed: P164's fix rode P374's changeset). Deny only on positive
315
+ # evidence the covering changeset(s) belong to a DIFFERENT change: the
316
+ # commit cites work-item ID(s), EVERY covering changeset cites work-item
317
+ # ID(s), and none overlap. Any ambiguity allows — a ticket-less commit,
318
+ # a prose-only changeset, or an ID overlap — so the ADR-014 batch-grain
319
+ # (same-slice commits share a ticket) and prose-only / adopter changesets
320
+ # are never over-fired.
321
+ local commit_ids cs_path cs_ids id has_idless=0 overlap=0
322
+ commit_ids=$(_work_item_ids "$commit_msg")
323
+
324
+ # Commit cites no work-item ID → cannot change-scope; allow (pre-P387).
325
+ [ -n "$commit_ids" ] || return 0
326
+
327
+ while IFS= read -r cs_path; do
328
+ [ -n "$cs_path" ] || continue
329
+ cs_ids=$(_work_item_ids "${cs_path}
330
+ $(cat "$cs_path" 2>/dev/null)")
331
+ if [ -z "$cs_ids" ]; then
332
+ has_idless=1
333
+ continue
334
+ fi
335
+ for id in $commit_ids; do
336
+ case " $cs_ids " in
337
+ *" $id "*) overlap=1; break ;;
338
+ esac
339
+ done
340
+ [ "$overlap" -eq 1 ] && break
341
+ done <<EOF
342
+ $covering
343
+ EOF
344
+
345
+ # Overlapping ID (same change) or a prose-only covering changeset
346
+ # (cannot prove it is for a different change) → allow.
347
+ if [ "$overlap" -eq 1 ] || [ "$has_idless" -eq 1 ]; then
348
+ return 0
349
+ fi
350
+ # Else: every covering changeset cites work-item ID(s), none matching
351
+ # the commit → unrelated-sibling signature → fall through to deny.
279
352
  fi
280
353
 
281
354
  printf '%s\n' "$plugin_source_slug"
@@ -574,3 +574,87 @@ mark_origin_at_head() {
574
574
  [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
575
575
  [[ "$output" == *"P141"* ]]
576
576
  }
577
+
578
+ # --- P387: Check 2b is change-scoped, not merely plugin-scoped ---
579
+ #
580
+ # P141 Phase 2's Check 2b passed a plugin-source commit if ANY in-scope
581
+ # changeset targeted the plugin — even one authored for a DIFFERENT change.
582
+ # So a change shipped to npm with no CHANGELOG record of its own, riding a
583
+ # sibling changeset's coattails (witnessed: P164's octal fix shipped
584
+ # undocumented under P374's changeset, @windyroad/risk-scorer 0.13.5/0.14.0).
585
+ #
586
+ # P387 tightens Check 2b to change-scoped via conservative work-item-ID
587
+ # (ticket) keying. The commit's work-item IDs (P<NNN> / RFC-<NNN> /
588
+ # STORY-<NNN>, extracted from the git-commit COMMAND string passed into the
589
+ # helper) are compared against each in-scope covering changeset's work-item
590
+ # IDs (extracted from its filename + body). Check 2b DENIES only on positive
591
+ # evidence of an unrelated sibling: the commit cites work-item ID(s), EVERY
592
+ # covering changeset cites work-item ID(s), and none overlap. Any ambiguity
593
+ # allows — a ticket-less commit, a prose-only changeset, or an ID overlap.
594
+ # This preserves the ADR-014 batch-grain (same-slice commits share a ticket,
595
+ # so the slice's changeset still covers them) and never over-fires on
596
+ # adopter/prose-only changesets that carry no ticket ref.
597
+
598
+ @test "P387 deny: plugin-source commit citing a DIFFERENT ticket than the only in-scope changeset (unrelated sibling) denies" {
599
+ mark_origin_at_head
600
+ # Commit 1: P374's changeset + its source.
601
+ echo "p374 work" > packages/itil/skills/foo/SKILL.md
602
+ printf -- '---\n"@windyroad/itil": patch\n---\nP374 work. Refs: P374.\n' > .changeset/wr-itil-p374.md
603
+ git add packages/itil/skills/foo/SKILL.md .changeset/wr-itil-p374.md
604
+ git -c commit.gpgsign=false commit --quiet -m "fix(itil): P374 work (P374)"
605
+ # Commit 2: P164's UNRELATED fix — stages itil source, authors NO changeset.
606
+ # The only in-scope changeset is P374's. Change-scoped Check 2b must DENY.
607
+ echo "octal fix" > packages/itil/scripts/extract.sh
608
+ git add packages/itil/scripts/extract.sh
609
+ run run_bash_hook "git commit -m 'fix(itil): octal eval (P164)'"
610
+ [ "$status" -eq 0 ]
611
+ [[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
612
+ [[ "$output" == *"P141"* ]]
613
+ [[ "$output" == *"itil"* ]]
614
+ }
615
+
616
+ @test "P387 allow: plugin-source commit citing the SAME ticket as the in-scope changeset (same change) allows" {
617
+ mark_origin_at_head
618
+ # Commit 1: P347's changeset + slice-1 source.
619
+ echo "p347 slice 1" > packages/itil/skills/foo/SKILL.md
620
+ printf -- '---\n"@windyroad/itil": patch\n---\nP347 slice. Refs: P347.\n' > .changeset/wr-itil-p347.md
621
+ git add packages/itil/skills/foo/SKILL.md .changeset/wr-itil-p347.md
622
+ git -c commit.gpgsign=false commit --quiet -m "feat(itil): P347 slice 1 (P347)"
623
+ # Commit 2: same P347 slice — no new changeset; the in-range P347 changeset
624
+ # shares the commit's ticket, so the ADR-014 batch is preserved. Allow.
625
+ echo "p347 slice 2" > packages/itil/skills/foo/SKILL.md
626
+ git add packages/itil/skills/foo/SKILL.md
627
+ run run_bash_hook "git commit -m 'feat(itil): P347 slice 2 (P347)'"
628
+ [ "$status" -eq 0 ]
629
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
630
+ [ "${#output}" -eq 0 ]
631
+ }
632
+
633
+ @test "P387 allow: plugin-source commit with NO work-item ID falls back to plugin-scoped allow (conservative)" {
634
+ mark_origin_at_head
635
+ # In-range changeset cites P374; the new commit cites no ticket at all —
636
+ # we cannot prove it is a DIFFERENT change, so we must not over-fire.
637
+ printf -- '---\n"@windyroad/itil": patch\n---\nsome fix. Refs: P374.\n' > .changeset/wr-itil-p374.md
638
+ git add .changeset/wr-itil-p374.md
639
+ git -c commit.gpgsign=false commit --quiet -m "chore: add changeset"
640
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
641
+ git add packages/itil/skills/foo/SKILL.md
642
+ run run_bash_hook "git commit -m 'feat without a ticket ref'"
643
+ [ "$status" -eq 0 ]
644
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
645
+ }
646
+
647
+ @test "P387 allow: prose-only in-scope changeset (no work-item ID) does not over-fire even when the commit cites a ticket" {
648
+ mark_origin_at_head
649
+ # An adopter (or a plain) changeset with no ticket ref in filename or body.
650
+ # The commit cites P164, but we cannot prove the prose-only changeset is for
651
+ # a different change, so Check 2b allows (no over-fire on prose changesets).
652
+ printf -- '---\n"@windyroad/itil": patch\n---\nfix the thing\n' > .changeset/wr-itil-feature.md
653
+ git add .changeset/wr-itil-feature.md
654
+ git -c commit.gpgsign=false commit --quiet -m "chore: add changeset"
655
+ echo "skill body" > packages/itil/skills/foo/SKILL.md
656
+ git add packages/itil/skills/foo/SKILL.md
657
+ run run_bash_hook "git commit -m 'fix(itil): a thing (P164)'"
658
+ [ "$status" -eq 0 ]
659
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
660
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.54.2",
3
+ "version": "0.54.3-preview.792",
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"