@erclx/aitk 3.52.0 → 3.52.1

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.
@@ -1,694 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
- source "$PROJECT_ROOT/scripts/lib/worktree.sh"
10
- source "$PROJECT_ROOT/scripts/lib/tooling.sh"
11
-
12
- NESTED="${VERIFY_NESTED:-false}"
13
- WRITE="${VERIFY_WRITE:-true}"
14
- SCOPED=true
15
- CHANGED_FILES=""
16
-
17
- # Scenarios declaring no expectation, taken from `aitk sandbox coverage` against a
18
- # clean tree. Raising it is a deliberate edit that says which scenario shipped
19
- # unarmed and why.
20
- SANDBOX_UNDECLARED_CEILING=47
21
-
22
- # Rules no stack reaches, space separated and sorted the way `aitk gov list`
23
- # emits them. `260-shadcn` and `320-tanstack-query` are opt-in libraries a
24
- # project may not want. `505-at-references` used to sit here too, shipping
25
- # with no stack on purpose since a rule under `claude/` would reach every
26
- # base consumer through the folder-whole entry there. Its own install
27
- # channel, `aitk snippets install`, retired with nothing left to deliver it,
28
- # so `base` now carries `snippets` as a folder-whole entry of its own and the
29
- # rule reaches every base consumer through that instead. Both are recorded
30
- # here rather than in a config file: the list is what a reader compares a
31
- # new arrival against, and a config file would absorb the arrival silently.
32
- GOV_EXPECTED_UNREFERENCED="260-shadcn 320-tanstack-query"
33
-
34
- # The retained counts the audit stage compares each run against. Spelled here
35
- # rather than derived, because this script only ever names the file in a remedy
36
- # a reader has to be able to open, and `aitk audits run` owns writing it.
37
- AUDITS_BASELINE=".claude/audits/baseline.json"
38
-
39
- # The corpora a `src/` test asserts over from outside `src/`, censused in
40
- # `.claude/context/development/verification.md`. This array and that list are two
41
- # copies of one set with nothing comparing them, so a corpus joining the census
42
- # joins this array in the same change. The first four are directory prefixes
43
- # because their tests walk the tree whole, which is what reaches a rule or a
44
- # skill a branch adds rather than edits.
45
- TEST_CORPORA_PATTERNS=(
46
- '^claude/skills/'
47
- '^governance/rules/'
48
- '^\.claude/hooks/'
49
- '^tooling/claude/seeds/\.claude/hooks/'
50
- '^standards/markdown\.md$'
51
- '^tooling/base/reference\.md$'
52
- '^tooling/web/configs/scripts/worktree-port\.sh$'
53
- '^\.cspell/banned-spellings\.txt$'
54
- '^scripts/lib/worktree\.sh$'
55
- '^scripts/core/check-ignore-parity\.sh$'
56
- )
57
- TEST_CORPORA=$(
58
- IFS='|'
59
- printf '%s' "${TEST_CORPORA_PATTERNS[*]}"
60
- )
61
-
62
- check_dependencies() {
63
- command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
64
- }
65
-
66
- print_usage() {
67
- echo "Usage: bun run check [--all]"
68
- echo " --all Run every stage instead of scoping shell, types, and tests to changed files"
69
- echo " --help Print this message"
70
- }
71
-
72
- parse_args() {
73
- local arg
74
- for arg in "$@"; do
75
- case "$arg" in
76
- --all) SCOPED=false ;;
77
- -h | --help)
78
- print_usage
79
- exit 0
80
- ;;
81
- *) log_error "Unknown argument: $arg" ;;
82
- esac
83
- done
84
- }
85
-
86
- # Union of the branch's committed diff, the working tree, and untracked files.
87
- # A wider set only means running more stages, so every fallback widens.
88
- collect_changed_files() {
89
- [ "$SCOPED" = true ] || return 0
90
-
91
- local base head local_baseline=false
92
- # origin/main, not local main. On main itself the local ref is HEAD, so a commit
93
- # not yet pushed would drop out of the changed set and skip the scoped stages.
94
- base=$(git -C "$PROJECT_ROOT" merge-base HEAD origin/main 2>/dev/null) || base=""
95
- if [ -z "$base" ]; then
96
- local_baseline=true
97
- base=$(git -C "$PROJECT_ROOT" merge-base HEAD main 2>/dev/null) || base=""
98
- fi
99
- if [ -z "$base" ]; then
100
- SCOPED=false
101
- log_warn "No merge base with main. Running every stage."
102
- return 0
103
- fi
104
-
105
- # Without a remote baseline, a merge base equal to HEAD hides committed work.
106
- if [ "$local_baseline" = true ]; then
107
- head=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null) || head=""
108
- if [ -z "$head" ] || [ "$base" = "$head" ]; then
109
- SCOPED=false
110
- log_warn "No pushed baseline to compare against. Running every stage."
111
- return 0
112
- fi
113
- fi
114
-
115
- CHANGED_FILES=$({
116
- git -C "$PROJECT_ROOT" diff --name-only "$base" HEAD
117
- git -C "$PROJECT_ROOT" diff --name-only HEAD
118
- git -C "$PROJECT_ROOT" ls-files --others --exclude-standard
119
- } | sort -u)
120
- }
121
-
122
- has_changed() {
123
- [ "$SCOPED" = true ] || return 0
124
- printf '%s\n' "$CHANGED_FILES" | grep -qE "$1"
125
- }
126
-
127
- run_check() {
128
- local cmd=$1
129
- local err_msg=$2
130
- local output
131
- if ! output=$(eval "$cmd" 2>&1); then
132
- echo "$output" | pipe_output
133
- log_error "$err_msg"
134
- fi
135
- echo "$output" | pipe_output
136
- }
137
-
138
- # Whatever plugin and marketplace manifests the repo currently carries, so the
139
- # stage picks up a new one without an edit here. Both listings honor .gitignore,
140
- # which keeps linked worktrees and dependency copies out.
141
- collect_plugin_manifests() {
142
- {
143
- git -C "$PROJECT_ROOT" ls-files -- '*.claude-plugin/plugin.json' '*.claude-plugin/marketplace.json'
144
- git -C "$PROJECT_ROOT" ls-files --others --exclude-standard -- '*.claude-plugin/plugin.json' '*.claude-plugin/marketplace.json'
145
- } | sort -u
146
- }
147
-
148
- # sha256 of a file under the coreutils name and the macOS one. `aitk capture`
149
- # writes the same digest into the stamp through node's crypto, so the two sides
150
- # agree on an algorithm rather than on a tool being installed.
151
- #
152
- # Neither tool present returns 1 and says so. Falling through to an empty digest
153
- # reports a mismatch against a blank value, which tells the reader the image is
154
- # wrong when the truth is that the checker never ran.
155
- #
156
- # The refusal goes to stderr because every caller reads this through `$(...)`,
157
- # which captures stdout into the digest variable and would swallow the message.
158
- # `run_check` folds stderr into what it pipes, so the reader still sees it.
159
- file_sha256() {
160
- local digest
161
- if command -v sha256sum >/dev/null 2>&1; then
162
- digest=$(sha256sum "$1")
163
- elif command -v shasum >/dev/null 2>&1; then
164
- digest=$(shasum -a 256 "$1")
165
- else
166
- echo "Neither sha256sum nor shasum is installed, so $1 cannot be hashed." >&2
167
- return 1
168
- fi
169
- printf '%s\n' "${digest%% *}"
170
- }
171
-
172
- # One digest the stamp recorded against the file it was taken over. An absent
173
- # field reports itself rather than comparing against an empty string, so a stamp
174
- # predating the current format is distinguishable from a file that moved.
175
- #
176
- # Both names in the message come off the paths rather than from arguments. The
177
- # caller passes absolute paths and the reader wants repository-relative ones, and
178
- # deriving them here is what keeps a name from disagreeing with the file it
179
- # labels once a second capture source calls this.
180
- assert_stamp_field() {
181
- local stamp=$1 field=$2 file=$3
182
- local stamp_label=${stamp#"$PROJECT_ROOT"/}
183
- local file_label=${file#"$PROJECT_ROOT"/}
184
- local recorded actual
185
- recorded=$(awk -v key="$field:" '$1 == key { print $2; exit }' "$stamp")
186
- if [ -z "$recorded" ]; then
187
- echo "$stamp_label carries no $field line, so it predates the capture that writes one."
188
- return 1
189
- fi
190
- actual=$(file_sha256 "$file") || return 1
191
- if [ "$recorded" != "$actual" ]; then
192
- echo "$stamp_label records $field $recorded"
193
- echo "$file_label hashes to $actual"
194
- return 1
195
- fi
196
- }
197
-
198
- # The drift assert covers the HTML because the PNG is a chromium render whose
199
- # bytes move with the browser. That leaves the artifact a visitor actually sees
200
- # asserted nowhere, so a branch that regenerates the HTML and never runs the
201
- # capture passes every stage while shipping an image with the old counts.
202
- #
203
- # `aitk capture` records a digest of the markup it rendered and one of the image
204
- # it wrote, so this reads provenance rather than timing. Comparing the commit
205
- # that last touched each file passes any pair that moved together whatever the
206
- # two files hold, which is what a binary conflict resolved by taking either side
207
- # produces. All three absent passes, which is correct for a tree that carries
208
- # none of them.
209
- #
210
- # Both digests are checked because either file can move alone. The markup side
211
- # catches an edit committed with no capture, and the image side catches a PNG
212
- # replaced under markup that never changed, which is the case the timing read
213
- # caught by accident and a markup-only digest would drop.
214
- assert_hero_stamp() {
215
- local html="$PROJECT_ROOT/assets/hero.html"
216
- local png="$PROJECT_ROOT/assets/hero.png"
217
- local stamp="$PROJECT_ROOT/assets/hero.stamp"
218
-
219
- if [ ! -f "$html" ] && [ ! -f "$png" ] && [ ! -f "$stamp" ]; then return 0; fi
220
-
221
- local missing=""
222
- [ -f "$html" ] || missing="$missing assets/hero.html"
223
- [ -f "$png" ] || missing="$missing assets/hero.png"
224
- [ -f "$stamp" ] || missing="$missing assets/hero.stamp"
225
- if [ -n "$missing" ]; then
226
- echo "Missing from the hero set:$missing"
227
- return 1
228
- fi
229
-
230
- assert_stamp_field "$stamp" source-sha256 "$html" || return 1
231
- assert_stamp_field "$stamp" image-sha256 "$png" || return 1
232
- }
233
-
234
- # Entries the audit actually measured, summed across the folders it resolved.
235
- # `--json` carries one `"entries":<n>` per folder object, and the top-level key
236
- # of that name holds an array, so the numeric match reaches folders alone.
237
- #
238
- # A root can resolve a folder and measure nothing in it, which is a passing gate
239
- # over an empty set. The stage prints this per root rather than reporting one
240
- # verdict for every root, or a tree nobody measured reads as a tree that passed.
241
- seed_entry_count() {
242
- printf '%s' "$1" | grep -o '"entries":[0-9]\+' | grep -o '[0-9]\+' |
243
- awk '{ total += $1 } END { print total + 0 }'
244
- }
245
-
246
- # One numeric summary key out of a command's JSON record. Every caller passes a
247
- # name the nested objects in that record do not carry, so the match reaches the
248
- # top level alone and the caller does not depend on the order the keys are
249
- # emitted in.
250
- json_summary_field() {
251
- printf '%s' "$2" | grep -o "\"$1\":[0-9]\+" | grep -o '[0-9]\+'
252
- }
253
-
254
- assert_no_drift() {
255
- local paths=$1
256
- local err_msg=$2
257
- run_check "cd $PROJECT_ROOT && git diff --exit-code --quiet -- $paths" "$err_msg"
258
- run_check "cd $PROJECT_ROOT && [ -z \"\$(git ls-files --others --exclude-standard -- $paths)\" ]" "$err_msg"
259
- }
260
-
261
- main() {
262
- check_dependencies
263
- parse_args "$@"
264
-
265
- if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
266
-
267
- repair_bare_flag
268
- collect_changed_files
269
-
270
- if [ "$WRITE" = true ]; then
271
- echo -e "${GREY}├${NC} ${WHITE}Formatting${NC}"
272
- run_check "bun run format" "Format failed"
273
- log_info "Format applied"
274
- else
275
- echo -e "${GREY}├${NC} ${WHITE}Format check${NC}"
276
- run_check "bun run check:format" "Format check failed"
277
- log_info "Format check passed"
278
- fi
279
-
280
- log_step "Indexes"
281
- run_check "bash $PROJECT_ROOT/scripts/core/regen-indexes.sh" "Index regen failed"
282
- assert_no_drift "'*index.md'" "Indexes drifted. Run bun run check and commit the updated index files."
283
- log_info "Indexes clean"
284
-
285
- log_step "Consumed copies"
286
- run_check "bash $PROJECT_ROOT/scripts/core/regen-claude-copies.sh" "Consumed-copy regen failed"
287
- assert_no_drift ".claude/rules" "Consumed copies drifted. Run bun run check and commit .claude/rules."
288
- log_info "Consumed copies clean"
289
-
290
- # Only the HTML is asserted. The PNG beside it is a chromium render whose bytes
291
- # move with the browser version, so a drift check over it would fail on a
292
- # machine whose chromium differs rather than on a stale count.
293
- log_step "Hero"
294
- run_check "bash $PROJECT_ROOT/scripts/core/regen-hero.sh" "Hero regen failed"
295
- assert_no_drift "assets/hero.html" "Hero counts drifted. Run bun run check, then aitk capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp."
296
- run_check "assert_hero_stamp" "The hero set disagrees with the stamp written when the image was captured. Run aitk capture assets/hero.html and commit all three files together."
297
- log_info "Hero clean"
298
-
299
- log_step "Tooling paths"
300
- run_check "bash $PROJECT_ROOT/scripts/core/regen-tooling-paths.sh" "Tooling-path regen failed"
301
- assert_no_drift "claude/skills/aitk-cli/SKILL.md" "The overwrite contract drifted from what the stacks hold. Run bun run check and commit claude/skills/aitk-cli/SKILL.md."
302
- log_info "Tooling paths clean"
303
-
304
- # The claude manifest is the only route a target's ignore set travels, and it
305
- # is hand-maintained beside this repository's own `.gitignore` with nothing
306
- # comparing the two. A drift between them reaches every target on the next
307
- # `aitk tooling sync` and surfaces to nobody, which is why this gates rather
308
- # than reports. It is not an `assert_no_drift`: no generator produces either
309
- # list, so there is nothing to regenerate and diff.
310
- log_step "Ignore parity"
311
- run_check "bash $PROJECT_ROOT/scripts/core/check-ignore-parity.sh" "The ignore set a target receives disagrees with this repository's own."
312
- log_info "Ignore parity clean"
313
-
314
- log_step "Skill paths"
315
- run_check "bash $PROJECT_ROOT/scripts/core/check-skill-paths.sh" "Shipped skills reference a repo-local path."
316
- log_info "Skill paths clean"
317
-
318
- log_step "Plugin boundary"
319
- run_check "bash $PROJECT_ROOT/scripts/core/check-plugin-boundary.sh" "Plugin ships toolkit-internal content."
320
- log_info "Plugin boundary clean"
321
-
322
- # Seed prose is installed into every scaffolded project and read there as
323
- # instruction about that project, so a line naming this repository's CLI hands
324
- # a target a verb it may not be able to run. This gates for the reason the
325
- # Seed standards stage below gates: a defect authored once propagates into
326
- # every project scaffolded after it.
327
- log_step "Seed independence"
328
- run_check "bash $PROJECT_ROOT/scripts/core/check-seed-independence.sh" "Seed prose cites the toolkit CLI."
329
- log_info "Seed prose cites no toolkit CLI"
330
-
331
- # A stack entry naming a rule folder takes every rule in it, which is what
332
- # stops a new rule from needing a second edit to reach a target. The failure
333
- # it leaves open is a rule authored into a folder no stack names, which
334
- # `aitk gov install` never reaches on its own, whether or not another domain
335
- # installs the file by a different route.
336
- #
337
- # This reports and never fails. All three standing findings ship this way on
338
- # purpose, so gating would fail every push over the deliberate case and teach
339
- # a reader to route around the stage. Revisit if the set keeps growing and
340
- # the pattern turns out to be an accident rather than a design.
341
- log_step "Unreferenced rules"
342
- local gov_json gov_status=0 unreferenced
343
- gov_json=$(cd "$PROJECT_ROOT" && bun src/cli.ts gov list --json 2>/dev/null) || gov_status=$?
344
- if [ "$gov_status" -ne 0 ] || [ -z "$gov_json" ]; then
345
- log_warn "Skipped, the governance catalog did not report"
346
- else
347
- # `bun --eval` rather than a grep, because the key holds an array of names
348
- # and the numeric matches the stages above use reach a scalar alone.
349
- #
350
- # The `ok:` sentinel carries success rather than the exit code, because
351
- # `bun --eval` reading piped stdin exits 0 even when the script throws.
352
- # Measured on Bun 1.3.14: the same throw exits 1 with no pipe attached. A
353
- # payload that parses as text but not as JSON would therefore print nothing
354
- # and exit clean, and empty already means every rule is reached, so reading
355
- # the exit code would report a broken catalog as a clean sweep. A missing
356
- # or non-array key takes the same branch, since a stage that cannot measure
357
- # should say so rather than claim the sweep found nothing.
358
- local reported
359
- reported=$(printf '%s' "$gov_json" | bun --eval '
360
- try {
361
- const data = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
362
- if (!Array.isArray(data.unreferenced)) throw new Error("no field")
363
- console.log("ok:" + data.unreferenced.join(" "))
364
- } catch {
365
- console.log("unreadable:")
366
- }
367
- ')
368
- unreferenced="${reported#ok:}"
369
- if [ "${reported%%:*}" != "ok" ]; then
370
- log_warn "Skipped, the governance catalog carried no readable unreferenced list"
371
- elif [ -z "$unreferenced" ]; then
372
- log_info "Every rule is reached by a stack"
373
- elif [ "$unreferenced" = "$GOV_EXPECTED_UNREFERENCED" ]; then
374
- log_info "Reached by no stack: $unreferenced (each recorded above with why)"
375
- else
376
- log_warn "Reached by no stack: $unreferenced"
377
- log_warn "Expected: $GOV_EXPECTED_UNREFERENCED. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in this script and say why it reaches no stack."
378
- fi
379
- fi
380
-
381
- # Only the citation half of the audit gates. Length, depth, table, and index
382
- # findings are judgment thresholds, and failing a push on one would make the
383
- # stage something to route around. `bun src/cli.ts` rather than `aitk`, since a
384
- # globally installed binary resolves to the main checkout no matter which
385
- # worktree is running.
386
- log_step "Context citations"
387
- run_check "cd $PROJECT_ROOT && bun src/cli.ts context audit --citations-only" "A cited context path does not resolve. Run bun src/cli.ts context audit."
388
- log_info "Context citations resolve"
389
-
390
- # A rule citing a file that moved fails silently. The consumed-copy drift
391
- # stage passes an authored rule and its copy that are wrong together, and
392
- # nothing else resolves the path until a session opens it, which is how
393
- # `561-teach.md` shipped a `references/glossary.md` that had never existed. A
394
- # rule whose frontmatter glob names a directory that moved fails the same way,
395
- # by never firing again.
396
- #
397
- # This gates for the reason the stage above gates: a path resolving to
398
- # nothing carries no judgment. The classes where absence is correct, a path
399
- # the rule declares in its own frontmatter and one git ignores, are separated
400
- # inside the verb rather than left as a threshold here. Globs are read under
401
- # `internal/rules/` alone, since a shipped rule's glob names a target's shape.
402
- log_step "Rule citations"
403
- run_check "cd $PROJECT_ROOT && bun src/cli.ts gov citations" "A path a rule cites, or an internal frontmatter glob, does not resolve. Run bun src/cli.ts gov citations."
404
- log_info "Rule citations resolve"
405
-
406
- # A banned character, word, or spelling is a fact rather than a threshold, so
407
- # it fails the push while bullet, paragraph, and depth weight stay advisory
408
- # for the reason the stage above leaves its own thresholds so.
409
- #
410
- # The whole corpus is measured rather than the changed files, because a
411
- # `Do not use` bullet added to a standard bans a token retroactively and no
412
- # file in the push that adds it was edited.
413
- #
414
- # `--json` sends the record to stdout and the frame to stderr, so a passing
415
- # run stays silent and a failing one is re-run for its frame rather than
416
- # parsed out of a stream this script would have to strip. `bun src/cli.ts`
417
- # rather than `aitk` for the reason the stage above uses it.
418
- log_step "Markdown bans"
419
- local ban_status=0 ban_frame
420
- (cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit --json >/dev/null 2>&1) || ban_status=$?
421
- case $ban_status in
422
- 0)
423
- log_info "No banned character, word, or spelling"
424
- ;;
425
- 1)
426
- log_warn "Skipped, the markdown audit refused and measured nothing"
427
- ;;
428
- 3)
429
- log_error "The markdown audit shipped an empty ban set, so the corpus was walked and nothing was looked for. Check src/markdown/bans.ts."
430
- ;;
431
- 2)
432
- # `|| true` because the re-run exits non-zero by construction, and `set -e`
433
- # would take the script down before log_error names the remedy.
434
- ban_frame=$(cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit 2>&1 || true)
435
- echo "$ban_frame" | pipe_output
436
- log_error "Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion."
437
- ;;
438
- *)
439
- log_error "The markdown audit exited $ban_status, which is neither a pass nor a finding."
440
- ;;
441
- esac
442
-
443
- # The stage above audits this repository. Its seed tree ships into every
444
- # scaffolded project, so a seed breaking the standard it seeds propagates
445
- # instead of sitting still, and no rule path reaches the tree to report it.
446
- # `--gate` fails on the two findings beside citations that are facts, a
447
- # missing required section and index drift, and leaves the thresholds
448
- # advisory for the reason the stage above leaves them so. A passing run stays
449
- # silent because the audit prints a frame that would nest inside this one.
450
- log_step "Seed standards"
451
- local seed_roots seed_root seed_output seed_frame seed_entries seed_measured seed_status
452
- seed_roots=$(collect_seed_roots)
453
- if [ -z "$seed_roots" ]; then
454
- log_info "Skipped, no seed root carries .claude/"
455
- else
456
- seed_measured=0
457
- while IFS= read -r seed_root; do
458
- # `--json` puts the record on stdout and the frame on stderr, so the
459
- # passing run stays silent and the failing one is re-run for its frame
460
- # rather than parsed out of a stream this script would have to strip.
461
- seed_status=0
462
- seed_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts context audit "$seed_root" --gate --json 2>/dev/null) || seed_status=$?
463
-
464
- # The audit separates 1 from 2 and they mean opposite things. 2 is a seed
465
- # breaking the standard it seeds. 1 is the audit refusing, which a seed
466
- # root carrying no audited folder produces, and reporting that as a
467
- # violation sends a reader hunting one that does not exist. Discovery is
468
- # what puts this in reach, since a new stack seeding `.claude/` alone
469
- # arrives here with no edit to this script.
470
- case $seed_status in
471
- 0) ;;
472
- 1)
473
- log_warn "$seed_root: no audited folder under .claude/, nothing measured"
474
- continue
475
- ;;
476
- *)
477
- # `|| true` because the re-run exits non-zero by construction, and
478
- # `set -e` would take the script down before log_error names the root.
479
- seed_frame=$(cd "$PROJECT_ROOT" && bun src/cli.ts context audit "$seed_root" --gate 2>&1 || true)
480
- echo "$seed_frame" | pipe_output
481
- if [ "$seed_status" -eq 2 ]; then
482
- log_error "A seed breaks the standard governing the folder it seeds: $seed_root"
483
- else
484
- log_error "The seed audit exited $seed_status against $seed_root, which is neither a pass nor a finding."
485
- fi
486
- ;;
487
- esac
488
-
489
- seed_entries=$(seed_entry_count "$seed_output")
490
- seed_measured=$((seed_measured + seed_entries))
491
-
492
- if [ "$seed_entries" -eq 0 ]; then
493
- log_warn "$seed_root: no entry under an audited folder, nothing measured"
494
- else
495
- log_info "$seed_root: $seed_entries entries measured"
496
- fi
497
- done <<<"$seed_roots"
498
-
499
- if [ "$seed_measured" -eq 0 ]; then
500
- log_warn "No seed entry was measured. The stage covered nothing."
501
- fi
502
- fi
503
-
504
- # Presence of a required file is a fact, so it gates. The name, description,
505
- # folder, and requirement-section measures beside it report and are read from a
506
- # bare run. `bun src/cli.ts` for the reason the stage above uses it, and the
507
- # command reads the cwd, so this measures the worktree being pushed.
508
- log_step "Skill requirements"
509
- run_check "cd $PROJECT_ROOT && bun src/cli.ts claude skills audit --requirements-only" "A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit."
510
- log_info "Skill requirements present"
511
-
512
- # Scoped to arrival rather than the corpus, since standards/standard.md
513
- # forbids writing a criterion into an existing standard outside the change
514
- # that exercises it. Gating the 26 known gaps would fail every push until
515
- # someone closed them all, which is the sweep that rule exists to prevent.
516
- log_step "Standard success criteria"
517
- local standards_output standards_status=0
518
- standards_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts standards audit --arrivals-only 2>&1) || standards_status=$?
519
- if [ "$standards_status" -eq 0 ]; then
520
- log_info "Arriving standards carry a success criterion"
521
- elif [ "$standards_status" -eq 2 ]; then
522
- echo "$standards_output" | pipe_output
523
- log_error "A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit."
524
- else
525
- echo "$standards_output" | pipe_output
526
- log_error "aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why."
527
- fi
528
-
529
- # `aitk sandbox coverage` moves only when a person runs it, so a scenario added
530
- # with no expectation ships unnoticed. The gate is an absolute count of
531
- # undeclared scenarios rather than a ratio or a floor under the declared count.
532
- # A floor under the declared count passes the case this exists to catch, since
533
- # adding an unarmed scenario leaves that number where it was. A ratio moves
534
- # when a scenario is legitimately deleted, and this ceiling does not: deleting
535
- # an unarmed scenario lowers it and deleting an armed one leaves it alone.
536
- log_step "Sandbox coverage"
537
- local coverage_output coverage_status=0 total armed undeclared
538
- coverage_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts sandbox coverage --json 2>/dev/null) || coverage_status=$?
539
- if [ "$coverage_status" -ne 0 ]; then
540
- if [ "${CI:-false}" = true ]; then
541
- log_error "bun src/cli.ts sandbox coverage --json exited $coverage_status. The scenario tree ships in the checkout, so a run that does not report is a broken command rather than an absent tree, and skipping would report the pass this stage exists to withhold."
542
- fi
543
- log_warn "Skipped, the scenario tree did not report"
544
- else
545
- # `|| x=""` on both, because a grep that matches nothing exits non-zero and
546
- # errexit would take the script down at the assignment, before the guard
547
- # below could name what went missing.
548
- total=$(json_summary_field totalScenarios "$coverage_output") || total=""
549
- armed=$(json_summary_field armedScenarios "$coverage_output") || armed=""
550
- if [ -z "$total" ] || [ -z "$armed" ]; then
551
- log_error "The coverage report carried no scenario totals, so the stage measured nothing. Run bun src/cli.ts sandbox coverage --json."
552
- fi
553
- undeclared=$((total - armed))
554
- if [ "$undeclared" -gt "$SANDBOX_UNDECLARED_CEILING" ]; then
555
- log_error "$undeclared of $total scenarios declare no expectation, over the ceiling of $SANDBOX_UNDECLARED_CEILING. Declare expectations on the new scenario, or raise SANDBOX_UNDECLARED_CEILING in this script and say which scenario shipped unarmed."
556
- fi
557
- log_info "$armed of $total scenarios declare expectations, $undeclared undeclared against a ceiling of $SANDBOX_UNDECLARED_CEILING"
558
- fi
559
-
560
- # The three stages above gate on the three findings here that are facts, and
561
- # this stage reports the rest. It runs the whole set anyway rather than only
562
- # what those stages skip, because the aggregate's own value is one verdict
563
- # over every audit, and a stage measuring a subset would report a health this
564
- # repository never took.
565
- #
566
- # The duplicate walk costs 0.8s wall against roughly 4.4s of processor,
567
- # measured on the authoring machine at 12 verbs run together. That is under
568
- # every other stage in this script, which is what settles the open question
569
- # about whether the pipeline can afford it.
570
- #
571
- # This reports and never fails. Growth in a judgment count is the thing the
572
- # baseline exists to make visible, and failing a push on one would teach a
573
- # contributor to route around the stage, which is the split every audit stage
574
- # here already keeps. A fact still fails the push, at the specific stage above
575
- # that names its own remedy.
576
- log_step "Audit set"
577
- local audits_output audits_status=0 audits_grown audits_shrunk audits_facts audits_unmeasured audits_absent audits_unrecorded
578
- audits_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts audits run --json 2>/dev/null) || audits_status=$?
579
- if [ -z "$audits_output" ]; then
580
- log_warn "Skipped, the audit set did not report (exit $audits_status)"
581
- else
582
- audits_grown=$(json_summary_field grown "$audits_output") || audits_grown=""
583
- audits_shrunk=$(json_summary_field shrunk "$audits_output") || audits_shrunk=""
584
- audits_facts=$(json_summary_field facts "$audits_output") || audits_facts=""
585
- audits_unmeasured=$(json_summary_field unmeasured "$audits_output") || audits_unmeasured=""
586
- audits_absent=$(json_summary_field absent "$audits_output") || audits_absent=""
587
- audits_unrecorded=$(json_summary_field unrecorded "$audits_output") || audits_unrecorded=""
588
-
589
- # An absent field is a record this stage cannot read, which is not the same
590
- # as a run with nothing to report. Reading it as zero would print a clean
591
- # line over a summary nobody parsed.
592
- if [ -z "$audits_grown" ] || [ -z "$audits_facts" ] || [ -z "$audits_unmeasured" ]; then
593
- log_warn "The audit record carried no summary, so this stage measured nothing. Run bun src/cli.ts audits run."
594
- else
595
- # An absent per-machine folder is the ordinary state here rather than a
596
- # finding, since every one of them is gitignored and CI carries none. It
597
- # is still stated, because a stage naming only what it measured claims a
598
- # coverage it does not have.
599
- if [ -n "$audits_absent" ] && [ "$audits_absent" -gt 0 ]; then
600
- log_info "$audits_absent per-machine corpus/corpora absent, so unmeasured here by design"
601
- fi
602
- if [ "$audits_unmeasured" -gt 0 ]; then
603
- log_warn "$audits_unmeasured audit(s) did not report, so the set is incomplete. Run bun src/cli.ts audits run."
604
- fi
605
- if [ "$audits_facts" -gt 0 ]; then
606
- log_warn "$audits_facts audit(s) carry a finding that is a fact. The stage above names the remedy."
607
- fi
608
- if [ -n "$audits_unrecorded" ] && [ "$audits_unrecorded" -gt 0 ]; then
609
- log_warn "$audits_unrecorded tracked audit(s) have no recorded floor. Take one with bun src/cli.ts audits run --record."
610
- fi
611
- if [ "$audits_grown" -gt 0 ]; then
612
- log_warn "$audits_grown measure(s) grew against $AUDITS_BASELINE. Run bun src/cli.ts audits run to see which, then fix them or re-record and say why."
613
- else
614
- log_info "No measure grew against $AUDITS_BASELINE"
615
- fi
616
- if [ -n "$audits_shrunk" ] && [ "$audits_shrunk" -gt 0 ]; then
617
- log_info "$audits_shrunk measure(s) fell against $AUDITS_BASELINE"
618
- fi
619
- fi
620
- fi
621
-
622
- # The plugin is the second delivery path and this is the only stage gating it,
623
- # so the skip below is for a contributor's machine rather than for the merge
624
- # gate. A runner installs the CLI as a workflow step, which makes an absent
625
- # binary there a broken workflow, and skipping would report a pass for every
626
- # manifest on the way to a marketplace install. A global install can also land
627
- # the wrapper and no platform-native binary, which resolves on PATH and cannot
628
- # run, so the guard tests both and CI refuses on either.
629
- log_step "Plugin manifests"
630
- local plugin_cli_state=ready
631
- if ! command -v claude >/dev/null 2>&1; then
632
- plugin_cli_state=absent
633
- elif ! claude --version >/dev/null 2>&1; then
634
- plugin_cli_state=broken
635
- fi
636
- if [ "$plugin_cli_state" = absent ]; then
637
- if [ "${CI:-false}" = true ]; then
638
- log_error "claude is not installed. CI installs it before this stage, so read the Install Plugin CLI step in .github/workflows/verify.yml."
639
- fi
640
- log_info "Skipped, claude is not installed"
641
- elif [ "$plugin_cli_state" = broken ]; then
642
- if [ "${CI:-false}" = true ]; then
643
- log_error "claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in .claude/context/ci.md."
644
- fi
645
- log_info "Skipped, claude is installed but cannot run"
646
- else
647
- local manifests manifest
648
- manifests=$(collect_plugin_manifests)
649
- if [ -z "$manifests" ]; then
650
- log_info "Skipped, no manifests present"
651
- else
652
- while IFS= read -r manifest; do
653
- run_check "cd $PROJECT_ROOT && claude plugin validate --strict '$manifest'" "Manifest validation failed: $manifest"
654
- done <<<"$manifests"
655
- log_info "Manifests valid"
656
- fi
657
- fi
658
-
659
- log_step "Spelling"
660
- run_check "bun run check:spell" "Spell check failed"
661
- log_info "Spell check passed"
662
-
663
- log_step "Shell"
664
- if has_changed '\.sh$|^package\.json$'; then
665
- run_check "bun run check:shell" "Shell check failed"
666
- run_check "bash $PROJECT_ROOT/scripts/core/check-color-source.sh" "A color escape is defined outside scripts/lib/ui.sh."
667
- log_info "Shell check passed"
668
- else
669
- log_info "Skipped, no shell changes"
670
- fi
671
-
672
- log_step "Types"
673
- if has_changed '^src/|^tsconfig\.json$|^package\.json$'; then
674
- run_check "bun run check:types" "Typecheck failed"
675
- log_info "Typecheck passed"
676
- else
677
- log_info "Skipped, no TypeScript changes"
678
- fi
679
-
680
- log_step "Tests"
681
- if has_changed "^src/|^vitest\.config\.ts\$|^tsconfig\.json\$|^package\.json\$|$TEST_CORPORA"; then
682
- run_check "bun run test" "Tests failed"
683
- log_info "Tests passed"
684
- else
685
- log_info "Skipped, no TypeScript or asserted-corpus changes"
686
- fi
687
-
688
- if [ "$NESTED" = false ]; then
689
- echo -e "${GREY}└${NC}\n"
690
- echo -e "${GREEN}✓ Verification passed${NC}"
691
- fi
692
- }
693
-
694
- main "$@"