Skip to content

perf(skill-quality): measure the listing budget in one awk pass, byte-identical and ~100x faster - #2322

Merged
kyle-sexton merged 1 commit into
mainfrom
perf/2216-listing-budget-single-pass
Aug 12, 2026
Merged

perf(skill-quality): measure the listing budget in one awk pass, byte-identical and ~100x faster#2322
kyle-sexton merged 1 commit into
mainfrom
perf/2216-listing-budget-single-pass

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/*/skillsverbatim what
#2023's procedure and .github/recurring-schedule.json's listing-budget-watch row tell an
operator to run each cycle
— took 289s on Windows (Git Bash) and, run in the foreground by an
agent, was killed at 180s with exit 143 and zero output. On the one machine where that quarterly
routine is actually driven, a report-only drift watch silently produced nothing.

The cause is process-spawn cost, not the machine. The per-file loop spent at least eleven forked
subshells and five external process execs
(4× awk, 1× tr) on every one of the repo's ~200
SKILL.md files — on the order of 2,000 spawns. Process creation costs roughly two orders of
magnitude more on Windows than on Linux, which is why CI (ubuntu-24.04) never surfaced it.

The measurement is now one awk pass. Same command, same tree: 232s → 2.2s, output
byte-identical.

The byte-identical claim, and how it was actually checked

Diffing the two reports is not sufficient — two files with offsetting extraction errors produce a
matching aggregate, a matching entry count, and a matching top-10, so the report diff comes back
clean while the parser is broken. Both implementations were therefore instrumented to dump their
CONTRIB_FILE (the per-file entry_len ⇥ skill ⇥ root rows) and those were diffed first:

$ diff baseline.contrib new.contrib && echo "IDENTICAL TRIPLES ($(wc -l < new.contrib) rows)"
IDENTICAL TRIPLES (144 rows)

$ diff baseline.out new.out && echo "IDENTICAL REPORT"
IDENTICAL REPORT

144/144 per-file rows identical, aggregate 94,468 identical, top-10 ordering identical. Emission
order into the contribution file is preserved by construction (roots in argv order, glob order per
root), so sort -t $'\t' -k1,1nr's tie-breaking cannot shift.

A port, not a rewrite

The awk program reimplements, behaviour for behaviour, the four helpers the loop shelled out to:
skill_frontmatter::extract; ::field — including block-scalar unfolding for | and > and the
quote-aware trailing-comment strip with its doubled-single-quote case; ::strip_quotes (ONE outer
layer, double or single, never both, and never a lone quote character); and
normalize_bool / trim_ws, whose deliberate refusal to fold YAML 1.1's yes/on aliases is
carried across with its reasoning.

Two behaviours the old pipeline got for free from command substitution are reproduced explicitly
and commented as such, because they were load-bearing rather than incidental:

  • fm="$(…extract…)" stripped trailing newlines from the extracted frontmatter, so a trailing
    blank line could never append a separator inside a block scalar — and an all-blank block counted
    as no frontmatter at all.
  • "$(…field…)" stripped trailing newlines from each field's value. A folded (>) scalar joins
    with spaces, which command substitution does not strip, so only newlines are removed.

The file list reaches awk on stdin, not as operands: awk reads an operand containing = as a
variable assignment, so a path with = in it would be silently swallowed instead of read. The root
travels alongside each path rather than being re-derived from it, because the report prints the root
string the caller passed and a derivation would have to reproduce its exact spelling to stay
byte-identical.

skill-frontmatter.sh is no longer sourced here — its helpers are the per-call execs that caused
this. The library file itself is unchanged and check-skill.sh remains its consumer.

No flag, no option, and no output format changed. skill-quality 0.15.2 → 0.15.3 (patch —
byte-identical output is the reason).

The locale trap, handled rather than hoped past

The pre-port script measured with bash ${#var}; the port measures with awk length(). Both are
locale-dependent and they can disagree — mawk ignores the locale and counts bytes while gawk does
not. A fixture asserting a hardcoded character count would encode one environment's answer and
fail elsewhere for the wrong reason, so case 18 asserts an equivalence: the script's reported
aggregate for a multibyte description must equal ${#UTF8_DESC} computed in the same shell. A
divergence is then reported as exactly what it is. On this machine both say 5
(GNU Awk 5.4.0, LANG=en_US.UTF-8).

Test plan

Five new cases; the full suite:

$ bash plugins/skill-quality/scripts/check-listing-budget.test.sh
...
ok   - a trailing YAML comment is excluded from the measured description
ok   - a plain scalar drops its trailing comment but keeps a non-comment #
ok   - a # inside a block scalar is content and is still measured
ok   - a literal block scalar joins with newlines and drops the trailing blank
ok   - a doubled single quote is content; the trailing comment is still cut
ok   - multibyte description measures the same as the shell's own ${#var} (5 chars)
ok   - 200-file corpus measured in 1s (bound: 30s)
all assertions passed

Byte-identity over the real tree, both implementations run over the same worktree:

pre-port  (merge base) : rc=0 elapsed_ms=232534   144 skills, aggregate 94468
single-pass            : rc=0 elapsed_ms=2180     144 skills, aggregate 94468
report diff            : empty
per-file triple diff   : empty (144 rows)

The wall-clock bound discriminates — measured, not assumed. Same 200-file fixture corpus the test
builds, both implementations:

PRE-PORT   : 194s over 200 files
SINGLE-PASS:   1s over 200 files
bound in the test: 30s
--- identical output over the fixture corpus? ---
YES - byte-identical

The 30s bound is deliberately very loose against a ~1s target: this runs in required CI, and a tight
timing assertion is a flaky gate — worse than the defect it guards. It fails only on a return to
per-file process spawning, which is two orders of magnitude away.

Lint:

$ shellcheck -x plugins/skill-quality/scripts/check-listing-budget.sh \
    plugins/skill-quality/scripts/check-listing-budget.test.sh
(no output)

$ bash scripts/check-shell-portability.sh $(git merge-base origin/main HEAD)
No unexcused GNU-only constructs in 2 shell file(s).

This change reads the same files the script already read and writes only its own temp files — no new
external read or write, no new grant, no hook. No security review note applies.

Related

…-identical and ~100x faster

`bash check-listing-budget.sh plugins/*/skills` took 289s on Windows (Git Bash)
and, run in the foreground by an agent, was killed at 180s with exit 143 and
zero output. Re-measured at this branch's merge base before the port: 232s. That
command is verbatim what #2023's procedure and .github/recurring-schedule.json's
listing-budget-watch row instruct an operator to run each cycle, so on the one
machine where the routine is actually driven, a report-only drift watch silently
produced nothing.

The cause is process-spawn cost, not the machine: the per-file loop spent at
least eleven forked subshells and five external process execs (4x awk, 1x tr) on
every one of ~200 SKILL.md files — on the order of 2,000 spawns. Process
creation costs roughly two orders of magnitude more on Windows than on Linux,
which is why CI (ubuntu-24.04) never surfaced it.

The same run now takes 2.2s, and the output is byte-identical. Proved by diffing
the PER-FILE contribution rows of both implementations over the same tree, not
just their reports: 144/144 rows identical, aggregate 94,468 identical, top-10
ordering identical. Per-file rows are the load-bearing comparison — two files
with offsetting extraction errors produce a matching aggregate and a clean report
diff while the parser is broken.

This is a port, not a rewrite. The awk program reimplements, behaviour for
behaviour, the four helpers the loop shelled out to: extract, field (block-scalar
unfolding for | and >, quote-aware trailing-comment strip including the
doubled-single-quote case), strip_quotes (one outer layer, double OR single), and
normalize_bool/trim_ws. Two behaviours the old pipeline got free from command
substitution are reproduced explicitly and commented: trailing newlines stripped
from the extracted frontmatter, and from each field's value.

Corpus equivalence is not parser equivalence, so four fixtures pin the shapes
most likely to diverge, including a multibyte description asserted as an
EQUIVALENCE against the shell's own ${#var} rather than a hardcoded count — awk
length() and ${#var} can disagree where awk counts bytes and the shell counts
characters. A fifth bounds wall clock over a 200-file corpus at 30s: the pre-port
script takes 194s on that corpus, the single pass 1s, so the bound discriminates
while staying far too loose to flake in required CI.

The file list reaches awk on stdin rather than as operands, because awk reads an
operand containing `=` as a variable assignment and would swallow such a path.

skill-frontmatter.sh is no longer sourced here; the library is unchanged and
check-skill.sh remains its consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 0s —— View job


Security review complete

  • Gather context (PR diff, changed files)
  • Review check-listing-budget.sh for injection / unsafe handling
  • Review check-listing-budget.test.sh
  • Review plugin.json / CHANGELOG.md for permission-widening or supply-chain changes
  • Post final findings

No security issues found. This PR is a behavior-preserving performance refactor of a local, report-only CLI script (bash loop → single awk pass). It has no network calls, no secrets/credentials, no GitHub Actions changes, and no permission changes (plugin.json is a version bump only, 0.15.2 → 0.15.3).

What I checked specifically

  • Command/argument injection: The rewrite actually removes the injection-relevant surface rather than adding one — the old code shelled out to awk/tr per file (~11 forked subshells + 5 execs per SKILL.md); the new code has zero system()/exec-equivalent calls inside the awk program. File paths are transported as data lines in a tab-separated temp file ($FILE_LIST, created via mktemp) that is passed to awk as a single filename operand — see check-listing-budget.sh#L353. This deliberately avoids the documented risk of a path containing = being swallowed as an awk variable assignment if passed as a command-line operand directly.
  • Path handling: getline ln < path (check-listing-budget.sh#L338) opens path via a direct syscall inside awk — no shell interpolation, so no path/command injection even though path is glob-derived from repo content.
  • Temp files: Both CONTRIB_FILE and FILE_LIST are created with mktemp/mktemp -d (safe, non-predictable, race-free) and cleaned up via trap ... EXIT. No sensitive data involved.
  • Trust boundary: The script only reads files under caller-specified roots inside the repo checkout (same boundary as before the port) and never evaluates file contents as code — frontmatter is parsed as text with bounded, non-backtracking POSIX awk regexes (no ReDoS concern).
  • Supply chain / CI / permissions: No workflow files, dependency pins, or plugin.json permission fields touched — only the version string and changelog prose changed.

One non-security robustness note (not reported as a finding per the review scope): the new tab/newline-delimited transport between bash and awk (SKILL_ENTRIES$FILE_LIST) would misparse a skill directory name that itself contained a literal tab or newline byte, since those are used as the field/record separators. This can't cross a privilege boundary (it requires already controlling a file inside the trusted checkout, same as the pre-port code) and at worst produces a mis-attributed report row in this report-only, exit-0 tool — so it doesn't rise to a security finding, but may be worth a fixture if the authors want to harden it further.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8335a11f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/skill-quality/scripts/check-listing-budget.sh
Comment thread plugins/skill-quality/scripts/check-listing-budget.sh
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 18s —— View job


Review complete

  • Gather context (diff, CLAUDE.md, REVIEW.md)
  • Review check-listing-budget.sh awk port for correctness
  • Review check-listing-budget.test.sh new test cases
  • Review CHANGELOG.md / plugin.json version bump
  • Post findings as inline comments + summary

Environment note: repo CI/test execution (the sandbox in this review lane) requires interactive approval for running shell scripts, git init, etc., and none was available here — so I could not execute check-listing-budget.test.sh or the script itself to independently confirm the PR's "byte-identical" / timing claims. Everything below is from manually reading and hand-tracing the diff against the unchanged skill-frontmatter.sh helpers it replaces (line by line, including the block-scalar join, quote-stripping, and comment-stripping paths) — not from running the code. Treat the PR's own Test Plan output as author-claimed and unverified by me.

What I checked

I traced the new single-pass awk program in plugins/skill-quality/scripts/check-listing-budget.sh (lines 130–422) against the four bash/awk helpers it replaces in skill-frontmatter.sh (extract, field, strip_quotes, and the removed normalize_bool/trim_ws), for several representative cases:

  • Frontmatter fence detection (must be line 1; second --- ends the block) — matches.
  • Trailing-blank-line stripping via the while (FN > 0 && FM[FN] == "") FN-- loop, reproducing what command substitution used to strip for free — matches, including the "all-blank frontmatter counts as none" case.
  • Literal (|) vs folded (>) block-scalar joining, including a blank line mid-block before a following key — hand-traced against the original getline-based loop and got identical output for the description in test 16 ("abc\nde", 6 chars).
  • The quote-aware trailing-comment strip and its doubled-single-quote handling (test 17: 'it''s ok' → 8 chars after strip_quotes) — matches; note this doesn't collapse '' to a literal ' in either the old or new implementation, so this is pre-existing, unchanged behavior, not a port regression.
  • getline ln < path (not bare getline) is used for reading each SKILL.md, correctly avoiding clobbering the outer record's $1/$2 (root/path) — a detail that would have been an easy mistake.
  • > out inside the awk program is a single open+append stream for the life of the process (not per-record truncation), and close(out)/close(path) are both present, so CONTRIB_FILE is fully flushed before the shell reads it afterward — correct.

I didn't find a functional/correctness bug in the port itself. Two inline nits, both cosmetic/low severity:

  • check-listing-budget.sh:407 — a comment lost its apostrophe ("check 2 own FAIL") working around the single-quoted awk program; reads as a typo.
  • check-listing-budget.sh:400strip_trailing_nl() around the disable-model-invocation field is redundant given normalize_bool()'s own trim_ws() already strips trailing whitespace/newlines; harmless, just a needless call on every scanned file.

Also confirmed:

  • Temp-file lifecycle is correct: both CONTRIB_FILE and the new FILE_LIST are cleaned up via the trap ... EXIT at line 224.
  • No output-format/report code was touched (lines 435–459 are untouched by the diff), consistent with the "no flag, no output format changed" claim.
  • plugin.json version bump (0.15.2 → 0.15.3) and the CHANGELOG entry are consistent with a patch-level, behavior-preserving perf fix.
  • Per REVIEW.md's lane split, this repo has a security lane, so I left security-scoped concerns (there weren't any of note here — no new external input reaches a shell, getline ... < path is awk's own file I/O, not shell-interpreted) to that lane.

Nothing here blocks merging; the two nits above are optional polish.

Comment thread plugins/skill-quality/scripts/check-listing-budget.sh
Comment thread plugins/skill-quality/scripts/check-listing-budget.sh
@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton
kyle-sexton merged commit 6164a7b into main Aug 12, 2026
35 checks passed
@kyle-sexton
kyle-sexton deleted the perf/2216-listing-budget-single-pass branch August 12, 2026 04:17
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…strophe-less comment (#2348)

## Summary

Follow-up to #2322, which merged before its two advisory review findings
were addressed. Both are on
`plugins/skill-quality/scripts/check-listing-budget.sh` and both are
live on `main` in 0.15.3. They
are nits, not correctness bugs — but this is the file whose entire point
is byte-identical output, so
neither was taken on faith and the identity was re-proved rather than
assumed.

### 1. `:400` — a redundant `strip_trailing_nl`

`normalize_bool` opens with `trim_ws`, whose trailing `sub` uses
`[[:space:]]` — a class that matches
a newline. So the strip removed a strict subset of what the very next
call removed, on every scanned
file's `disable-model-invocation` field. Verified rather than reasoned:

```
$ awk 'BEGIN{
    v = "  True\n\n"
    a = v; sub(/^[[:space:]]+/,"",a); sub(/[[:space:]]+$/,"",a)
    b = v; sub(/\n+$/,"",b); sub(/^[[:space:]]+/,"",b); sub(/[[:space:]]+$/,"",b)
    printf "trim_ws only            -> [%s] len=%d\n", a, length(a)
    printf "strip_trailing_nl+trim  -> [%s] len=%d\n", b, length(b)
    printf "identical: %s\n", (a==b ? "YES" : "NO")
    t = "x\n"; sub(/[[:space:]]+$/,"",t)
    printf "[[:space:]] strips a bare newline: %s\n", (t=="x" ? "YES" : "NO")
  }'
