-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck-invariants.sh
More file actions
executable file
·570 lines (553 loc) · 33.6 KB
/
Copy pathcheck-invariants.sh
File metadata and controls
executable file
·570 lines (553 loc) · 33.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
#!/bin/sh
# Mechanical checks for the AGENTS.md invariants that a tool can decide.
#
# These checks exist because prose alone did not hold. Invariant 5 was written down
# and this repo's own CI still shipped `actions/checkout@v4` and `ubuntu-latest`;
# invariant 6 was believed to say the opposite of what it says, and the resulting
# duplicate-hooks manifest key stopped the plugin loading entirely (0.2.1). A rule a
# reader has to remember is worth less than one that fails the build.
#
# POSIX sh, no jq — this script's own requirement, because CI invokes it with `sh`.
# (NOT invariant 4: that one is about the hook running on machines we don't control.)
# Prints every offending line, not just the first.
# Regression suite: scripts/check-invariants.test.sh (run by the same CI step).
#
# Scope, stated so the gaps are known rather than assumed away: this is a
# line-oriented grep, not a YAML parser. It normalises quoting, which is the form
# that actually occurs, but a value written as a block scalar or split across lines
# would slip past. Locations are also recovered from grep's `file:line:` output, which
# assumes the filename has no colon in it. Both are accepted limits of a ~90-line
# checker — it raises the floor, it is not a proof.
#
# TESTED SPELLINGS ONLY: extend the fixtures before extending the regex.
#
# MUTATION RE-RUN PROCEDURE (manual; nothing automates it). The three prompt-conformance
# checks below are bracketed by `# --- BEGIN check 4a ---` / `# --- END check 4a ---`
# markers -- and likewise for 4b and 4c -- so a scratch copy can be neutered cleanly.
# Substitute the marker for each check in turn; the procedure is otherwise identical:
#
# TMP=$(mktemp -d) || exit 1
# [ -n "$TMP" ] && [ -d "$TMP" ] || exit 1 # else the copy below targets /repo
# trap 'rm -rf "$TMP"' EXIT HUP INT TERM
# mkdir -p "$TMP/repo"; tar cf - --exclude=.git . | (cd "$TMP/repo" && tar xf -)
# chk=4a # then 4b, then 4c
# sed "/BEGIN check $chk/,/END check $chk/d" scripts/check-invariants.sh \
# > "$TMP/repo/scripts/check-invariants.sh"
# sh scripts/check-invariants.test.sh > "$TMP/before" 2>&1; base=$?
# ( cd "$TMP/repo" && sh scripts/check-invariants.test.sh ) > "$TMP/after" 2>&1; mut=$?
# [ "$base" -eq 0 ] || { echo "VOID: baseline not green" >&2; exit 1; }
# [ "$mut" -ne 0 ] || { echo "VOID: check is not load-bearing" >&2; exit 1; }
# diff "$TMP/before" "$TMP/after" | grep '^> FAIL' | sed 's/^> FAIL - //; s/ (.*)$//' \
# > "$TMP/flipped"
# [ -s "$TMP/flipped" ] || { echo "VOID: nothing flipped" >&2; exit 1; }
# cat "$TMP/flipped"
#
# Each validity check EXITS rather than warning: a check that prints and continues lets
# a red baseline, a passing mutant or an empty flip set be recorded as evidence, which
# is the failure this procedure exists to prevent.
#
# The script's checks establish only that the baseline was green, the mutant failed, and
# something flipped. They do NOT establish the mutant failed for the right reason — a
# syntax error in the neutered copy would also flip cases. Comparing the flipped set
# against the fixture list, and confirming no accept case moved, is a HUMAN step and is
# mandatory. The recorded result lives in check-invariants.test.sh; re-run and update it
# when changing either marked check, its markers, its fixtures or assertion names, or
# the harness.
set -u
root=$(cd "$(dirname "$0")/.." && pwd)
cd "$root" || exit 1
rc=0
# `source-files/` is a frozen extraction archive that is never edited (AGENTS.md
# invariant 5, exception 1; MANIFEST.md). Scanning it would report violations that
# are deliberately preserved history.
scan() { grep -rn "$1" --include='*.yml' --include='*.yaml' --include='*.md' \
--include='*.json' --include='*.toml' . 2>/dev/null | grep -v '^\./source-files/'; }
# Same, but emits one output line per MATCH (-o) rather than per source line. Needed
# wherever a single line can carry several independent things to check: filtering
# whole lines would let one pinned package on a line clear an unpinned one beside it.
scan_each() { grep -rno "$1" --include='*.yml' --include='*.yaml' --include='*.md' \
--include='*.json' --include='*.toml' . 2>/dev/null | grep -v '^\./source-files/'; }
# YAML quotes its scalars optionally, so `uses: "actions/checkout@v4"` is the same
# step as the unquoted form. Strip the quotes around a uses:/runs-on: value before
# matching, or the check is trivially evaded by adding a quote.
# Two -e expressions rather than one `\(uses:\|runs-on:\)` alternation: BSD sed (still
# /usr/bin/sed on macOS) has no `\|` in a BRE, so the alternation silently matched
# nothing and every quoted ref fell through to the "no ref at all" branch — rejected,
# but for the wrong reason, which the accept-case test caught.
# `g` on both: without it only the FIRST quoted occurrence on a line was normalised,
# so a second valid quoted ref kept its quote and was rejected.
unquote() {
sed -e 's/uses:[[:space:]]*["'\'']\([^"'\'']*\)["'\'']/uses: \1/g' \
-e 's/runs-on:[[:space:]]*["'\'']\([^"'\'']*\)["'\'']/runs-on: \1/g'
}
# Drop the YAML comment from a `file:line:text` record. Prose ABOUT a floating
# dependency is not one. Two forms: a comment after whitespace, and a comment at
# column zero — which sits immediately after grep's `file:line:` prefix, so a
# whitespace-anchored pattern alone missed it.
strip_comment() { sed -e 's/[[:space:]]#.*$//' -e 's/^\(\.[^:]*:[0-9]*:\)[[:space:]]*#.*$/\1/'; }
# One argument, printed as a block. Passing the lines as "$@" would word-split them
# on spaces and mangle every message.
fail() { rc=1; printf '\n%s\n' "$1"; printf '%s\n' "$2" | sed 's/^/ /'; }
# --- Invariant 5: every version pinned exactly ------------------------------------
# An action ref must be a 40-char commit SHA. The ref token is extracted rather than
# the line stripped of comments: the scaffolded template shows a COMMENTED example
# step, so "delete from the first #" would blank the whole line and flag it.
# scan_each, not scan: one line can carry more than one `uses:`, and skipping or
# accepting a whole line on the strength of its FIRST ref let a floating action hide
# behind a pinned or local one beside it.
# Comments stripped first, for the same reason as the runner check and to match the
# documented rule: a ref MENTIONED in a trailing comment is prose, not a step.
# unquote runs BEFORE extraction: `grep -o` stops at the closing quote, so extracting
# first left a dangling `"` on the token and got valid quoted local and docker://
# actions rejected.
bad_uses=$(scan 'uses:' | strip_comment | unquote | while IFS= read -r rec; do
# Keep grep's `./file:NN:` prefix and re-attach it to each occurrence, so a CI
# failure still says WHERE. Extracting the tokens alone lost that.
#
# Re-attached with printf, never `sed "s|^|$loc|"`: a filename containing the sed
# delimiter (`a|b.yml`) made sed error out, and the checker then went on to print
# "ok" — a real violation passing a blocking gate, the one direction that must
# never happen.
loc=$(printf '%s' "$rec" | sed -n 's/^\(\.[^:]*:[0-9]*:\).*/\1/p')
printf '%s' "$rec" | grep -o 'uses:[[:space:]]*[^[:space:]]\{1,\}' |
while IFS= read -r tok; do printf '%s%s\n' "$loc" "$tok"; done
done |
while IFS= read -r occ; do
# Prefix/suffix stripping throughout instead of `case`: bash 3.2 (still /bin/sh on
# macOS) mis-parses a case pattern's `)` inside `$( )` as the closing paren of the
# substitution, which is a syntax error, not a subtle bug.
ref_part=${occ##*uses:}
ref_part=$(printf '%s' "$ref_part" | tr -d '[:space:]')
# A local composite action (`uses: ./.github/actions/x`) lives in this repo and is
# versioned by the commit under test — there is nothing to pin.
[ "${ref_part#./}" != "$ref_part" ] && continue
# A docker:// ref pins by tag or digest rather than by SHA, so it gets its own
# rule instead of a blanket exemption: `docker://alpine:latest`, and an untagged
# image (which resolves to :latest), float exactly like `@v4` does.
if [ "${ref_part#docker://}" != "$ref_part" ]; then
img=${ref_part#docker://}
# Digest-pinned — but only if the digest is real. Accepting any `@sha256:`
# suffix let `@sha256:abc123` and even a bare `@sha256:` read as pinned, which
# is the checker asserting something it had not actually checked.
if [ "${img#*@sha256:}" != "$img" ]; then
dig=${img##*@sha256:}
if [ "${#dig}" -eq 64 ] && [ -z "$(printf '%s' "$dig" | tr -d '0-9a-f')" ]; then
continue
fi
printf '%s\n' "$occ"; continue
fi
# Look for the tag in the FINAL path component only: a registry port
# (`reg:5000/img`) also contains a colon, and treating that as the tag read an
# untagged image as pinned.
last=${img##*/}
if [ "${last#*:}" = "$last" ]; then printf '%s\n' "$occ"; continue; fi # no tag
tag=${last##*:}
if [ -z "$tag" ] || [ "$tag" = latest ]; then printf '%s\n' "$occ"; fi
continue
fi
# Only owner/repo refs are actions; anything else on a uses: line is not ours.
[ "${ref_part#*/}" = "$ref_part" ] && continue
if [ "${ref_part#*@}" = "$ref_part" ]; then
printf '%s\n' "$occ"; continue # `uses: owner/repo` with no ref at all
fi
ref=${ref_part##*@}
# ONLY the literal `<sha>` placeholder the scaffolded template ships, and nothing
# else. Allowing any `<...>` would make `@<latest>` a general bypass.
[ "$ref" = "<sha>" ] && continue
# 40 lowercase hex characters, and nothing else.
if [ "${#ref}" -ne 40 ] || [ -n "$(printf '%s' "$ref" | tr -d '0-9a-f')" ]; then
printf '%s\n' "$occ"
fi
done)
[ -n "$bad_uses" ] && fail "Invariant 5: action ref not pinned to a 40-char commit SHA." "$bad_uses"
# `*-latest` is a moving runner image. Pin an OS release instead — that is exception
# 2, which bounds the drift rather than eliminating it.
# Two passes. The `runs-on:` form is checked everywhere (it appears in the scaffolded
# template too). The bare-token form is checked only inside real workflow YAML,
# because `runs-on: ${{ matrix.os }}` moves the moving value into a matrix list —
# `os: [ubuntu-latest]` — where no `runs-on:` prefix appears at all. Restricting the
# broad form to *.yml/*.yaml keeps prose that merely mentions ubuntu-latest out of it.
# Comments are stripped before re-matching: prose ABOUT a moving runner ("pin the
# release rather than `ubuntu-latest`") is not a moving runner, and the broad token
# scan flagged this repo's own explanatory comment until it was.
bad_runner=$(
{ scan 'runs-on:[[:space:]]*["'\'']\?[A-Za-z0-9._-]*-latest' | unquote
# Only where a runner value can actually live: an `os:`/`runner:` key, or a bare
# list item (`- ubuntu-latest`), which is how a matrix spells it. Matching every
# `*-latest` token in the file flagged unrelated values — `RELEASE_CHANNEL:
# product-latest` — with a "moving runner" diagnostic and blocked valid workflows.
# The second alternative matches a bare list item (` - ubuntu-latest`), which is
# how a multi-line matrix spells it. Anchored to the start of the FILE line, not to
# grep's `file:line:` output prefix — anchoring to the prefix meant it never matched
# anything, silently missing every multi-line matrix.
#
# A bare list item carries no key to identify it, so it is restricted to the known
# GitHub runner-image prefixes. Matching any `- *-latest` treated unrelated lists —
# a `workflow_dispatch` input whose options include `product-latest` — as runners
# and failed valid workflows. This is a heuristic, and deliberately the narrow kind:
# a runner label outside these prefixes is missed by this pass, but the `os:`/
# `runner:`/`runs-on:` alternative above still covers every keyed form.
grep -rnE '(^|[[:space:]])(os|runner|runs-on)[[:space:]]*:[^#]*[A-Za-z0-9]-latest|^[[:space:]]*-[[:space:]]*["'\'']?(ubuntu|windows|macos|macOS)-latest' \
--include='*.yml' --include='*.yaml' ./.github/workflows 2>/dev/null | unquote
} | strip_comment | grep '[A-Za-z0-9]-latest' | sort -u || true)
[ -n "$bad_runner" ] && fail "Invariant 5: runner pinned to a moving *-latest image." "$bad_runner"
# `npx -y <pkg>` with no exact version executes latest-on-npm at launch. Both the
# JSON args form (`"-y", "pkg"`) and the shell form (`npx -y pkg`) are checked, and
# an exact version means major.minor.patch — `@1` and `@1.x` still float.
#
# scan_each, not scan: filtering whole LINES here meant that
# `["-y", "floating", "-y", "pinned@1.0.0"]` was cleared by the pinned package on the
# same line, silently passing the floating one beside it.
# The version must END after the patch digits: without a trailing boundary,
# `pkg@1.2.3oops` matched the prefix and was accepted as exact.
exact='@[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\([^0-9A-Za-z.]\|$\)'
# The optional quote, `$` and `{}` matter: the shell strips quotes before exec, so
# `npx -y "floating-pkg"` runs exactly like the bare form, and `npx -y "$pkg"` cannot
# be shown to be pinned at all — both must be flagged.
# shellcheck disable=SC2016 # those are literal regex characters matching a variable
# in the SCANNED file, not an expansion in this one.
npx_shell_re='npx[[:space:]]\{1,\}\(-y\|--yes\)[[:space:]]\{1,\}["'\''$]\?[A-Za-z0-9@/._${}-]\{1,\}'
bad_npx=$(
{ scan_each '"\(-y\|--yes\)",[[:space:]]*"[A-Za-z0-9@/._-]\{1,\}"'
# The shell form also scans *.sh — a shell script is precisely where an unpinned
# npx call actually executes, and omitting that extension left an executable
# surface unguarded. The JSON form above deliberately does NOT scan *.sh: this
# suite embeds JSON fixtures as shell string literals and would flag its own data.
#
# This checker and its suite are excluded from the shell scan: they necessarily
# contain the very literals they search for, in the search pattern, in the
# explanatory comments, and in the fixtures. A tool cannot lint its own pattern
# text. These two files are covered by review and by the gates instead.
grep -rn 'npx[[:space:]]\{1,\}\(-y\|--yes\)' \
--include='*.yml' --include='*.yaml' --include='*.md' --include='*.json' \
--include='*.toml' --include='*.sh' . 2>/dev/null |
grep -v '^\./source-files/' |
# EXACT paths, not a prefix: `grep -v '^\./scripts/check-invariants'` also
# exempted every future sibling like check-invariants-extra.sh, quietly widening
# a two-file exception into a whole-namespace one.
grep -v '^\./scripts/check-invariants\.sh:' |
grep -v '^\./scripts/check-invariants\.test\.sh:' |
# Comments are not executions. Stripped before extraction, so a shell file that
# merely documents an unpinned call is not treated as making one.
strip_comment |
# The optional quote and `$` matter: the shell strips quotes before exec, so
# `npx -y "floating-pkg"` runs exactly like the bare form, and `npx -y "$pkg"`
# cannot be shown to be pinned at all — both are flagged.
grep -o "$npx_shell_re"
} | grep -v "$exact" | sort -u)
[ -n "$bad_npx" ] && fail "Invariant 5: npx package launched without an exact @version." "$bad_npx"
# --- Invariant 6: the manifest never re-declares convention-loaded components ------
# skills/, commands/, agents/ and hooks/hooks.json load from their paths. A `hooks` key
# alongside the convention-loaded file is a duplicate-hooks error that stops the
# plugin loading at all — the 0.2.1 failure. Manifest keys are only for files
# OUTSIDE the convention paths. Newlines are squeezed first so a key and its colon
# split across lines (valid JSON) cannot slip through a line-oriented grep.
for manifest in plugins/*/.claude-plugin/plugin.json; do
[ -f "$manifest" ] || continue
bad_keys=$(tr '\n' ' ' < "$manifest" |
grep -o '"\(skills\|commands\|hooks\|agents\)"[[:space:]]*:' || true)
[ -n "$bad_keys" ] &&
fail "Invariant 6: $manifest re-declares a convention-loaded component." "$bad_keys"
done
# Scan domain for checks 4a and 4b: Markdown only, because both rules are about prompt
# text. NOT 4c -- that one reads two fixed paths directly and is not part of this scan,
# so this domain stays a two-check domain even though the file now carries three
# prompt-conformance checks. Incrementing the number here would claim a scope 4c does not
# use. The wider yml/json/toml domain used by invariant 5 is
# deliberately NOT reused — a `Target model:` line in a JSON fixture is not a prompt
# claim. `grep -r` does not follow symlinks (`-R` would), which is the intended form.
#
# Exclusions, each for its own reason. Anchored `($|:)` at the end, NOT `$`: the file
# scan emits bare paths while the claim scan emits `path:line:match`, and a `$`-anchored
# pattern silently matches nothing in the second form — an exclusion that looks applied
# and is not.
# source-files/ frozen extraction archive, never edited (MANIFEST.md)
# docs/superpowers/ historical artifacts; its plans legitimately say "all 11 checklist
# items", so excluding it is load-bearing, not tidy
# .context/ generated Gate A/B review artifacts; the quality command must not
# depend on ephemeral review wording
# hardening-log.md the ledger QUOTES defects, so a row describing either defect below
# trips the very check that row records. It carries both a quoted
# assertion phrase and the historical `all ten items`.
# No .sh exclusion is needed: `--include='*.md'` already puts this script and its suite
# out of scope.
PROMPT_EXCL='(^|/)source-files/|(^|/)docs/superpowers/|(^|/)\.context/|(^|/)hardening-log\.md($|:)'
# --- BEGIN check 4a ---
# A file asserting it follows docs/prompt-standards.md must name exactly one executing
# model. Prose alone did not hold: a doc shipped claiming conformance while giving its
# target model as "any capable chat model", which names no model at all.
#
# WHAT THIS CATCHES, exactly — the rest of the class stays instruction-backed:
# the missing/duplicated/unnamed/multi-model spellings of the `Target model:` line, in
# files carrying the tested assertion spelling `prompt artifact and follows`. A bare
# `Target model: Claude` naming no execution surface PASSES, as does any value whose
# prose is wrong in a way no token test can see.
#
# Two independent rules, and neither subsumes the other: the value must BEGIN with a
# recognized token (a token merely present accepts "any capable chat model (… developed
# with Claude …)", which is the exact defect this exists for), and it must contain
# exactly one DISTINCT recognized token (which rejects "Claude or Codex" without needing
# a separator grammar).
# The offenders are COLLECTED and reported once, matching this file's existing idiom
# (`bad_npx`, `bad_keys`). Calling `fail` inside the loop would not work: a `while read`
# fed by a pipeline runs in a subshell, so the `rc=1` it sets is discarded and the
# checker would print every violation and still exit 0.
# The scan's status is captured BEFORE filtering. `grep` exits 0 on a match and 1 on no
# match, but >=2 on a real error (unreadable path, I/O failure, bad option). Piping
# straight into the filter would report the FILTER's status and turn any traversal
# failure into an empty offender set — the checker would print success without having
# looked, which is the fail-open direction invariant 2 forbids.
model_scan=$(grep -rl 'prompt artifact and follows' --include='*.md' . 2>/dev/null)
model_scan_st=$?
[ "$model_scan_st" -le 1 ] ||
fail "Prompt standards: the 4a scan failed; results are not trustworthy." \
"grep exited $model_scan_st"
# The FILTER gets its own status check too. Capturing only the scan's status closed
# traversal errors but left the next stage open: a `grep -vE` failure also yields an
# empty offender set, which reads as clean.
model_files=$(printf '%s\n' "$model_scan" | grep -vE "$PROMPT_EXCL"); model_filter_st=$?
[ "$model_filter_st" -le 1 ] ||
fail "Prompt standards: the 4a exclusion filter failed; results are not trustworthy." \
"grep -v exited $model_filter_st"
bad_model=$(printf '%s\n' "$model_files" |
while IFS= read -r f; do
[ -n "$f" ] || continue
# Per-file statuses are checked too, and an operational failure is emitted as an
# `ERROR:` sentinel line rather than swallowed. The loop runs inside `$( )`, so it
# cannot set rc directly; without the sentinel a `grep` that printed a partial
# count and exited >=2 would leave the offender set empty and the gate would pass.
decls=$(grep -c '^Target model:' "$f"); decls_st=$?
if [ "$decls_st" -gt 1 ]; then
printf 'ERROR: %s: counting declarations failed (grep exited %s)\n' "$f" "$decls_st"
continue
fi
if [ "$decls" -ne 1 ]; then
printf '%s: %s "Target model:" declarations, need exactly 1\n' "$f" "$decls"
continue
fi
# ONE status-bearing command, deliberately not a pipeline. `grep | head | sed` put
# only sed's status in $?, so a grep that printed a valid line and THEN failed
# (status 2) left the file looking conformant — a fail-open path that survived three
# rounds of status-checking because the pipeline's shape hid it. awk also replaces
# `head -1` (`-m` is not POSIX) via `exit` after the first match.
value=$(awk '/^Target model:/ { # extract-target-model
sub(/^Target model:[[:space:]]*/, ""); print; exit }' "$f")
value_st=$?
if [ "$value_st" -ne 0 ]; then
printf 'ERROR: %s: extracting the value failed (pipeline exited %s)\n' "$f" "$value_st"
continue
fi
if ! printf '%s\n' "$value" | grep -qE '^(Claude|Codex|GPT)([^[:alnum:]_]|$)'; then
printf '%s: names no executing model -> Target model: %s\n' "$f" "$value"
continue
fi
distinct=0; tok_err=
for tok in Claude Codex GPT; do
printf '%s\n' "$value" | grep -qE "(^|[^[:alnum:]_])$tok([^[:alnum:]_]|\$)"; tst=$?
if [ "$tst" -eq 0 ]; then distinct=$((distinct + 1))
elif [ "$tst" -gt 1 ]; then tok_err="grep exited $tst on $tok"; fi
done
if [ -n "$tok_err" ]; then
printf 'ERROR: %s: token matching failed (%s)\n' "$f" "$tok_err"
continue
fi
[ "$distinct" -eq 1 ] ||
printf '%s: names %s models, exactly one executes it -> Target model: %s\n' \
"$f" "$distinct" "$value"
done)
# An operational failure and a real violation are different diagnoses and must not
# share one message: the first means the check did not complete, the second means it did.
if printf '%s\n' "$bad_model" | grep -q '^ERROR: '; then
fail "Prompt standards: the 4a per-file checks failed; results are not trustworthy." \
"$bad_model"
elif [ -n "$bad_model" ]; then
fail "Prompt standards item 1: a file claiming conformance does not name one executing model." \
"$bad_model"
fi
# --- END check 4a ---
# --- BEGIN check 4b ---
# A prose count of the prompt-standards checklist must equal the number of items in it.
# The motivating occurrence was the WORD form "all ten items" against a 12-item list, so
# word forms one..twenty are in scope; above twenty, ordinals, hyphenated compounds and
# split-line claims are not, and stay instruction-backed.
#
# Claims are recognized in TWO stages on purpose. A canonical-only pattern would make a
# malformed claim invisible rather than rejected: `all 012 items` matches no canonical
# claim and would be silently ignored. So stage 1 matches any digit run, and stage 2
# requires it to be canonical decimal.
# Prints the item count, or 'BAD' for a malformed definition. Returns 2 if the PARSER
# itself failed, which is not the same thing: an awk that cannot run yields empty output,
# and empty matches neither 'BAD' nor a number, so the caller's comparison merely errors
# into a false condition and execution continues with rc still 0 — the checker reporting
# success without having parsed either checklist.
prompt_checklist_count() { # $1 = file
# `grep -c` exits 1 when the count is ZERO, which is a valid answer here (a file with
# no checklist heading is malformed, not unreadable). Only >=2 is a real error, so the
# status is captured and compared rather than used as a bare `||`.
heads=$(grep -cE '^## Checklist([[:space:]].*)?$' "$1"); heads_st=$?
[ "$heads_st" -le 1 ] || return 2
[ "$heads" -eq 1 ] || { printf 'BAD'; return 0; }
# Any numbered label inside the section is CONSIDERED, not only canonically-formatted
# ones. Matching `^[0-9]+\. \*\*` as the guard skipped a non-bold `13. item`
# entirely, so appending one to both definitions left N at 12 and let a now-stale
# `all 12 items` claim pass — failing open exactly when the checklist changes.
awk '
/^## Checklist([[:space:]].*)?$/ { inlist = 1; next }
inlist && /^## / { inlist = 0 }
inlist && /^[0-9]+\./ {
if ($0 !~ /^[1-9][0-9]*\. \*\*/) { bad = 1; next } # 0., leading zero, or non-bold
sub(/\..*/, "", $0); n += 1
if ($0 "" != n "") bad = 1 # string compare: an oversized label must not overflow
}
END { if (bad || n == 0) print "BAD"; else print n }
' "$1" || return 2
}
n_repo=$(prompt_checklist_count docs/prompt-standards.md); st_repo=$?
n_tmpl=$(prompt_checklist_count plugins/dev-workflow/commands/workflow-init.md); st_tmpl=$?
if [ "$st_repo" -ne 0 ] || [ "$st_tmpl" -ne 0 ]; then
fail "Prompt standards: the checklist parser failed; results are not trustworthy." \
"parser exited $st_repo (repo) / $st_tmpl (template)"
elif [ "$n_repo" = BAD ] || [ "$n_tmpl" = BAD ]; then
fail "Prompt standards: a checklist definition is missing, empty, duplicated or misnumbered." \
"docs/prompt-standards.md=$n_repo workflow-init.md=$n_tmpl"
elif [ "$n_repo" -ne "$n_tmpl" ]; then
fail "Prompt standards: the repo checklist and the scaffolded template disagree." \
"docs/prompt-standards.md=$n_repo workflow-init.md=$n_tmpl"
else
# One scan for both spellings, one awk to judge them. Digit comparison is done as
# STRINGS after canonicalisation, never `+0`, so a 40-digit claim cannot overflow its
# way to a wrong verdict.
words='one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty'
# Same status capture as the 4a scan, and for the same reason.
claim_scan=$(grep -rnoE "(^|[^[:alnum:]_])all ([0-9]+|$words)( checklist)? items([^[:alnum:]_]|\$)" \
--include='*.md' . 2>/dev/null)
claim_scan_st=$?
[ "$claim_scan_st" -le 1 ] ||
fail "Prompt standards: the 4b claim scan failed; results are not trustworthy." \
"grep exited $claim_scan_st"
# Filter and validator each get their own status, for the same reason as 4a's.
claim_filtered=$(printf '%s\n' "$claim_scan" | grep -vE "$PROMPT_EXCL"); claim_filter_st=$?
[ "$claim_filter_st" -le 1 ] ||
fail "Prompt standards: the 4b exclusion filter failed; results are not trustworthy." \
"grep -v exited $claim_filter_st"
bad_claims=$(printf '%s\n' "$claim_filtered" |
awk -v n="$n_repo" -v words="$words" '
BEGIN { c = split(words, w, "|"); for (i = 1; i <= c; i++) val[w[i]] = i }
{
tok = $0; sub(/.*all /, "", tok); sub(/[^0-9a-zA-Z].*/, "", tok)
if (tok ~ /^[0-9]+$/) {
if (tok !~ /^[1-9][0-9]*$/) { print $0 " <- non-canonical number"; next }
# Same forced-string idiom as prompt_checklist_count above. n arrives via -v,
# which makes it a strnum, so a bare tok != n leans on awk type inference to
# stay a string compare. It does today on every awk tested, but the header
# credits this idiom precisely so overflow on a long digit run cannot depend
# on that inference. (No apostrophes in here: this program is inside a
# single-quoted shell string, and one terminated it.)
if (tok "" != n "") print $0 " <- checklist has " n
} else if (tok in val) {
if (val[tok] != n + 0) print $0 " <- checklist has " n
}
}'); claim_awk_st=$?
[ "$claim_awk_st" -eq 0 ] ||
fail "Prompt standards: the 4b claim validator failed; results are not trustworthy." \
"awk exited $claim_awk_st"
[ -n "$bad_claims" ] &&
fail "Prompt standards: a checklist count claim disagrees with the checklist." "$bad_claims"
fi
# --- END check 4b ---
# --- BEGIN check 4c ---
# The finding-line severity vocabulary must be stated as a CLOSED SET in both prompt
# copies, exactly once each. This is the normative statement that the writer's tokens are
# uppercase, so the comparison is CASE-SENSITIVE: a title-case copy does not carry the
# rule. Mechanics' own "Blocker (wrong/unsafe...)" sentence is a DIFFERENT sentence and
# keeps its title case -- the READER normalizes case, the writer syntax does not.
#
# EQUALITY, not containment. A line that merely contains the sentence can negate it
# ("Ignore the following: Severity is one of exactly: ..."), so the whole line must BE the
# sentence, after stripping a leading blockquote marker and indentation. Nothing is
# stripped from the right: "byte-for-byte" means what it says, and a trailing space is a
# Markdown hard break, not whitespace noise.
#
# WHOLE-FILE count for duplicates, PLUS a placement rule in the command file. An earlier
# design bounded a section-5 region in both files; on the command file that needed fence
# nesting, and the parser returned a multiple-end-boundary error on the real file, so it
# could never have passed. Whole-file counting replaced it -- and silently traded away a
# guarantee: the line can sit in the command file's own prose, outside the template that
# `/workflow-init` actually scaffolds, and the count is still 1. Verified, not theorised.
# Only the template region reaches a user's project, so the command file gets a placement
# rule anchored on its `### 2.1` scaffold heading and terminated by the NEXT NUMBERED
# heading -- not the next `###`, because the template contains its own unnumbered
# `### Profiles` and `### Mechanics` subsections and would truncate the range.
#
# The repo's own CLAUDE.md needs no placement rule: the whole file is the artifact.
SEV_CANON='Severity is one of exactly: BLOCKER | MAJOR | MINOR | NIT — no other token.'
# Prints "<whole-file count> <in-template count> <anchor count> <terminator>", where the
# terminator is the number of the first numbered `### ` heading after the anchor, or
# `none`. Returns 2 if awk itself failed, which is not the same as zeroes: an awk that
# cannot run prints nothing, and empty is not a number, so the caller checks the status.
#
# The TERMINATOR is checked, not just the anchor. Without it the range fails OPEN when the
# boundary moves: rename `### 2.2` to something unnumbered and the section runs on to 2.3,
# so a line planted in the old 2.2 region counts as inside the template. Verified before
# this was added -- the battery stayed green. An anchor-based rule that survives its own
# anchor drifting is worth nothing.
severity_rule_scan() { # $1 = file
awk -v canon="$SEV_CANON" ' # sev-canon-count
/^### 2\.1[[:space:]]/ { intpl = 1; heads += 1; next }
intpl && /^### [0-9]/ { intpl = 0; term = $2; next }
{ line = $0
sub(/^[ \t]*/, "", line); sub(/^> ?/, "", line); sub(/^[ \t]*/, "", line)
if (line == canon) { n += 1; if (intpl) t += 1 } }
END { printf "%d %d %d %s\n", n + 0, t + 0, heads + 0, (term == "" ? "none" : term) }
' "$1" || return 2
}
# Both paths are REQUIRED. A missing one is a named failure, never a skip: the rule is
# defined over both copies, so continuing quietly would turn half the check off exactly
# when a file is deleted or moved -- the fail-open direction.
for sev_file in CLAUDE.md plugins/dev-workflow/commands/workflow-init.md; do
if [ ! -f "$sev_file" ]; then
fail "Prompt standards: $sev_file is missing, so the closed severity set cannot be checked." \
"both prompt copies are required"
continue
fi
if [ ! -r "$sev_file" ]; then
fail "Prompt standards: $sev_file is unreadable, so the closed severity set cannot be checked." \
"check permissions"
continue
fi
sev_out=$(severity_rule_scan "$sev_file"); sev_st=$?
if [ "$sev_st" -ne 0 ]; then
fail "Prompt standards: the closed severity set parser failed; results are not trustworthy." \
"awk exited $sev_st on $sev_file"
continue
fi
# Word splitting is the point: the scan prints three space-separated integers.
# shellcheck disable=SC2086
set -- $sev_out
sev_n=$1; sev_t=$2; sev_heads=$3; sev_term=$4
if [ "$sev_n" -ne 1 ]; then
fail "Prompt standards: $sev_file must state the closed severity set exactly once; found $sev_n." \
"$SEV_CANON"
elif [ "$sev_file" != CLAUDE.md ]; then
# Placement, command file only. A missing or renamed anchor fails loudly rather than
# skipping the rule -- the safe direction, and the one an anchor-based check has to
# get right to be worth having.
if [ "$sev_heads" -ne 1 ]; then
fail "Prompt standards: $sev_file has $sev_heads '### 2.1' scaffold headings, so the closed severity set's placement cannot be checked." \
"expected exactly one"
elif [ "$sev_term" != 2.2 ]; then
# Loud, not lenient. A renumbered or renamed boundary is precisely when the range
# silently widens, so the check refuses rather than measuring a range it cannot
# trust. Fixing it is renaming a heading back, or updating this expectation
# deliberately.
fail "Prompt standards: $sev_file's '### 2.1' section is terminated by '$sev_term', not '2.2', so the closed severity set's placement cannot be checked." \
"the range would silently widen past the scaffolded template"
elif [ "$sev_t" -ne 1 ]; then
fail "Prompt standards: $sev_file states the closed severity set outside the scaffolded CLAUDE.md template, so an initialized project would not receive it." \
"expected it inside the '### 2.1' section"
fi
fi
done
# --- END check 4c ---
[ "$rc" -eq 0 ] && printf 'invariant checks: ok\n'
exit "$rc"