Skip to content

fix(ci): fetch each commit's workflow once in the Bun crash census and skip comment lines in jobs - #489

Merged
roodboi merged 4 commits into
nextfrom
wf/g100-crash-census-followups
Oct 24, 2026
Merged

roodboi merged 4 commits into
nextfrom
wf/g100-crash-census-followups

Conversation

@roodboi

@roodboi roodboi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Refs HACK-1441

Follow-ups from the #476 verifier on scripts/ci-bun-crash-census.ts. Stacked on #476: #476 is still open, so this branch merges its head (e3814e87) on top of next (1281184b). Until #476 lands, this PR's diff also shows #476's changes. Only commit 159df1e0 belongs to this PR.

Root cause

  1. Cache key had no test. If you change const cacheKey = `${sha}\n${job}` to const cacheKey = sha, all 16 existing tests still pass. With a wildcard selector, a sha-only key gives every job at that sha the version of whichever job resolves first. For example, the test fan-in (no Bun) and a shard (pinned Bun) would share one result.
  2. Column-0 comments ended the job scan. workflowJobBunVersion stopped at the first non-indented line after jobs:. A column-0 # comment inside jobs: therefore hid every later job, and the function returned null for those jobs.
  3. One fetch per job name. The only cache was keyed by (sha, job), so the same ci.yml was fetched once for each distinct job name at a commit. That is up to 4 GETs per sha for the shards, plus 1 for the fan-in.

Fix

  • Add a per-sha cache of the decoded workflow text. Each sha now costs one contents GET, whatever the number of jobs, runs or concurrent workers. The cache stores the promise, so concurrent runs at the same sha share the in-flight request.
  • Keep the per-(sha, job) version memo on top of that cache. A comment explains why the key must never be the sha alone.
  • workflowJobBunVersion now skips blank lines and lines that start with # in column 0. Any other column-0 line is still the next top-level key and still ends the jobs.
  • The header doc now says the workflow is fetched once per commit.

Tests (tests/ci-bun-crash-census.test.ts, new workflow reads (HACK-1441) block)

  • Comment and blank lines: a workflow with a column-0 comment, blank lines, a commented-out job and a top-level env: key after jobs:. Shard, lint, fan-in, commented-out-job and post-jobs: lookups each return the right value.
  • Per-job version under --job "test*": three runs, two of them at the same sha. In one run the fan-in is listed first; in another a shard is listed first. Every shard gets 1.4.2 workflow, the fan-in gets unknown, and the expected warning is no_bun_setup 2/9.
  • Fetch count: the same fixture at concurrency 1 and 3 makes exactly one contents GET per sha (2 in total).

Evidence

  • Focused tests: bun test tests/ci-bun-crash-census.test.ts gives 19 pass, 0 fail (16 existing + 3 new), on Bun 1.4.2.

  • Mutation results. Each mutation was applied to the fixed script, tested, and then the file was restored:

    Mutation Result
    M1: cacheKey = sha killed by the per-job version test
    M2a: column-0 comment ends the scan again killed by the comment test and the version test
    M2b: full revert to break-only behavior killed
    M2c: skip every column-0 line (never end jobs:) killed by the comment test (late lookup)
    M3: no per-sha text cache (the pre-fix behavior) killed by the fetch-count test
  • Gates: root bun run check passes (bun-version, privacy:check, turbo check; existing warnings only). bun run --cwd packages/cli typecheck has 0 errors and covers tests/, which imports the script. Biome is clean on both changed files. gitleaks 8.30.1 (gitleaks dir over a git log -p origin/next..HEAD export) found no leaks.

  • No CLI surface or consumer agent guidance changed, so I did not run docs:cli-reference or generate:agent-plugins.

  • Full suite: hosted CI test shards at this PR's exact head.

Gaps

  • No live gh census was run against GitHub. The behavior is proven with the fake GhReader only.
  • If #476 is squash-merged before this PR, next has to be merged into this branch again. Expect a possible conflict at the end of the test file, because both sides add lines after the same last block.

Round 2: merge next after #476 landed

