@windyroad/architect 0.17.1 → 0.17.2

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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-architect",
126
- "version": "0.17.1"
126
+ "version": "0.17.2"
127
127
  }
@@ -31,6 +31,10 @@
31
31
 
32
32
  set -uo pipefail
33
33
 
34
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
35
+ # shellcheck source=lib/command-detect.sh
36
+ source "$SCRIPT_DIR/lib/command-detect.sh"
37
+
34
38
  # PreToolUse input arrives on stdin as JSON.
35
39
  input=$(cat)
36
40
 
@@ -40,20 +44,15 @@ tool_name=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)
40
44
  command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)
41
45
  [ -n "$command" ] || exit 0
42
46
 
43
- # Only fire on `git commit` invocations. Leading-executable check (P268
44
- # pattern): a bare substring match would catch unrelated commands that mention
45
- # "git commit" (grep/sed/cat). Strip leading whitespace + env assignments, then
46
- # require the first effective tokens to be `git commit`.
47
- echo "$command" | awk '
48
- {
49
- sub(/^[[:space:]]+/, "")
50
- while ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/) {
51
- sub(/^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/, "")
52
- }
53
- if ($0 ~ /^git[[:space:]]+commit([[:space:]]|$)/) exit 0
54
- exit 1
55
- }
56
- ' || exit 0
47
+ # Only fire on `git commit` invocations. Delegates to the shared
48
+ # command_invokes_git_commit helper (P268 family; synced from
49
+ # packages/shared/hooks/lib/command-detect.sh per ADR-017) rather than
50
+ # re-implementing leading-token detection inline a bare substring match would
51
+ # catch unrelated commands that mention "git commit" (grep/sed/cat), and an
52
+ # inline awk re-implementation risks re-introducing the BSD/GNU portability bugs
53
+ # (P366 `\b`) and prefix-stripping gaps (`cd <path> &&`) the helper already
54
+ # solved and regression-tests.
55
+ command_invokes_git_commit "$command" || exit 0
57
56
 
58
57
  # Allow-list bypass token (parity with the retired refresh-discipline hook and
59
58
  # ADR-014 commit-message bypass shape).
@@ -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
+ }
@@ -83,6 +83,42 @@ run_commit_hook() {
83
83
  [ "$status" -eq 0 ]
84
84
  }
85
85
 
86
+ # --- P366 leading-token detection regression guards ---
87
+ # These exercise the shared command_invokes_git_commit helper that the hook
88
+ # now sources (replacing inline awk). Permit-path-only coverage is what let
89
+ # the original BSD-awk `\b` bug hide; these are the deny-path / mention-path
90
+ # guards the ticket asks for.
91
+
92
+ @test "denies 'cd <repo> && git commit' with an unpaired ADR (P366 cd-prefix)" {
93
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
94
+ git add docs/decisions/049-x.proposed.md
95
+ run run_commit_hook "cd $REPO && git commit -m wip"
96
+ [ "$status" -eq 2 ]
97
+ [[ "$output" == *"deny"* ]]
98
+ }
99
+
100
+ @test "denies 'VAR=1 git commit' with an unpaired ADR (P366 env-prefix)" {
101
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
102
+ git add docs/decisions/049-x.proposed.md
103
+ run run_commit_hook "GIT_AUTHOR_NAME=x git commit -m wip"
104
+ [ "$status" -eq 2 ]
105
+ [[ "$output" == *"deny"* ]]
106
+ }
107
+
108
+ @test "permits a command that merely MENTIONS 'git commit' as a substring (P366 mention-path)" {
109
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
110
+ git add docs/decisions/049-x.proposed.md
111
+ run run_commit_hook "grep -r 'git commit' docs/"
112
+ [ "$status" -eq 0 ]
113
+ }
114
+
115
+ @test "permits 'git commit-tree' plumbing (P366 token-boundary)" {
116
+ echo "# adr 049 edited" > docs/decisions/049-x.proposed.md
117
+ git add docs/decisions/049-x.proposed.md
118
+ run run_commit_hook "git commit-tree HEAD"
119
+ [ "$status" -eq 0 ]
120
+ }
121
+
86
122
  @test "registered in hooks.json as PreToolUse Bash (criterion 6)" {
87
123
  HOOKS_JSON="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/hooks.json"
88
124
  run jq -e '.hooks.PreToolUse[] | select(.matcher=="Bash") | .hooks[] | select(.command | test("architect-readme-pairing-check"))' "$HOOKS_JSON"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/architect",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "Architecture decision enforcement for AI coding agents",
5
5
  "bin": {
6
6
  "windyroad-architect": "./bin/install.mjs"