trim_ws only            -> [True] len=4
strip_trailing_nl+trim  -> [True] len=4
identical: YES
[[:space:]] strips a bare newline: YES
```

**The reviewer's own scoping is honoured: it is NOT removed at
`:401–402`.** There it runs before
`strip_quotes`, which trims nothing — a value still ending in a newline
has that newline as its last
character, so the closing quote never matches and the quote marks would
survive into the measured
length. Confirmed the same way (`substr(s, length(s), 1)` on `"abc"\n`
is the newline, not `"`).

That asymmetry now carries a comment at the site, because otherwise it
reads as an oversight worth
"fixing" — which would silently inflate every quoted description by two
characters.

### 2. `:407` — an apostrophe dropped to survive the awk quoting

`"check 2 own FAIL"` reads as a typo. **Rephrased** to `"the FAIL check
2 already raises"` rather than
escaped: one apostrophe does not justify a `'"'"'` sequence inside an
awk program, which is a
readability cost with no payoff.

### The byte-identity re-proof, against a fresh baseline

The original proof's baseline was captured at `a0abaf81`. `main` has
since **gained a skill**
(`plugins/claude-ops/skills/inventory`, absent at `a0abaf81`) and edited
two descriptions
(`discovery/research` 666→900, `docs-hygiene/audit-encapsulation`
350→408), so re-using it would have
measured tree drift and reported a false difference. Instead the **last
pre-port revision of the
script** (`def5f67b`, the parent of the port merge) was run over
**today's tree**, with both
implementations instrumented to dump their per-file contribution rows:

