@windyroad/itil 0.35.4 → 0.35.6-preview.363
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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/itil-changeset-discipline.sh +25 -9
- package/hooks/itil-readme-refresh-discipline.sh +17 -9
- package/hooks/lib/command-detect.sh +126 -0
- package/hooks/test/command-detect.bats +181 -0
- package/hooks/test/itil-changeset-discipline.bats +109 -0
- package/hooks/test/itil-readme-refresh-discipline.bats +107 -0
- package/package.json +1 -1
|
@@ -13,10 +13,19 @@
|
|
|
13
13
|
# shape (no skill wrapper required — authoring a changeset is a
|
|
14
14
|
# single command).
|
|
15
15
|
#
|
|
16
|
+
# Command-shape detection delegates to
|
|
17
|
+
# `lib/command-detect.sh::command_invokes_git_commit`, which strips
|
|
18
|
+
# common prefix shapes (leading whitespace, env-var assignments,
|
|
19
|
+
# `cd <path> &&`) and checks whether the residual leading token pair
|
|
20
|
+
# is literally `git commit`. P272: replaced the prior substring match
|
|
21
|
+
# `*"git commit"*` that misfired on non-commit Bash whose argument
|
|
22
|
+
# vectors merely mentioned the phrase (grep / sed / cat-heredoc /
|
|
23
|
+
# echo / `git log --grep`).
|
|
24
|
+
#
|
|
16
25
|
# Allow paths (exit 0 silently per ADR-045 Pattern 1):
|
|
17
26
|
# - tool_name != "Bash" (only Bash invocations are gated)
|
|
18
|
-
# - command
|
|
19
|
-
#
|
|
27
|
+
# - command is not a `git commit` invocation by leading-executable
|
|
28
|
+
# semantics (helper returns 1)
|
|
20
29
|
# - staged set is changeset-clean (helper returns 0)
|
|
21
30
|
# - BYPASS_CHANGESET_GATE=1 env (helper returns 0 first)
|
|
22
31
|
# - outside a git work tree (helper fails-open)
|
|
@@ -41,10 +50,15 @@
|
|
|
41
50
|
# `.changeset/*.md`); composes-with as defence-in-depth.
|
|
42
51
|
# P125 — sibling staging-trap hook (same enforcement-layer shape).
|
|
43
52
|
# P141 — this hook.
|
|
53
|
+
# P268 — shared `command_invokes_git_commit` helper landed for
|
|
54
|
+
# `itil-readme-refresh-discipline.sh`; consumed here.
|
|
55
|
+
# P272 — sibling-hook refactor: substring-match → helper here.
|
|
44
56
|
|
|
45
57
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
46
58
|
# shellcheck source=lib/changeset-detect.sh
|
|
47
59
|
source "$SCRIPT_DIR/lib/changeset-detect.sh"
|
|
60
|
+
# shellcheck source=lib/command-detect.sh
|
|
61
|
+
source "$SCRIPT_DIR/lib/command-detect.sh"
|
|
48
62
|
|
|
49
63
|
INPUT=$(cat)
|
|
50
64
|
|
|
@@ -71,13 +85,15 @@ except:
|
|
|
71
85
|
print('')
|
|
72
86
|
" 2>/dev/null || echo "")
|
|
73
87
|
|
|
74
|
-
# Only fire on `git commit` invocations.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
# Only fire on actual `git commit` invocations. Delegates to
|
|
89
|
+
# `lib/command-detect.sh::command_invokes_git_commit`, which strips
|
|
90
|
+
# common prefix shapes (leading whitespace, env-var assignments,
|
|
91
|
+
# `cd <path> &&`) and checks whether the residual leading token pair
|
|
92
|
+
# is literally `git commit`. P272: replaced the prior substring match
|
|
93
|
+
# `*"git commit"*` that misfired on non-commit Bash whose argument
|
|
94
|
+
# vectors merely mentioned the phrase (grep / sed / cat-heredoc /
|
|
95
|
+
# echo / `git log --grep`).
|
|
96
|
+
command_invokes_git_commit "$COMMAND" || exit 0
|
|
81
97
|
|
|
82
98
|
# Run detection. Helper echoes offending plugin slug on stdout when
|
|
83
99
|
# detected; returns 1 in that case. Returns 0 (allow) on no-trap,
|
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
#
|
|
20
20
|
# Allow paths (exit 0 silently per ADR-045 Pattern 1):
|
|
21
21
|
# - tool_name != "Bash" (only Bash invocations are gated)
|
|
22
|
-
# - command does not
|
|
23
|
-
#
|
|
22
|
+
# - command does not invoke (P268: leading-executable check
|
|
23
|
+
# `git commit` as its leading- via `lib/command-detect.sh`
|
|
24
|
+
# effective command — replaces prior substring match
|
|
25
|
+
# that misfired on grep/sed/cat
|
|
26
|
+
# whose arguments contained the
|
|
27
|
+
# literal phrase)
|
|
24
28
|
# - staged set is README-discipline- (helper returns 0)
|
|
25
29
|
# clean
|
|
26
30
|
# - BYPASS_README_REFRESH_GATE=1 env (helper returns 0 first)
|
|
@@ -44,10 +48,13 @@
|
|
|
44
48
|
# P125 — sibling staging-trap hook (same enforcement-layer shape).
|
|
45
49
|
# P141 — sibling changeset-discipline hook (same shape).
|
|
46
50
|
# P165 — this hook.
|
|
51
|
+
# P268 — leading-executable-token command-detect helper.
|
|
47
52
|
|
|
48
53
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
49
54
|
# shellcheck source=lib/readme-refresh-detect.sh
|
|
50
55
|
source "$SCRIPT_DIR/lib/readme-refresh-detect.sh"
|
|
56
|
+
# shellcheck source=lib/command-detect.sh
|
|
57
|
+
source "$SCRIPT_DIR/lib/command-detect.sh"
|
|
51
58
|
|
|
52
59
|
INPUT=$(cat)
|
|
53
60
|
|
|
@@ -74,13 +81,14 @@ except:
|
|
|
74
81
|
print('')
|
|
75
82
|
" 2>/dev/null || echo "")
|
|
76
83
|
|
|
77
|
-
# Only fire on `git commit` invocations.
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
# Only fire on actual `git commit` invocations. Delegates to
|
|
85
|
+
# `lib/command-detect.sh::command_invokes_git_commit`, which strips
|
|
86
|
+
# common prefix shapes (leading whitespace, env-var assignments,
|
|
87
|
+
# `cd <path> &&`) and checks whether the residual leading token pair
|
|
88
|
+
# is literally `git commit`. P268: replaced the prior substring match
|
|
89
|
+
# `*"git commit"*` that misfired on non-commit Bash whose argument
|
|
90
|
+
# vectors merely mentioned the phrase (grep/sed/cat-heredoc/echo).
|
|
91
|
+
command_invokes_git_commit "$COMMAND" || exit 0
|
|
84
92
|
|
|
85
93
|
# Run detection. Helper echoes offending ticket path on stdout when
|
|
86
94
|
# detected; returns 1 in that case. Returns 0 (allow) on no-trap,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# P268: shared command-detection helper for PreToolUse:Bash hooks
|
|
3
|
+
# that need to distinguish ACTUAL `git commit` invocations from Bash
|
|
4
|
+
# commands that merely MENTION the literal phrase "git commit" in
|
|
5
|
+
# argument vectors or heredoc bodies (grep patterns, sed patterns,
|
|
6
|
+
# echo strings, cat heredocs, `git log --grep` queries, etc.).
|
|
7
|
+
#
|
|
8
|
+
# Replaces the case-statement substring match
|
|
9
|
+
#
|
|
10
|
+
# case "$COMMAND" in
|
|
11
|
+
# *"git commit"*) ;;
|
|
12
|
+
# *) exit 0 ;;
|
|
13
|
+
# esac
|
|
14
|
+
#
|
|
15
|
+
# that 5 sibling PreToolUse:Bash hooks previously used to gate on
|
|
16
|
+
# `git commit`. The substring match misfired on legitimate non-commit
|
|
17
|
+
# Bash whose arguments contained the phrase (P268 ticket Description
|
|
18
|
+
# — observed iter-1 retro write and orchestrator grep, ≥3 events per
|
|
19
|
+
# session).
|
|
20
|
+
#
|
|
21
|
+
# Strategy (Fix shape B per P268 ticket):
|
|
22
|
+
# 1. Strip leading whitespace from the candidate command string.
|
|
23
|
+
# 2. Iteratively strip env-var-assignment prefixes (`VAR=value `,
|
|
24
|
+
# `VAR="..." `, `VAR='...' `) and a `cd <path> &&` prefix until
|
|
25
|
+
# stable (order-independent — both shapes can interleave).
|
|
26
|
+
# 3. Check whether the residual leading token pair is literally
|
|
27
|
+
# `git[whitespace]commit` followed by whitespace or end-of-
|
|
28
|
+
# string. The end-of-token boundary check stops `git commit-tree`
|
|
29
|
+
# and similar `git commit-*` plumbing commands from matching.
|
|
30
|
+
#
|
|
31
|
+
# Scope deliberately narrow (per P268 ticket Description recommended
|
|
32
|
+
# Fix shape B): handles only the prefix shapes the orchestrator and
|
|
33
|
+
# capture/manage/work skills actually emit — direct `git commit`,
|
|
34
|
+
# `cd && git commit`, `VAR=value git commit`. Does NOT split on
|
|
35
|
+
# `&&`/`||`/`;`/`|` mid-chain — so `git add foo && git commit` reads
|
|
36
|
+
# as `git add` leading and the helper returns 1 (the gate silently
|
|
37
|
+
# passes). That is a documented and acceptable false-negative: a
|
|
38
|
+
# stand-alone re-run of `git commit` re-triggers detection. False
|
|
39
|
+
# positives — the case this fix exists to close — were causing the
|
|
40
|
+
# orchestrator to need manual workaround (stage README first, then
|
|
41
|
+
# run the offending non-commit command) 3+ times per session.
|
|
42
|
+
#
|
|
43
|
+
# Pure exit-code contract — helper never writes to stdout or stderr.
|
|
44
|
+
# Callers that need to name the trigger surface in deny messages
|
|
45
|
+
# should do so from their own delegated-detect helpers (e.g.
|
|
46
|
+
# `lib/readme-refresh-detect.sh` echoes the offending ticket path).
|
|
47
|
+
#
|
|
48
|
+
# References:
|
|
49
|
+
# ADR-005 — plugin testing strategy (this helper's bats live at
|
|
50
|
+
# `packages/itil/hooks/test/command-detect.bats` per
|
|
51
|
+
# behavioural-test discipline).
|
|
52
|
+
# ADR-009 — gate marker lifecycle (helper is per-invocation, no
|
|
53
|
+
# marker state — same precedent as `lib/staging-detect.sh`,
|
|
54
|
+
# `lib/changeset-detect.sh`, `lib/readme-refresh-detect.sh`).
|
|
55
|
+
# ADR-045 — hook injection budget (silent-on-pass / silent-on-fail
|
|
56
|
+
# — pure exit-code contract).
|
|
57
|
+
# P125 — sibling staging-trap hook (candidate for follow-up
|
|
58
|
+
# refactor to use this helper).
|
|
59
|
+
# P141 — sibling changeset-discipline hook (candidate).
|
|
60
|
+
# P165 — sibling README-refresh-discipline hook (first consumer
|
|
61
|
+
# under P268).
|
|
62
|
+
# P268 — this helper's surfacing ticket.
|
|
63
|
+
|
|
64
|
+
# Returns 0 if $1 is a Bash command that invokes `git commit`.
|
|
65
|
+
# Returns 1 otherwise (including commands that merely mention the
|
|
66
|
+
# phrase in their argument vectors or heredoc bodies, and commands
|
|
67
|
+
# whose leading-effective token is some other git subcommand).
|
|
68
|
+
command_invokes_git_commit() {
|
|
69
|
+
local cmd="$1"
|
|
70
|
+
|
|
71
|
+
# Strip leading whitespace (spaces, tabs, newlines).
|
|
72
|
+
if [[ "$cmd" =~ ^[[:space:]]+ ]]; then
|
|
73
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# Iteratively strip env-var-assignment prefixes and `cd <path> &&`
|
|
77
|
+
# prefixes until stable. The order is unconstrained: both shapes
|
|
78
|
+
# can appear in either sequence (`VAR=1 cd /tmp && git commit` or
|
|
79
|
+
# `cd /tmp && VAR=1 git commit`).
|
|
80
|
+
local prev=""
|
|
81
|
+
while [ "$cmd" != "$prev" ]; do
|
|
82
|
+
prev="$cmd"
|
|
83
|
+
|
|
84
|
+
# Env-var assignment with double-quoted value: VAR="..." then ws.
|
|
85
|
+
if [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=\"[^\"]*\"[[:space:]]+ ]]; then
|
|
86
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
87
|
+
continue
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Env-var assignment with single-quoted value: VAR='...' then ws.
|
|
91
|
+
if [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=\'[^\']*\'[[:space:]]+ ]]; then
|
|
92
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
93
|
+
continue
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# Env-var assignment with unquoted value: VAR=word then ws.
|
|
97
|
+
# `word` here is any sequence of non-whitespace characters.
|
|
98
|
+
if [[ "$cmd" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+ ]]; then
|
|
99
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
100
|
+
continue
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# `cd <path> &&` prefix. Path token is double-quoted, single-
|
|
104
|
+
# quoted, or unquoted (in which case it cannot itself contain
|
|
105
|
+
# whitespace or `&`).
|
|
106
|
+
if [[ "$cmd" =~ ^cd[[:space:]]+\"[^\"]*\"[[:space:]]*\&\&[[:space:]]+ ]]; then
|
|
107
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
108
|
+
continue
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
if [[ "$cmd" =~ ^cd[[:space:]]+\'[^\']*\'[[:space:]]*\&\&[[:space:]]+ ]]; then
|
|
112
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
113
|
+
continue
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
if [[ "$cmd" =~ ^cd[[:space:]]+[^[:space:]\&]+[[:space:]]*\&\&[[:space:]]+ ]]; then
|
|
117
|
+
cmd="${cmd#"${BASH_REMATCH[0]}"}"
|
|
118
|
+
continue
|
|
119
|
+
fi
|
|
120
|
+
done
|
|
121
|
+
|
|
122
|
+
# After prefix-strip, the leading two tokens must be literally
|
|
123
|
+
# `git` and `commit`, with whitespace or end-of-string after
|
|
124
|
+
# `commit` (so `git commit-tree` and similar do not match).
|
|
125
|
+
[[ "$cmd" =~ ^git[[:space:]]+commit([[:space:]]|$) ]]
|
|
126
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P268: lib/command-detect.sh — `command_invokes_git_commit` helper.
|
|
4
|
+
#
|
|
5
|
+
# Behavioural contract:
|
|
6
|
+
# `command_invokes_git_commit "$cmd"` returns 0 iff `$cmd`, when
|
|
7
|
+
# executed by bash, would actually invoke `git commit` as its
|
|
8
|
+
# leading-effective command (after stripping common prefix shapes:
|
|
9
|
+
# leading whitespace, env-var assignments, `cd <path> &&` prefix).
|
|
10
|
+
# Returns 1 for any other command, including commands whose
|
|
11
|
+
# argument vectors or heredoc bodies merely mention the literal
|
|
12
|
+
# string "git commit".
|
|
13
|
+
#
|
|
14
|
+
# Replaces the substring-match `case "$COMMAND" in *"git commit"*) ;;`
|
|
15
|
+
# pattern that 5 sibling PreToolUse:Bash hooks previously shared. The
|
|
16
|
+
# substring match misfired on `grep -n 'git commit' file.md`,
|
|
17
|
+
# `cat >> file <<EOF ... git commit ... EOF`, `echo "git commit ..."`,
|
|
18
|
+
# `sed -i 's/git commit/.../' file`, `git log --grep 'git commit'`,
|
|
19
|
+
# and similar non-commit Bash invocations whose arguments contained
|
|
20
|
+
# the literal phrase (P268 ticket Description).
|
|
21
|
+
#
|
|
22
|
+
# Per P081 (behavioural tests): tests assert helper return code against
|
|
23
|
+
# realistic command strings — NOT grep-against-source-content.
|
|
24
|
+
|
|
25
|
+
setup() {
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
27
|
+
HELPER="$SCRIPT_DIR/lib/command-detect.sh"
|
|
28
|
+
# shellcheck source=../lib/command-detect.sh
|
|
29
|
+
source "$HELPER"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# --- Positive cases (helper returns 0 — command IS a git commit) ---
|
|
33
|
+
|
|
34
|
+
@test "positive: bare git commit -m" {
|
|
35
|
+
run command_invokes_git_commit "git commit -m 'feat'"
|
|
36
|
+
[ "$status" -eq 0 ]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@test "positive: git commit --amend" {
|
|
40
|
+
run command_invokes_git_commit "git commit --amend"
|
|
41
|
+
[ "$status" -eq 0 ]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@test "positive: git commit with no args" {
|
|
45
|
+
run command_invokes_git_commit "git commit"
|
|
46
|
+
[ "$status" -eq 0 ]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@test "positive: leading whitespace before git commit" {
|
|
50
|
+
run command_invokes_git_commit " git commit -m 'feat'"
|
|
51
|
+
[ "$status" -eq 0 ]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@test "positive: env-var prefix BYPASS_X=1 git commit" {
|
|
55
|
+
run command_invokes_git_commit "BYPASS_README_REFRESH_GATE=1 git commit -m 'feat'"
|
|
56
|
+
[ "$status" -eq 0 ]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@test "positive: multiple env-var prefixes git commit" {
|
|
60
|
+
run command_invokes_git_commit "FOO=1 BAR=baz git commit -m 'feat'"
|
|
61
|
+
[ "$status" -eq 0 ]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@test "positive: env-var with double-quoted value git commit" {
|
|
65
|
+
run command_invokes_git_commit "GIT_AUTHOR_NAME=\"Test User\" git commit -m 'feat'"
|
|
66
|
+
[ "$status" -eq 0 ]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@test "positive: env-var with single-quoted value git commit" {
|
|
70
|
+
run command_invokes_git_commit "GIT_AUTHOR_NAME='Test User' git commit -m 'feat'"
|
|
71
|
+
[ "$status" -eq 0 ]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@test "positive: cd <path> && git commit" {
|
|
75
|
+
run command_invokes_git_commit "cd /tmp && git commit -m 'feat'"
|
|
76
|
+
[ "$status" -eq 0 ]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@test "positive: cd <quoted path> && git commit" {
|
|
80
|
+
run command_invokes_git_commit "cd \"/tmp/with spaces\" && git commit -m 'feat'"
|
|
81
|
+
[ "$status" -eq 0 ]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@test "positive: env-var then cd then git commit (combined prefixes)" {
|
|
85
|
+
run command_invokes_git_commit "BYPASS=1 cd /tmp && git commit -m 'feat'"
|
|
86
|
+
[ "$status" -eq 0 ]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@test "positive: cd then env-var then git commit (reversed prefix order)" {
|
|
90
|
+
run command_invokes_git_commit "cd /tmp && BYPASS=1 git commit -m 'feat'"
|
|
91
|
+
[ "$status" -eq 0 ]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@test "positive: tab-indented git commit" {
|
|
95
|
+
run command_invokes_git_commit $'\tgit commit -m feat'
|
|
96
|
+
[ "$status" -eq 0 ]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# --- Negative cases (helper returns 1 — command is NOT a git commit) ---
|
|
100
|
+
|
|
101
|
+
@test "negative: grep with 'git commit' as pattern argument" {
|
|
102
|
+
run command_invokes_git_commit "grep -n 'git commit' file.md"
|
|
103
|
+
[ "$status" -eq 1 ]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@test "negative: grep -rn 'git commit' (the recurring orchestrator surface)" {
|
|
107
|
+
run command_invokes_git_commit "grep -rn 'git commit' packages/"
|
|
108
|
+
[ "$status" -eq 1 ]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@test "negative: cat heredoc whose body contains the phrase git commit" {
|
|
112
|
+
run command_invokes_git_commit $'cat >> docs/problems/README-history.md <<EOF\nWe added a git commit gate.\nEOF'
|
|
113
|
+
[ "$status" -eq 1 ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@test "negative: echo string containing git commit" {
|
|
117
|
+
run command_invokes_git_commit "echo 'the git commit gate fires here'"
|
|
118
|
+
[ "$status" -eq 1 ]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@test "negative: sed substitution mentioning git commit" {
|
|
122
|
+
run command_invokes_git_commit "sed -i 's/git commit/git push/' file.md"
|
|
123
|
+
[ "$status" -eq 1 ]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@test "negative: git log --grep 'git commit'" {
|
|
127
|
+
run command_invokes_git_commit "git log --grep 'git commit'"
|
|
128
|
+
[ "$status" -eq 1 ]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@test "negative: git status (other git subcommand)" {
|
|
132
|
+
run command_invokes_git_commit "git status"
|
|
133
|
+
[ "$status" -eq 1 ]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@test "negative: git push (other git subcommand)" {
|
|
137
|
+
run command_invokes_git_commit "git push origin main"
|
|
138
|
+
[ "$status" -eq 1 ]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@test "negative: git commit-tree (plumbing — boundary check)" {
|
|
142
|
+
run command_invokes_git_commit "git commit-tree HEAD^{tree}"
|
|
143
|
+
[ "$status" -eq 1 ]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@test "negative: empty command" {
|
|
147
|
+
run command_invokes_git_commit ""
|
|
148
|
+
[ "$status" -eq 1 ]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@test "negative: whitespace-only command" {
|
|
152
|
+
run command_invokes_git_commit " "
|
|
153
|
+
[ "$status" -eq 1 ]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@test "negative: cat with single-quoted body containing git commit phrase" {
|
|
157
|
+
run command_invokes_git_commit "printf '%s\n' 'the git commit gate is here' > /tmp/out"
|
|
158
|
+
[ "$status" -eq 1 ]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@test "negative: a different command chained before git commit (e.g. git add foo && git commit) — leading is git add, helper passes" {
|
|
162
|
+
# Per Fix shape B narrow scope: helper only checks the leading-
|
|
163
|
+
# effective command after prefix-strip. Mid-chain `git commit`
|
|
164
|
+
# after a non-prefix-shape leading command (here `git add`) is a
|
|
165
|
+
# documented false negative — standalone re-commit would re-trigger
|
|
166
|
+
# detection. Acceptable per P268 ticket Description.
|
|
167
|
+
run command_invokes_git_commit "git add foo && git commit -m 'feat'"
|
|
168
|
+
[ "$status" -eq 1 ]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# --- Silence contract (ADR-045 Pattern 1) ---
|
|
172
|
+
|
|
173
|
+
@test "helper emits zero bytes on stdout (pure exit-code contract)" {
|
|
174
|
+
run command_invokes_git_commit "git commit -m 'feat'"
|
|
175
|
+
[ "${#output}" -eq 0 ]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@test "helper emits zero bytes on stdout for negative case too" {
|
|
179
|
+
run command_invokes_git_commit "grep -n 'git commit' file.md"
|
|
180
|
+
[ "${#output}" -eq 0 ]
|
|
181
|
+
}
|
|
@@ -245,3 +245,112 @@ run_bash_hook() {
|
|
|
245
245
|
[ "$status" -eq 0 ]
|
|
246
246
|
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
247
247
|
}
|
|
248
|
+
|
|
249
|
+
# --- P272: substring-vs-invocation regression coverage ---
|
|
250
|
+
#
|
|
251
|
+
# Prior to P272, the hook used `case "$COMMAND" in *"git commit"*) ;;`
|
|
252
|
+
# which fired on ANY Bash command whose text contained the literal
|
|
253
|
+
# phrase "git commit" — including grep patterns, sed substitutions,
|
|
254
|
+
# cat heredoc bodies, echo strings, and `git log --grep` queries.
|
|
255
|
+
# Workaround was stage-changeset-first-or-different-shell, observed
|
|
256
|
+
# ≥3 events per session in the P268 sibling-hook class.
|
|
257
|
+
# P272 replaces that match with a leading-executable-token check via
|
|
258
|
+
# `lib/command-detect.sh::command_invokes_git_commit` (the shared
|
|
259
|
+
# helper landed by P268). The tests below stage `@windyroad/itil/`
|
|
260
|
+
# source (which would trigger deny if the gate fired) and run various
|
|
261
|
+
# non-commit Bash commands whose argument vectors mention `git commit`.
|
|
262
|
+
# The hook MUST pass silently.
|
|
263
|
+
|
|
264
|
+
@test "P272 allow: grep with 'git commit' pattern does NOT trigger gate" {
|
|
265
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
266
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
267
|
+
run run_bash_hook "grep -n 'git commit' file.md"
|
|
268
|
+
[ "$status" -eq 0 ]
|
|
269
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
270
|
+
# Silent pass per ADR-045 Pattern 1.
|
|
271
|
+
[ "${#output}" -eq 0 ]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@test "P272 allow: grep -rn 'git commit' packages/ (the recurring orchestrator surface)" {
|
|
275
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
276
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
277
|
+
run run_bash_hook "grep -rn 'git commit' packages/"
|
|
278
|
+
[ "$status" -eq 0 ]
|
|
279
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
280
|
+
[ "${#output}" -eq 0 ]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@test "P272 allow: sed -i 's/git commit/.../' substitution does NOT trigger gate" {
|
|
284
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
285
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
286
|
+
run run_bash_hook "sed -i 's/git commit/git push/' file.md"
|
|
287
|
+
[ "$status" -eq 0 ]
|
|
288
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
289
|
+
[ "${#output}" -eq 0 ]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@test "P272 allow: echo with 'git commit' inside string does NOT trigger gate" {
|
|
293
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
294
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
295
|
+
run run_bash_hook "echo 'the git commit gate fires here'"
|
|
296
|
+
[ "$status" -eq 0 ]
|
|
297
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
298
|
+
[ "${#output}" -eq 0 ]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@test "P272 allow: git log --grep 'git commit' does NOT trigger gate (git log is leading)" {
|
|
302
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
303
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
304
|
+
run run_bash_hook "git log --grep 'git commit'"
|
|
305
|
+
[ "$status" -eq 0 ]
|
|
306
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
307
|
+
[ "${#output}" -eq 0 ]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@test "P272 allow: cat heredoc whose body contains 'git commit' does NOT trigger gate (retro-write surface)" {
|
|
311
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
312
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
313
|
+
# Inline-build JSON with embedded newlines via python3 to mimic the
|
|
314
|
+
# Bash tool's multi-line command payload (the canonical retro-write
|
|
315
|
+
# surface that misfired in the P268 sibling).
|
|
316
|
+
local payload
|
|
317
|
+
payload=$(python3 -c "import json,sys; print(json.dumps({'tool_name':'Bash','tool_input':{'command':'cat >> docs/problems/README-history.md <<EOF\nFlow note: the git commit gate fires here.\nEOF'}}))")
|
|
318
|
+
run bash -c "echo '$payload' | bash $HOOK"
|
|
319
|
+
[ "$status" -eq 0 ]
|
|
320
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
321
|
+
[ "${#output}" -eq 0 ]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@test "P272 allow: git commit-tree (boundary check — commit-tree is a different plumbing command)" {
|
|
325
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
326
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
327
|
+
run run_bash_hook "git commit-tree HEAD^{tree}"
|
|
328
|
+
[ "$status" -eq 0 ]
|
|
329
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
330
|
+
[ "${#output}" -eq 0 ]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
@test "P272 deny: actual git commit invocation with staged packages/<plugin>/ source still triggers gate (positive regression)" {
|
|
334
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
335
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
336
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
337
|
+
[ "$status" -eq 0 ]
|
|
338
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
339
|
+
[[ "$output" == *"P141"* ]]
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@test "P272 deny: cd <path> && git commit (prefix-strip path) still triggers gate" {
|
|
343
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
344
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
345
|
+
run run_bash_hook "cd . && git commit -m 'feat'"
|
|
346
|
+
[ "$status" -eq 0 ]
|
|
347
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@test "P272 deny: GIT_AUTHOR_NAME=Test git commit (env-prefix path) still triggers gate" {
|
|
351
|
+
echo "skill body" > packages/itil/skills/foo/SKILL.md
|
|
352
|
+
git add packages/itil/skills/foo/SKILL.md
|
|
353
|
+
run run_bash_hook "GIT_AUTHOR_NAME=Test git commit -m 'feat'"
|
|
354
|
+
[ "$status" -eq 0 ]
|
|
355
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
356
|
+
}
|
|
@@ -419,3 +419,110 @@ EOF
|
|
|
419
419
|
[[ "$output" == *".claude/settings.json"* ]]
|
|
420
420
|
[[ "$output" == *"P173"* ]]
|
|
421
421
|
}
|
|
422
|
+
|
|
423
|
+
# --- P268: substring-vs-invocation regression coverage ---
|
|
424
|
+
#
|
|
425
|
+
# Prior to P268, the hook used `case "$COMMAND" in *"git commit"*) ;;`
|
|
426
|
+
# which fired on ANY Bash command whose text contained the literal
|
|
427
|
+
# phrase "git commit" — including grep patterns, sed substitutions,
|
|
428
|
+
# cat heredoc bodies, echo strings, and `git log --grep` queries.
|
|
429
|
+
# Workaround was stage-README-first, observed ≥3 times per session.
|
|
430
|
+
# P268 replaces that match with a leading-executable-token check via
|
|
431
|
+
# `lib/command-detect.sh::command_invokes_git_commit`. The tests
|
|
432
|
+
# below stage a ticket file (which would trigger deny if the gate
|
|
433
|
+
# fired) and run various non-commit Bash commands whose argument
|
|
434
|
+
# vectors mention `git commit`. The hook MUST pass silently.
|
|
435
|
+
|
|
436
|
+
@test "P268 allow: grep with 'git commit' pattern does NOT trigger gate" {
|
|
437
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
438
|
+
git add docs/problems/open/999-x.md
|
|
439
|
+
run run_bash_hook "grep -n 'git commit' file.md"
|
|
440
|
+
[ "$status" -eq 0 ]
|
|
441
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
442
|
+
# Silent pass per ADR-045 Pattern 1.
|
|
443
|
+
[ "${#output}" -eq 0 ]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
@test "P268 allow: grep -rn 'git commit' packages/ (the recurring orchestrator surface)" {
|
|
447
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
448
|
+
git add docs/problems/open/999-x.md
|
|
449
|
+
run run_bash_hook "grep -rn 'git commit' packages/"
|
|
450
|
+
[ "$status" -eq 0 ]
|
|
451
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
452
|
+
[ "${#output}" -eq 0 ]
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
@test "P268 allow: sed -i 's/git commit/.../' substitution does NOT trigger gate" {
|
|
456
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
457
|
+
git add docs/problems/open/999-x.md
|
|
458
|
+
run run_bash_hook "sed -i 's/git commit/git push/' file.md"
|
|
459
|
+
[ "$status" -eq 0 ]
|
|
460
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
461
|
+
[ "${#output}" -eq 0 ]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@test "P268 allow: echo with 'git commit' inside string does NOT trigger gate" {
|
|
465
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
466
|
+
git add docs/problems/open/999-x.md
|
|
467
|
+
run run_bash_hook "echo 'the git commit gate fires here'"
|
|
468
|
+
[ "$status" -eq 0 ]
|
|
469
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
470
|
+
[ "${#output}" -eq 0 ]
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@test "P268 allow: git log --grep 'git commit' does NOT trigger gate (git log is leading)" {
|
|
474
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
475
|
+
git add docs/problems/open/999-x.md
|
|
476
|
+
run run_bash_hook "git log --grep 'git commit'"
|
|
477
|
+
[ "$status" -eq 0 ]
|
|
478
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
479
|
+
[ "${#output}" -eq 0 ]
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
@test "P268 allow: cat heredoc whose body contains 'git commit' does NOT trigger gate (iter-1 retro write surface)" {
|
|
483
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
484
|
+
git add docs/problems/open/999-x.md
|
|
485
|
+
# Inline-build JSON with embedded newlines via printf to mimic the
|
|
486
|
+
# Bash tool's multi-line command payload (the canonical retro-write
|
|
487
|
+
# surface that misfired in P268).
|
|
488
|
+
local payload
|
|
489
|
+
payload=$(python3 -c "import json,sys; print(json.dumps({'tool_name':'Bash','tool_input':{'command':'cat >> docs/problems/README-history.md <<EOF\nFlow note: the git commit gate fires here.\nEOF'}}))")
|
|
490
|
+
run bash -c "echo '$payload' | bash $HOOK"
|
|
491
|
+
[ "$status" -eq 0 ]
|
|
492
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
493
|
+
[ "${#output}" -eq 0 ]
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@test "P268 deny: actual git commit invocation with staged ticket still triggers gate (positive regression)" {
|
|
497
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
498
|
+
git add docs/problems/open/999-x.md
|
|
499
|
+
run run_bash_hook "git commit -m 'feat'"
|
|
500
|
+
[ "$status" -eq 0 ]
|
|
501
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
502
|
+
[[ "$output" == *"P165"* ]]
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@test "P268 deny: cd <path> && git commit (prefix-strip path) still triggers gate" {
|
|
506
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
507
|
+
git add docs/problems/open/999-x.md
|
|
508
|
+
run run_bash_hook "cd . && git commit -m 'feat'"
|
|
509
|
+
[ "$status" -eq 0 ]
|
|
510
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
@test "P268 deny: GIT_AUTHOR_NAME=Test git commit (env-prefix path) still triggers gate" {
|
|
514
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
515
|
+
git add docs/problems/open/999-x.md
|
|
516
|
+
run run_bash_hook "GIT_AUTHOR_NAME=Test git commit -m 'feat'"
|
|
517
|
+
[ "$status" -eq 0 ]
|
|
518
|
+
[[ "$output" == *"\"permissionDecision\": \"deny\""* ]]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
@test "P268 allow: git commit-tree (boundary check — commit-tree is a different plumbing command)" {
|
|
522
|
+
echo "# Problem 999" > docs/problems/open/999-x.md
|
|
523
|
+
git add docs/problems/open/999-x.md
|
|
524
|
+
run run_bash_hook "git commit-tree HEAD^{tree}"
|
|
525
|
+
[ "$status" -eq 0 ]
|
|
526
|
+
[[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
|
|
527
|
+
[ "${#output}" -eq 0 ]
|
|
528
|
+
}
|
package/package.json
CHANGED