@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.
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
179
|
-
# 1 — change required + no covering
|
|
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
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
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