```
PRE-PORT (def5f67) : rc=0  324s
CURRENT (0.15.4)    : rc=0  2s

--- per-file contribution rows ---
IDENTICAL (145 rows)

--- report ---
IDENTICAL
Shared listing-budget estimate over 145 listing-eligible skill(s) across 65 root(s):
  aggregate: 95735 chars
  budget:    8000 chars (documented default (SLASH_COMMAND_TOOL_CHAR_BUDGET fallback))
```

Per-file rows are the load-bearing comparison, not the report: two files
with offsetting extraction
errors produce a matching aggregate and a clean report diff while the
parser is broken.

`skill-quality` 0.15.3 → **0.15.4** (patch — output is unchanged). The
shipped 0.15.3 entry keeps the
wording it shipped with; the CHANGELOG diff is **27 insertions, 0
deletions**.

## Test plan

```
$ bash plugins/skill-quality/scripts/check-listing-budget.test.sh
...
ok   - a trailing YAML comment is excluded from the measured description
ok   - a plain scalar drops its trailing comment but keeps a non-comment #
ok   - a # inside a block scalar is content and is still measured
ok   - a literal block scalar joins with newlines and drops the trailing blank
ok   - a doubled single quote is content; the trailing comment is still cut
ok   - multibyte description measures the same as the shell's own ${#var} (5 chars)
ok   - 200-file corpus measured in 2s (bound: 30s)
all assertions passed

$ shellcheck -x plugins/skill-quality/scripts/check-listing-budget.sh
(no output)

$ git diff --numstat origin/main -- plugins/skill-quality/CHANGELOG.md
27      0       plugins/skill-quality/CHANGELOG.md     # pure insertion: 0.15.3's entry untouched
```

Comment-only and expression-only changes to one awk program; no flag, no
option, no output format,
and no new read or write. No security review note applies.

## Related

- No linked issue — these are the two advisory review findings from
#2322, which merged before they
  were addressed; they are nits with no separate tracked issue.
- Follow-up to #2322 (merged), which closed #2216.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(skill-quality): check-listing-budget.sh takes 289s over plugins/*/skills on Windows, so #2023's own cycle command cannot complete in the foreground

1 participant