#476 was squash-merged into next as 101d2638. This branch still carried #476 as the merge commit 0482673b, so the PR conflicted with next (d09e1b73) in scripts/ci-bun-crash-census.ts and tests/ci-bun-crash-census.test.ts.

  • Fix: merge commit 499e8019 merges next (d09e1b73) into the branch and keeps the PR side of both census files. Before resolving, I checked that fix(ci): read the test shard jobs in the Bun crash census and fix stale CI test job docs #476's five files are byte-identical between 0482673b and d09e1b73 (git diff is empty), so the PR side differs from next only by the HACK-1441 changes.
  • Diff now: git diff next HEAD is only the two HACK-1441 files (+177/-20). The "Stacked on fix(ci): read the test shard jobs in the Bun crash census and fix stale CI test job docs #476" note above no longer applies, because fix(ci): read the test shard jobs in the Bun crash census and fix stale CI test job docs #476's changes are already in next.
  • Focused tests at 499e8019: 19 pass, 0 fail, 87 expects (Bun 1.4.2).
  • Mutations re-run at 499e8019: each was applied, tested and then restored. M1 (cacheKey = sha) was killed by the per-job version test. M2 (skip block removed) was killed by the comment test and the per-job test. M3 (per-sha text cache never hits) was killed by the fetch-count test.
  • Gates at 499e8019: root bun run check exits 0 (privacy-check ok). bun run --cwd packages/cli typecheck exits 0 with 0 errors. Biome is clean on both files. gitleaks 8.30.1 (gitleaks dir over a git log -p origin/next..HEAD export plus the net diff) found no leaks.
  • Hosted CI at exactly 499e8019: run 40120172542 passed all 20 checks, including test shards 1/4 to 4/4, docker-e2e shards 1/4 to 4/4, secret-scan, Candidate core, Config compiler (all platforms), Runtime state models, linux-process-lifetime and runtime-images. This replaces the earlier evidence from 159df1e0.
  • Remaining: after CI started, next gained one more commit, 78487f72 (test(native): import adopted refusal messages and size binding test budgets for load (HACK-1432) #487). git merge-tree reports a clean merge, and that commit touches no census files. GitHub shows BEHIND, not a conflict.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

roodboi and others added 3 commits October 24, 2026 02:02
…le CI test job docs

Since #442 the `test` job is only the required fan-in of the four
`test shard i/4` jobs. It runs no Bun, but `ci:bun-crash-census` still
read it by default and reported zero crashes without saying so, and
`workflowJobBunVersion(ci.yml, "test")` found the fan-in block, which
has no setup-bun.

- With no --job, the census reads every `test shard i/n` job of a run,
  or the `test` job of a run that has no shard job (a run from before
  the split, whose `test` job ran the suite).
- --job accepts `/` and a `*` pattern, so the documented
  `--job "test shard 1/4"` and `--job "test shard *"` work; before,
  both were refused as an invalid argument.
- The workflow lookup matches a job by its `name:` template, so
  `test shard 2/4` finds the `test-shard` matrix job's Bun pin.
- The report prints a closed `warning:` line (also on stderr, and in
  --json) when no job matched or a selected job shows no Bun setup in
  its log or workflow block.
- docs/security/env-secrets-threat-model.md and
  docs/reference/shared-host-lifecycle.md now say the tests run in a
  `test shard i/4` job and that `test` is the fan-in.

Refs HACK-1424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d skip comment lines in jobs

The census resolved a job's Bun version from the workflow file at the run's
head commit through a cache keyed by (sha, job), so one commit cost one
contents GET per distinct job name. It now caches the decoded workflow text
per sha and keeps the per-(sha, job) version memo on top, with a comment on
why the key must not be sha alone.

workflowJobBunVersion ended the jobs scan at any column-0 line, so a
column-0 YAML comment inside jobs: hid every later job. Blank lines and
column-0 comments are now skipped; any other column-0 line still ends jobs.

Tests pin the (sha, job) key with a wildcard selector over the fan-in and
shards at one sha, count contents fetches across runs and concurrency, and
cover comment, blank, commented-out-job and later top-level-key lines.

Refs HACK-1441

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 24, 2026

Copy link
Copy Markdown

HACK-1441

Resolve the conflict with #476's squash (101d263) by keeping the PR side
of scripts/ci-bun-crash-census.ts and tests/ci-bun-crash-census.test.ts.
The #476 content is identical on both sides; only the HACK-1441 changes differ.

Refs HACK-1441

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 24, 2026 21:45
@roodboi
roodboi merged commit 8c73ec8 into next Oct 24, 2026
21 checks passed
@roodboi
roodboi deleted the wf/g100-crash-census-followups branch October 24, 2026 21:45
roodboi added a commit that referenced this pull request Oct 24, 2026
…ow parser (#503)

## Summary

Two small follow-ups to #489 in `workflowJobBunVersion` (`scripts/ci-bun-crash-census.ts`).

### Root cause

1. **Commented `jobs:` line.** The parser found the jobs section with `line.trimEnd() ===
"jobs:"`. A valid workflow whose `jobs:` line has a trailing YAML comment (`jobs: # all jobs`)
therefore had no jobs, and every job read as having no Bun version. 2. **Dead blank-line
clause.** The job scan skipped lines with `line.trim() === "" || line.startsWith("#")`. Removing
the blank-line half changed no result: a blank or whitespace-only line has no non-space first
character (so it does not end the jobs) and matches none of the job-key, `name:` or `bun-
version` patterns (so it does not change a block). No test can fail without it, so the JSDoc
claim that blank lines are "skipped" described code that did nothing.

### Fix

- Strip a whitespace-led trailing comment (`/\s+#.*$/`) before matching `jobs:`. A `#` with no
  space before it (`jobs:#`) is not a YAML comment and still does not match.
- Remove the redundant blank-line clause and correct the JSDoc to say why blank lines need no
  skip.

### Tests

- New: `a trailing comment on the jobs: line still opens the jobs (HACK-1462)`. It covers `jobs:
  # all jobs` with a whitespace-only line inside the job, plus the `jobs:#` negative case.
- Existing: `blank and column-0 comment lines inside jobs: do not end the job scan` already has
  blank lines between jobs and still holds the documented behavior.

### Evidence

Focused suite `tests/ci-bun-crash-census.test.ts`: 20 pass, 0 fail. Mutation results:

| Mutation | Result | | --- | --- | | M1: match `jobs:` with plain `trimEnd()` (pre-fix) | new
test fails (19 pass, 1 fail) | | M2: comment regex `\s*#` (no whitespace needed) | new test
fails on the `jobs:#` case | | M3: a blank line ends the jobs scan | 4 tests fail, including the
blank-line test and the real `ci.yml` test | | M4: put back the removed blank-line clause | 20
pass: the clause makes no difference to behavior |

Gates: root `bun run check` (includes `privacy:check`) passes with exit 0. `bun run --cwd
packages/cli typecheck` has 0 errors. Biome reports no issues on both changed files. gitleaks
8.30.1 found no leaks in `origin/next..HEAD`. The CLI surface and agent guidance did not change,
so neither needs regeneration. For the full suite, see hosted CI `test` at this head.

### Gaps

- Job-key lines (`  test: # note`) and `name:` lines with trailing comments are still not
  handled. They are outside this unit's scope and are reported separately.

Refs HACK-1462

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 24, 2026
… or non-ASCII task programs (HACK-1438, HACK-1461) (#490)

Refs HACK-1438

## Root cause

The task-catalog denylist (`isInlineCommandArgv` in `src/lib/task-catalog-recipe.ts`) did not
model the `git send-email` options that run a command. On `next` (1281184), every one of these
was accepted:

- `--to-cmd`, `--cc-cmd`, `--header-cmd` and `--sendmail-cmd`, in both the `--opt cmd` and
  `--opt=cmd` forms.
- `--smtp-server=/abs/path`. send-email runs an absolute path given here as a sendmail program.

A second reason it was open: `git send-email` is a Perl script that reads its options with
Getopt::Long's defaults (`pass_through` only). Getopt::Long's grammar is wider than Git's parse-
options. It accepts `--`, `-` or `+` before a long name, ignores letter case, and accepts any
unique prefix. So even a rule written for `--to-cmd` would miss `-to-cmd=cmd`, `+to-cmd=cmd` and
`--CC-CMD=cmd`. I confirmed each spelling against real Git 2.56 in a scratch repo. Each one ran
a marker command: `--to-cmd X`, `--to-cmd=X`, `--cc-cmd=X`, `--header-cmd=X`, `--sendmail-
cmd=X`, `--smtp-server=/abs`, `--cc-cm=X`, `--hea=X`, `--sendm=X`, `--to-cm=X`, `--CC-CMD=X`,
`-cc-cmd=X`, `-to-cmd=X`, `-header-cmd X`, `-sendmail-cmd=X`, `+to-cmd=X`, `-smtp-server=/abs`,
and `git Send-Email --cc-cmd=X`. The last one works because APFS is case-insensitive, so Git
finds the `git-send-email` file for `Send-Email`.

The `-c sendemail.ccCmd=…` / `toCmd` / `headerCmd` / `sendmailCmd` / `smtpServer` config forms
also ran, including identity-scoped `sendemail.<id>.ccCmd`. They were already refused, because
`git -c` and `--config-env` are refused whatever the key. This PR adds tests for them.

## Fix (`src/lib/task-catalog-recipe.ts`)

- **New `subcommandGetoptLong` table** on the evaluator, plus a `getoptLongSelects` matcher. The
  matcher strips a `--`, `-` or `+` prefix, takes the name up to `=`, and lowercases it. It
  refuses the argument when the name is a prefix of an inline option. The one exception is a
  name that exactly matches another option (`to`, `cc`, `h`), because Getopt::Long takes an
  exact name as that option. `git send-email` gets `to-cmd`, `cc-cmd`, `header-cmd`, `sendmail-
  cmd` and `smtp-server`.
- **Subcommand scoping.** The table is scoped to the `send-email` operand the same way
  `subcommandInline` is, so other commands are not refused (`git rev-list --header`, `git status
  -s`, `git format-patch --to=…`, `git log --cc`). The dashed form (`git-send-email`,
  `/usr/lib/git-core/git-send-email`) goes through the existing dashed-builtin mapping.
- **Case-insensitive lookup.** Both subcommand-keyed tables now look up operands lowercased,
  because of the case-insensitive filesystem point above.
- **Sweep additions**, each refused the same way and each confirmed against real Git where it is
  installed (see the sweep below):
  - `grep -O` / `--open-files-in-pager` (scoped to `grep`)
  - `daemon --access-hook` (scoped to `daemon`)
  - `instaweb -d` / `--httpd` (scoped to `instaweb`)
  - `svn --authors-prog` (scoped to `svn`)
  - the `remote-ext` word
  - `ext::` URLs (new `extUrls` flag)
  - the `archimport` word

## Tests (`tests/project-task-catalog.test.ts`)

- **`HACK-1438: git send-email options that run a command`** (gap case).
  - Refused: every spelling listed above, plus the dashed, absolute-path, behind-`--git-dir` and
  mixed-case forms. Also refused: the `-c` config forms in any key case, identity-scoped and
  `smtpServer` keys, `--config-env`, and `-c sendemail.ccCmd=… -c alias.se=send-email se`, which
  only the `-c` rule catches.
  - Accepted: `--to=… --cc …`, `--suppress-cc=all`, `--sender=…`, `--no-header-cmd`, `--smtp-
  server-port=587`, `--no-to`, `-h`, and the same option names under other subcommands.
- **`HACK-1438: Git porcelain options that run a command`** (gap case). Covers the sweep
  additions, with scoped accepted controls: `git grep -n`, `git grep --only-matching --or`, `git
  branch -d`, `git svn fetch --authors-file=…`, `git daemon --export-all`, `git instaweb
  --stop`, an `https://` remote and `git add extension.txt`.
- **`HACK-1438: documented send-email and grep fail-closed collateral stays refused`.** Covers
  `send-email -s`, `--smtp`, `--smtp-server=mail`, `git grep -o`, and a path operand `Grep`
  scoping `-O`.

## Evidence

- **Before and after.** I probed `isInlineCommandArgv` directly with 24 argvs that should be
  refused. On `next`, 22 of them were accepted; only the 2 `-c`/`--config-env` forms were
  refused. After the change, all 24 are refused, and the probed controls (`--to=…`, `--cc=…`,
  `git log --cc`, `git format-patch --to=…`) are still accepted.
- **Mutation testing.** I applied 25 mutations one at a time, ran `-t HACK-1438` after each, and
  then restored the source. Every mutation fails at least one new test:

| Mutation | Failing tests |   | --- | --- |   | Drop the Getopt::Long check | 2 |   | Accept
only the `--` prefix | 2 |   | Drop the `+` prefix | 1 |   | No abbreviations | 2 |   | Case-
sensitive option name | 1 |   | Drop the exact list | 1 |   | Case-sensitive subcommand lookup |
2 |   | Unscoped (every `git` argv) | 1 |   | Drop each of `to-cmd`, `cc-cmd`, `header-cmd`,
`sendmail-cmd` | 1 each |   | Drop `smtp-server` | 2 |   | Drop `git -c` | 1 |   | Drop the
`grep` scope | 2 |   | Drop `--open-files-in-pager` | 1 |   | Drop `daemon` | 1 |   | Drop
`instaweb -d` | 1 |   | Drop `instaweb --httpd` | 1 |   | Drop `svn` | 1 |   | Drop the `remote-
ext` word | 1 |   | Drop the `archimport` word | 1 |   | Drop `ext::` URLs | 1 |   | Case-
sensitive `ext::` | 1 |   | Unscoped `subcommandInline` | 1 |

- **Focused tests.** `bun test tests/project-task-catalog.test.ts` reported 40 pass, 0 fail. It
  ran with a private HOME, HACK_HOME and DOCKER_CONFIG and
  `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`, and with `--timeout 60000`. The run with the
  default 5 s timeout hit 3 timeouts in slow existing domain tests (tracked pins, stale service
  definition, edit during read) while the host load average was about 106. None of the timeouts
  were in the changed tests.
- **Gates.** Root `bun run check` passed, including `privacy-check: ok`, with only existing
  warnings and none in the changed files. `bunx biome check` on the changed files was clean.
  `bun run --cwd packages/cli typecheck` reported 0 errors. gitleaks 8.30.1 over
  `origin/next..HEAD` found no leaks.
- **Not regenerated.** The CLI surface and consumer agent guidance did not change, so the CLI
  reference and plugin bundles were not regenerated.
- **Full suite.** The hosted CI `test` job at this head.

## Git porcelain sweep

Sources: `--git-completion-helper-all` for every command that `git --list-
cmds=builtins,main,others,nohelpers` lists on Git 2.56, plus the script sources on `git/git`
master (`git-send-email.perl`, `git-svn.perl`, `perl/Git/SVN.pm`, `git-instaweb.sh`,
`git-p4.py`, `git-cvsimport.perl`, `git-cvsexportcommit.perl`, `git-archimport.perl`, `git-
cvsserver.perl`, `git-web--browse.sh`, `git-mergetool.sh`, `git-difftool--helper.sh`, `git-
submodule.sh`, `daemon.c`, `builtin/grep.c`, `imap-send.c`). Version history (2.19 and later)
comes from the existing table and #463/#475.

| Option or form | Runs | Status | | --- | --- | --- | | `send-email --to-cmd/--cc-cmd/--header-
cmd/--sendmail-cmd` | shell command | **added** (Getopt::Long spellings) | | `send-email --smtp-
server=/abs` | sendmail program | **added** (any value, fail-closed) | | `-c`/`--config-env`
`sendemail.*Cmd`, `sendemail.smtpServer`, `core.sshCommand`, `core.pager`, `core.fsmonitor`, ...
| config | already refused (`-c`/`--config-env` refused whatever the key) | | `grep -O<pager>` /
`--open-files-in-pager=<pager>` | shell (`use_shell`) | **added**, scoped to `grep` | | `daemon
--access-hook=<cmd>` | shell (`use_shell`) | **added**, scoped to `daemon` | | `instaweb
-d/--httpd=<cmd>` | the daemon command line | **added**, scoped to `instaweb` | | `svn
--authors-prog=<cmd>` | backticks in `Git::SVN` | **added**, scoped to `svn` (git-svn is not
installed here; read from source) | | `git remote-ext <remote> <url>`, `ext::<cmd>` URLs | the
URL as a command | **added**. Git's default `protocol.ext.allow=never` blocks it, but config can
allow it. `EXT::` is also blocked by Git ("cannot handle remote-EXT as a builtin"). | | `git
archimport <archive/branch>` | operand pasted into `tla abrowse … $limit \|` | **added**. `git
archimport 'x/y;cmd #'` ran `cmd` even without tla. | | `--upload-pack`, `--receive-pack`,
`--exec` (`clone`, `fetch`, `pull`, `ls-remote`, `push`, `archive`, `fetch-pack`, `send-pack`),
`clone -u`, `rebase --exec/-x`, `difftool --extcmd/-x`, `submodule foreach`, `bisect run`,
`filter-branch`, `rebase--helper --add-exec-commands` | shell | already modeled. `ls-remote` has
no `-u` (its `--exec` is a hidden alias and is covered). | | `rebase--interactive --cmd`
(2.20–2.33) | shell | in #475 (HACK-1423), not on `next` yet | | `column --command` | config
lookup only | not a command | | `for-each-ref --shell`, `cat-file --batch-command` | quoting /
stdin protocol | not a command | | `difftool/mergetool -t/--tool`, `web--browse -b`, `instaweb
-b` | name must be a known tool or a configured `*.cmd` | config only | | `git hook run`,
`clone/init --template` (template hooks run during clone), `--exec-path`, `merge/rebase/pull -s
<name>` (`git-merge-<name>` on `PATH`), `transport::address` URLs (`git-remote-<transport>`) | a
program file, not a string | not modeled (documented gap) | | `git config <key> <cmd>` writes
(`core.pager`, `sendemail.ccCmd`, `include.path`, ...) | config read later | not modeled
(existing documented gap) | | `cvsimport -d :ext:host` (runs `$CVS_RSH host cvs server`), `p4`,
`cvsserver`, `cvsexportcommit`, `imap-send` (`imap.tunnel`) | env or config | not modeled.
`cvsexportcommit` rev-parses its commit operands before it uses them in shell strings. |

## Gaps

- **Repository aliases.** A repository alias still hides a subcommand from its scoped flags
  (`git se --cc-cmd cmd` with `alias.se = send-email`). This is the existing documented limit
  and is out of scope.
- **Fail-closed collateral, documented.**
  - Any prefix of a refused send-email option is refused, even one Getopt::Long finds ambiguous
  and passes on to `format-patch` (`send-email -s`, `-t`, `--smtp`).
  - `--smtp-server` is refused whatever its value.
  - `git grep -o` is refused, because flags are case-folded; use `--only-matching`.
  - Subcommand names now match in any letter case at any operand, so a path named `Grep` scopes
  `-O`. The same applies to the existing `clone -u` and `rebase --add-exec-commands`.
- **Interaction with #475 (HACK-1423, still a draft).** #475 changes `subcommandInline` to count
  only after the subcommand's operand. When it is rebased onto this change (or this onto it), it
  should apply the same after-the-operand rule to `subcommandGetoptLong`, and keep the
  lowercased lookup from `scopedEntries`. The two PRs touch the same lines in `evaluatesCode`
  and the docs paragraph, so expect a textual conflict.
- **Not modeled.** Config writes and program-file runners (table above) are not modeled.

## Round 2: merged with `next`, one scoping rule (HACK-1423 + HACK-1438)

The verifier found that this PR conflicted with `next` after #475 (HACK-1423) and #480 landed.
The conflict was not only textual. #475 made subcommand-scoped Git flags count only **after**
the operand that names the subcommand. This PR counted them at **any** operand. Its collateral
test asserted that `git log -o -- Grep` is refused, which contradicts #475.

**Rule chosen: #475's.** A scoped flag counts only after the operand that names its subcommand,
because Git reads a subcommand's options only after the subcommand's name. This PR's lowercased
subcommand lookup is kept, so `git Send-Email --cc-cmd=cmd` is still refused.

What changed (merge commit 6cf5296, then a clean merge of 78487f7 in 0556dd9):

- **`src/lib/task-catalog-recipe.ts`.** `scopedFlags` (from #475) and `scopedEntries` (from this
  PR) are now one helper, `scopedEntries({ table, positional, at })`. It returns the table
  entries for operands **before** argument `at`, and it looks up each operand lowercased. Both
  `subcommandInline` and `subcommandGetoptLong` use it, so the send-email Getopt::Long options
  follow the same after-the-subcommand rule as `clone -u`. #475's `--cmd` inline flag and its
  comments are kept.
- **Tests.** The collateral test now asserts `git log -- Grep -o` is refused and `git log -o --
  Grep` passes. New accepted controls where the flag comes before the subcommand word: `git rev-
  list --header send-email`, `git log -s -- Send-Email`, `git diff -Oorderfile -- grep`, `git
  push -d origin instaweb`. Every send-email spelling after `send-email` is still refused. New
  cases kill the two mutations that survived round 1: `git send-email --to=… -- p` and `git
  send-email --to=… -` pass (`--` and `-` name no option), `git push --repo=ext::cmd` is refused
  (`ext::` after `=`), and `git add docs/ext::notes.md` passes (`ext::` only at the start of an
  argument or after `=`).
- **Docs (`docs/reference/ax-task-catalog.md`).** The contract table keeps both #475's rows and
  this PR's rows. The scoping row now lists `git rev-list --header send-email`, `git push -d
  origin instaweb` and `git log -o -- Grep` as accepted, and `git log -- Grep -o` as refused.
  The limitations paragraph uses the single after-the-subcommand rule, says the lookup ignores
  letter case, and drops the round-1 text ("matched at any operand ... a path named `Grep`
  scopes those flags to it").

The "Interaction with #475" gap and the "any operand" collateral bullet above are now resolved
by this round.

### Evidence (round 2)

- **Focused tests.** `bun test tests/project-task-catalog.test.ts`: 42 pass, 0 fail. Run with a
  private HOME, HACK_HOME and DOCKER_CONFIG and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`.
- **Mutation testing.** I applied each mutation alone, ran the focused file, and restored the
  source:

| Mutation | Failing tests |   | --- | --- |   | Scoped flags count at any operand (drop the
`at` check) | 5 |   | Case-sensitive subcommand lookup | 2 |   | `subcommandGetoptLong` counts
at any operand | 1 |   | `subcommandInline` counts at any operand | 4 |   | Allow an empty
Getopt::Long name (`--`, `-`) | 1 (survived round 1) |   | `ext::` matched only at the start of
an argument | 1 (survived round 1) |   | `ext::` matched anywhere in an argument | 1 |   | Drop
`h` from the send-email exact list | 1 |   | Getopt::Long without the one-dash prefix | 2 |   |
`index > at` instead of `index >= at` | 0, an equivalent mutation: the subcommand word itself
cannot match a flag |

- **Gates.** Root `bun run check` exit 0, with `privacy-check: ok`. `bun run --cwd packages/cli
  typecheck` exit 0, with 0 errors. gitleaks 8.30.1 `dir` over a `git log -p origin/next..HEAD`
  export (next = 78487f7): no leaks. The CLI surface and consumer agent guidance did not
  change, so nothing was regenerated.
- **Full suite.** Hosted CI at 0556dd9, which contains `next` 78487f7.

## Round 3: Unicode case folding no longer bypasses the denylist (HACK-1461), merged with `next`

Refs HACK-1461

The round-2 verifier found that APFS (and NTFS) look up names with Unicode case folding, but the
denylist folded names with JavaScript's `toLowerCase()`. So `git ſend-email --to-cmd=cmd`
(U+017F long s), `git-ſend-email`, `git inſtaweb --httpd=cmd`, `git ſvn clone --authors-
prog=cmd` and `git filter-branch --tree-filter cmd` (U+FB01 fi ligature) were accepted. HACK-1461
records that two of them ran a marker command live on macOS.

What changed (7483db3, on top of the merge of `next` cd35034 in 84d0379):

- **`src/lib/task-catalog-recipe.ts`.**
  - A new `foldName` replaces `toLowerCase()` everywhere the denylist compares a name: program
  basenames (`programName`), Git helper porcelains (`gitSubcommand`), the subcommand-scoped
  tables (`scopedEntries`), the `words` check, first-operand `subcommands`, and Python
  REPL/trampoline modules (`isListedModule`).
  - `foldName` over-folds on purpose (fail-closed). It applies NFKC (`fi` becomes `fi`, `ſ`
  becomes `s`, the Kelvin sign becomes `K`), removes default-ignorable code points such as a
  zero-width space, then upper-cases before lower-casing (so `ß` becomes `ss` and dotless `ı`
  becomes `i`).
  - Option names keep ASCII-only folding. Git's parse-options and Getopt::Long compare options
  bytewise apart from ASCII letter case, and option values are left unfolded.
  - Two outright refusals in `evaluatesCode`, so the result does not depend on any one
  filesystem's folding table:
    - A listed program whose basename contains any non-ASCII character is refused whatever
  follows (`ſh scripts/x.sh`, `git-ſtatus`).
    - A Git subcommand (the first operand) that contains any non-ASCII character is refused
  (`git ſtatus`, `git --work-tree dir pack-refſ`).
  - Non-ASCII operands and values (`git commit -m 'café'`, `git log --author=Zoë`) and unlisted
  programs (`cat ſend-email`) still pass.
- **Tests (`tests/project-task-catalog.test.ts`).** New test `HACK-1461: names that a case-
  insensitive filesystem folds to a listed name are refused`:
  - 18 refused spellings covering the long s, the fi ligature, the Kelvin sign (`Ksh -c`,
  `awK`), dotless i (`git ınstaweb`, `ıex`), folding after the first operand (`git log filter-
  branch`, `git log bıſect`, `git help ſend-email --to-cmd=…`, `git log filter​-branch`,
  `python3 -m code`) and the non-ASCII refusals.
  - 8 accepted controls, plus the ASCII `filter-branch` and `send-email --to-cmd` refusals.
  - The collateral comment no longer claims a subcommand counts "at any operand". It now says
  the name is case-folded and scopes its flags from any operand that names it.
- **Docs (`docs/reference/ax-task-catalog.md`).**
  - The refusal list describes the Unicode folding and the non-ASCII rule, and the contract
  table has a new row for it.
  - The limitations paragraph documents `help.autocorrect` next to the repository-alias limit.
  With autocorrect set to run the correction, `git send-emai --to-cmd=cmd` runs `send-email`,
  but the catalog sees an unknown subcommand and does not scope the flag to it.

### Evidence (round 3)

- **Focused tests.** `bun test tests/project-task-catalog.test.ts`: 43 pass, 0 fail, run with
  `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`. The file's fixtures use their own private HOME,
  HACK_HOME and registry.
- **Mutation testing.** I applied each mutation alone, then ran the HACK-1461 test (it failed
  every time) and a pure `isInlineCommandArgv` probe over the same 30 cases, then restored the
  source.

| Mutation | Cases that flip to accepted |   | --- | --- |   | `foldName` is plain
`toLowerCase()` (the pre-fix folding) | 7: `ıex`, `git log filter-branch`, `git log bıſect`, `git
help ſend-email --to-cmd=id`, `git log filter​-branch`, `python3 -m code`, `ſh scripts/x.sh` |
| No non-ASCII listed-program refusal | 2: `ſh scripts/x.sh`, `git-ſtatus` |   | No non-ASCII
Git subcommand refusal | 2: `git ſtatus`, `git --work-tree dir pack-refſ` |   | No upper-case
step | 2: `ıex`, `git log bıſect` |   | NFC instead of NFKC | 1: `python3 -m code` |   |
Default-ignorables kept | 1: `git log filter​-branch` |   | `words` and `scopedEntries` back on
`toLowerCase()` | 4 |

The Kelvin cases do not flip under the `toLowerCase()` mutation, because JavaScript already
  lowercases U+212A to `k`. They stay in the test as regression cases.
- **Live macOS check (built CLI).**
  - Setup: I ran `bun run build`. In a scratch project with a private HOME, HACK_HOME and
  DOCKER_CONFIG, `.hack/tasks.json` held the HACK-1461 argv, each with a `touch <marker>`
  command. I then ran `./dist/hack project task list --json --path=<scratch>`.
  - At this head, every Unicode recipe (long s, dashed long s, fi ligature, dotless i, Kelvin)
  is refused as `inline_command`, as is the ASCII `send-email --to-cmd` control. The benign
  `lint` control gets past the inline check and is refused later as `input_path_invalid` (the
  scratch project is not a Git repository).
  - A CLI built the same way from the round-2 `task-catalog-recipe.ts` (0556dd9) let the
  long-s, dashed, fi and dotless-i recipes through the inline check; they too stopped only at
  `input_path_invalid`.
  - The marker file was never created. The catalog has no execution path.
- **Gates.**
  - Root `bun run check`: exit 0, with `privacy-check: ok`. Its 34 warnings were there before
  this change, and none is in the changed files.
  - `bun run --cwd packages/cli typecheck`: 0 errors.
  - gitleaks 8.30.1 `dir` over a `git log -p origin/next..HEAD` export: no leaks.
  - The CLI surface and the consumer agent guidance did not change, so nothing was regenerated.
- **Merge.** `next` cd35034 is merged in 84d0379 with no conflicts, so the branch is no longer
  behind.
- **Full suite.** Hosted CI run 40134680598 (pull_request, headSha 7483db3) passed every check:
  the `test` aggregate and test shards 1/4 to 4/4, docker-e2e shards 1/4 to 4/4, secret-scan,
  Candidate core on Ubuntu and macOS, the Config compiler jobs, Runtime state models, runtime-
  images and linux-process-lifetime. While CI ran, `next` moved to 191382d (#489, #497).
  Neither touches this PR's files, and `git merge-tree` merges it cleanly, so GitHub shows
  BEHIND, not a conflict.

### Gaps (round 3)

- I did not repeat the direct `git ſend-email --to-cmd` execution in this round, because the
  agent harness blocks a private-HOME `git` run outside its worktree. HACK-1461 already records
  that the bypass executes. This round shows that the catalog now refuses those argv.
- `help.autocorrect` and repository aliases are documented limits. They are not detected,
  because the check does not read Git config.

### Round 4 (312de30): capital sharp s and non-ASCII programs

The G109 verifier found a HIGH gap: `ẞh` (U+1E9E, capital sharp s) resolves to `/usr/bin/ssh` on
the case-insensitive macOS filesystem, but `foldName` did not fold it, because JavaScript
lowercases U+1E9E to `ß`, not `ss`.

- **Folding.** `foldName` now runs NFKC, strips default-ignorables, then applies lower, upper,
  lower. The upper step maps `ß` to `SS`, so `ẞh` and `ßh` fold to `ssh`.
- **argv[0] rule.** A recipe whose program (argv[0]) has any non-ASCII character in its file
  name is now refused, listed or not (`ẞh`, `/usr/bin/ẞh`, `./scripts/ünïcode.sh`, `café`). A
  case-insensitive filesystem may run such a name as an ASCII program that `foldName` does not
  predict, so the check fails closed. Reviewed scripts must use ASCII file names. Later
  arguments are still checked through `foldName` against the listed programs (`cat ẞh` is
  refused).
- **Tests.** `tests/project-task-catalog.test.ts` adds `ẞh host id`, `/usr/bin/ẞh -o
  ProxyCommand=id x`, `cat ẞh`, `./scripts/ünïcode.sh` and `café --version` as refused cases.
  The accepted controls did not change. 43 pass.
- **Mutations.** Restoring the old fold fails the `cat ẞh` case. Removing the argv[0] rule fails
  the HACK-1461 test.
- **Docs.** `docs/reference/ax-task-catalog.md` documents the `ẞ` fold, the argv[0] refusal and
  the ASCII-name requirement.
- **Gates.** Biome is clean on the changed files (complexity is back under the limit),
  `packages/cli` typecheck has 0 errors, `privacy:check` is ok, and gitleaks found no leaks in
  the commit patch. Hosted CI at 312de30 is the full-suite evidence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

1 participant