@windyroad/architect 0.17.1 → 0.17.3-preview.746
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/architect-compendium-update-entry.sh +33 -1
- package/hooks/architect-readme-pairing-check.sh +13 -14
- package/hooks/lib/command-detect.sh +126 -0
- package/hooks/test/architect-compendium-update-entry.bats +58 -0
- package/hooks/test/architect-readme-pairing-check.bats +36 -0
- package/package.json +1 -1
|
@@ -169,10 +169,22 @@ if [ -z "$new_entry" ] || ! printf '%s' "$new_entry" | grep -qE '^### ADR-[0-9]+
|
|
|
169
169
|
exit 0
|
|
170
170
|
fi
|
|
171
171
|
|
|
172
|
+
# --- Capture pre-modification invariants for the fail-closed guard (P367) ---
|
|
173
|
+
# A single-entry re-author must change ONLY the edited ADR's entry. Snapshot the
|
|
174
|
+
# set of all ADR ids and the count of `## ` section headers, plus a backup of
|
|
175
|
+
# the whole file, so the post-condition guard below can detect (and reject) any
|
|
176
|
+
# silent tail truncation or spurious-id/section injection from the subprocess.
|
|
177
|
+
before_ids=$(grep -oE '^### ADR-[0-9]+' "$readme" | grep -oE '[0-9]+' | sed 's/^0*//' | sort -n -u)
|
|
178
|
+
before_sections=$(grep -cE '^## ' "$readme")
|
|
179
|
+
entry_existed=0
|
|
180
|
+
[ -n "$current_entry" ] && entry_existed=1
|
|
181
|
+
|
|
172
182
|
# --- Apply the entry: delete any existing block, then insert sorted ---------
|
|
173
183
|
tmp_entry=$(mktemp -t architect-entry.XXXXXX)
|
|
174
184
|
tmp_readme=$(mktemp -t architect-readme.XXXXXX)
|
|
175
|
-
|
|
185
|
+
backup_readme=$(mktemp -t architect-readme-orig.XXXXXX)
|
|
186
|
+
trap 'rm -f "$tmp_entry" "$tmp_readme" "$backup_readme"' EXIT
|
|
187
|
+
cp "$readme" "$backup_readme"
|
|
176
188
|
printf '%s\n' "$new_entry" > "$tmp_entry"
|
|
177
189
|
|
|
178
190
|
# Pass 1 — remove any existing block for this ADR-ID (and the single blank line
|
|
@@ -218,6 +230,26 @@ awk -v id="$adr_id" -v section="$target_section" -v entryfile="$tmp_entry" '
|
|
|
218
230
|
END { if (!done) { print ""; print entry } }
|
|
219
231
|
' "$tmp_readme" > "$readme"
|
|
220
232
|
|
|
233
|
+
# --- Fail-closed post-condition guard (P367, ADR-078 criterion l) -----------
|
|
234
|
+
# The rewrite must preserve every OTHER ADR's entry and the section structure;
|
|
235
|
+
# only the edited ADR's entry may change (it may be newly added). If the result
|
|
236
|
+
# dropped a pre-existing entry (silent tail truncation) or injected spurious ids
|
|
237
|
+
# or sections (malformed subprocess emit), restore the original and degrade —
|
|
238
|
+
# never stage a corrupted compendium. Same contract as the subprocess-failure
|
|
239
|
+
# path: exit 0, do not block the body edit; Story B's pairing check surfaces it.
|
|
240
|
+
after_ids=$(grep -oE '^### ADR-[0-9]+' "$readme" | grep -oE '[0-9]+' | sed 's/^0*//' | sort -n -u)
|
|
241
|
+
after_sections=$(grep -cE '^## ' "$readme")
|
|
242
|
+
expected_ids="$before_ids"
|
|
243
|
+
if [ "$entry_existed" -eq 0 ]; then
|
|
244
|
+
expected_ids=$(printf '%s\n%s\n' "$before_ids" "$adr_id" | sed '/^$/d' | sort -n -u)
|
|
245
|
+
fi
|
|
246
|
+
edited_count=$(grep -oE '^### ADR-[0-9]+' "$readme" | grep -oE '[0-9]+' | sed 's/^0*//' | grep -cxF "$adr_id")
|
|
247
|
+
if [ "$after_ids" != "$expected_ids" ] || [ "$after_sections" != "$before_sections" ] || [ "$edited_count" -ne 1 ]; then
|
|
248
|
+
cp "$backup_readme" "$readme"
|
|
249
|
+
echo "architect-compendium-update-entry: post-condition guard tripped for ADR-${adr_id} (compendium entry-set or section drift — possible truncation or spurious injection); restored README unchanged (degraded mode), not staged. Recover with wr-architect-generate-decisions-compendium && git add docs/decisions/README.md" >&2
|
|
250
|
+
exit 0
|
|
251
|
+
fi
|
|
252
|
+
|
|
221
253
|
# Stage the compendium so it lands in the same commit as the ADR body change.
|
|
222
254
|
( cd "$project_dir" && git add docs/decisions/README.md 2>/dev/null ) || \
|
|
223
255
|
echo "architect-compendium-update-entry: git add docs/decisions/README.md failed (not a git repo or staging error) — stage it manually before commit" >&2
|
|
@@ -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.
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
|
@@ -234,6 +234,64 @@ run_hook() {
|
|
|
234
234
|
[ "$status" -eq 0 ]
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
@test "fail-closed guard: rejects a subprocess entry that injects spurious ADR ids/sections — restores README, degraded, unstaged (P367)" {
|
|
238
|
+
mk_readme
|
|
239
|
+
( cd "$PROJ" && git add -A && git commit -q -m init )
|
|
240
|
+
before=$(cat "$PROJ/docs/decisions/README.md")
|
|
241
|
+
fp=$(mk_adr "049" "accepted" "FortyNine")
|
|
242
|
+
# Malformed shim: valid header for the edited id, but ALSO injects an
|
|
243
|
+
# unrelated ADR-999 header and a spurious '## ' section — the additive
|
|
244
|
+
# corruption shape empirically reproduced for P367.
|
|
245
|
+
BADDIR="$(mktemp -d)"
|
|
246
|
+
cat > "$BADDIR/claude" <<'SHIM'
|
|
247
|
+
#!/usr/bin/env bash
|
|
248
|
+
cat >/dev/null
|
|
249
|
+
entry="### ADR-049 — Hijacked
|
|
250
|
+
**Status:** accepted | **Oversight:** confirmed
|
|
251
|
+
**Decides:** body.
|
|
252
|
+
|
|
253
|
+
## Injected section
|
|
254
|
+
|
|
255
|
+
### ADR-999 — Sneaky"
|
|
256
|
+
jq -cn --arg r "$entry" '{result:$r}'
|
|
257
|
+
SHIM
|
|
258
|
+
chmod +x "$BADDIR/claude"
|
|
259
|
+
export PATH="$BADDIR:$ORIG_PATH"
|
|
260
|
+
run run_hook "$fp"
|
|
261
|
+
[ "$status" -eq 0 ] # never blocks the body edit
|
|
262
|
+
[ "$before" = "$(cat "$PROJ/docs/decisions/README.md")" ] # restored unchanged
|
|
263
|
+
! grep -q 'ADR-999' "$PROJ/docs/decisions/README.md" # no injected id survives
|
|
264
|
+
[[ "$output" == *"guard"* ]] # observable degraded signal
|
|
265
|
+
# README left in its committed state — no corrupted blob staged.
|
|
266
|
+
( cd "$PROJ" && git diff --cached --quiet -- docs/decisions/README.md )
|
|
267
|
+
rm -rf "$BADDIR"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@test "fail-closed guard: rejects an emit for the wrong ADR id (edited id absent) — restores, degraded (P367)" {
|
|
271
|
+
mk_readme
|
|
272
|
+
before=$(cat "$PROJ/docs/decisions/README.md")
|
|
273
|
+
fp=$(mk_adr "050" "proposed" "Fifty") # NEW adr — not yet in the compendium
|
|
274
|
+
# Shim emits an entry for the WRONG id (049, which already exists) instead of
|
|
275
|
+
# the edited 050: the edited id never lands and 049 is duplicated.
|
|
276
|
+
WRONGDIR="$(mktemp -d)"
|
|
277
|
+
cat > "$WRONGDIR/claude" <<'SHIM'
|
|
278
|
+
#!/usr/bin/env bash
|
|
279
|
+
cat >/dev/null
|
|
280
|
+
entry="### ADR-049 — WrongId
|
|
281
|
+
**Status:** proposed | **Oversight:** confirmed
|
|
282
|
+
**Decides:** body."
|
|
283
|
+
jq -cn --arg r "$entry" '{result:$r}'
|
|
284
|
+
SHIM
|
|
285
|
+
chmod +x "$WRONGDIR/claude"
|
|
286
|
+
export PATH="$WRONGDIR:$ORIG_PATH"
|
|
287
|
+
run run_hook "$fp"
|
|
288
|
+
[ "$status" -eq 0 ]
|
|
289
|
+
[ "$before" = "$(cat "$PROJ/docs/decisions/README.md")" ] # restored unchanged
|
|
290
|
+
! grep -q '^### ADR-050' "$PROJ/docs/decisions/README.md" # edited id never landed
|
|
291
|
+
[[ "$output" == *"guard"* ]]
|
|
292
|
+
rm -rf "$WRONGDIR"
|
|
293
|
+
}
|
|
294
|
+
|
|
237
295
|
@test "registered in hooks.json on PostToolUse Edit|Write (criterion 9)" {
|
|
238
296
|
HOOKS_JSON="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/hooks.json"
|
|
239
297
|
run jq -e '.hooks.PostToolUse[] | select(.matcher | test("Edit")) | .hooks[] | select(.command | test("architect-compendium-update-entry"))' "$HOOKS_JSON"
|
|
@@ -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"
|