Repository navigation
Commit 7277c07
feat(scripts): check:pnpm-flag-spellings — no committed pnpm call spells -s, --prod=VALUE, --production=VALUE or --no-production, which a global pnpm 11+/12 frontend rejects (#23824)
## Problem
#23777 removed every pnpm argv spelling that a contributor's global pnpm
11.x / 12.x `@pnpm/exe` frontend rejects before it hands off to the
pinned pnpm 10.31: `-s`, and `--prod=false` on the objectui install.
Nothing stops a script from bringing one back.
CI cannot catch a regression. It gets pnpm through Corepack, which runs
the real 10.31 binary with no frontend in front of it, and 10.31 accepts
every one of these spellings. A regression stays green in CI and goes
red only on a laptop, where it shows up as something other than a flag
problem. Before #23777, `check:generated` read the frontend's exit 2 as
"every artifact stale", the pre-commit regen gate refused every commit,
and `pnpm objectui:build` installed nothing. Only a scan of the
committed source can see it.
## Change
- **`scripts/check-pnpm-flag-spellings.mjs`** (new). It reds on any pnpm
run in tracked source that uses one of these spellings in a pnpm option
position:
| spelling | fix it prints | frontend says (measured 2026-10-16, #23777)
|
|---|---|---|
| `-s` | `--silent` | unexpected argument '-s' found, exit 2 |
| `--prod=VALUE`, `--production=VALUE` | `--config.production=VALUE` |
unexpected value, exit 2 |
| `--no-production` | `--config.production=false` | unexpected argument,
exit 2 |
`--silent`, `--reporter=silent`, `--no-prod` and `--config.production=…`
are accepted, and counted on the success line so that a zero is a
measurement.
- **`check:pnpm-flag-spellings`** root script: `--self-test`, then the
gate.
- **`lint.yml`**: one step in the `Lint · CI, release & engine gates`
lane, right after its sibling `check:agent-test-spelling`, with an `id:`
and `continue-on-error: true` like every gate there. The lane's verdict
step keeps any red, and the required `Lint & Repo Gates` aggregate
carries it.
### What it reads
- **Population.** Tracked files only (`git ls-files`, read from the
working tree):
- `scripts/**`, every `scripts/` directory under `packages/`, and
`.githooks/**`. JS/TS files are recognised by extension; shell files by
extension or by an `sh`/`bash` shebang, because the hooks have no
extension.
- The `scripts` values of every tracked `package.json`.
- **Out of scope:** `.github/workflows/**`. CI's pnpm is Corepack's
10.31 binary, which no frontend fronts, so no workflow can hit this.
- **Three source forms:**
1. shell text, with comments stripped;
2. the content of JS/TS string and template literals, with comments
masked through `js-comment-mask.mjs` and regex literals not read;
3. JS/TS argv arrays. That covers both `spawnSync('pnpm', ['--filter',
pkg, '-s', 'build'])` and the tuple form `['pnpm', '-s', 'build']`.
- **Judged positions.** A flag is judged only where pnpm itself parses
it:
- before the command word;
- after a built-in command such as `install` or `list`;
- between `run`/`exec`/`dlx` and its target.
Anything after a script name, a bin, a run target or a bare `--` is
forwarded and never judged. So `pnpm build -s` is green, and so is `git
ls-files -s`.
- **Failure direction.** If the gate meets a value-taking flag it
doesn't know, the run goes unjudged. That's a miss, never a false red.
- **Refuses (exit 2) instead of passing** when any of these holds:
- `git ls-files` fails;
- a declared root holds no tracked file;
- no manifest carries `scripts`;
- a tracked manifest doesn't parse;
- the whole population yields no pnpm run.
- **Misses, named in the header:**
- flags built from a variable (`pnpm${s}`, as in `ownerRunCommand`,
whose output `git-merge-regen.mjs --self-test` already pins);
- argv arrays assembled somewhere else;
- combined short clusters such as `-rs`.
- **Dispatch watch hints.** The gate declares its population in the
spellings `scripts/pm/dispatch-gates.mjs` can match, so a card that adds
a new script under one of these roots, or touches a manifest, is routed
to it. Before this, the derivation read the `'scripts/'` label as a bare
root no dispatch can name, and its battery said so. The header and a
self-test pin cover the coverage measurements and both reconciliation
directions.
## Evidence
All on `adf2ca745e`, branched from `main` at `59f9763d3e`, which already
contains #23777.
**Green on main:**
```
$ pnpm check:pnpm-flag-spellings # exit 0
✓ check-pnpm-flag-spellings self-test: 64 cases across 7 batteries pass.
✓ check-pnpm-flag-spellings: no rejected pnpm flag spelling in 583 tracked file(s).
roots: scripts/ 434 · packages/**/scripts/ 174 · .githooks/ 2; read 478 JS/TS, 20 shell, 85 package.json (617 scripts); 111 data/prose file(s) not read, 0 tracked path(s) absent.
907 pnpm run(s), 18 of them argv arrays; 235 flag(s) in a pnpm option position, accepted spellings among them: --silent ×5, --reporter=silent ×0, --no-prod ×0, --config.production= ×1.
```
**Red on a planted occurrence.** I put the two spellings #23777 removed
back into tracked files, ran the gate, restored both files from HEAD,
and ran it again:
```
$ sed -i '' 's/pnpm install --frozen-lockfile --prefer-offline --config.production=false/pnpm install --frozen-lockfile --prefer-offline --prod=false/' scripts/build-console.sh
$ sed -i '' 's/execSync(`pnpm --silent ${script}`/execSync(`pnpm -s ${script}`/' packages/spec/scripts/check-generated.ts
$ node scripts/check-pnpm-flag-spellings.mjs # exit 1
✗ check-pnpm-flag-spellings: 2 pnpm flag spelling(s) that a global pnpm 11+/12 `@pnpm/exe` frontend rejects.
• packages/spec/scripts/check-generated.ts:690
pnpm -s ${}
`-s`: the frontend has no short `-s` ("unexpected argument '-s' found", exit 2)
fix: spell it `--silent`
• scripts/build-console.sh:334
npm_config_registry="$NPM_CONFIG_REGISTRY_OVERRIDE" pnpm install --frozen-lockfile --prefer-offline --prod=false
`--prod=false`: `--prod` / `--production` are booleans to the frontend, which refuses a value on them ("unexpected value", exit 2)
fix: spell it `--config.production=false`
```
```
$ git restore --source=HEAD --staged --worktree scripts/build-console.sh packages/spec/scripts/check-generated.ts
$ git status --porcelain # (empty)
$ node scripts/check-pnpm-flag-spellings.mjs # exit 0
✓ check-pnpm-flag-spellings: no rejected pnpm flag spelling in 583 tracked file(s).
```
**`--self-test`:** 64 cases across 7 batteries pass, under a frozen
roster with per-battery floors and a verdict handshake (shape copied
from `check-agent-model-declared.mjs`, not imported). Besides the
positive and negative spelling cases, the `TREE` battery runs the real
sweep over throwaway git work trees on disk:
- a clean tree is green;
- each of these is red at the right `file:line`: a planted `pnpm -s` in
a tracked `scripts/` shell file, in a `package.json` script, in a JS
spawn under `packages/*/scripts/`, and in an extension-less git hook;
- the same plant is green when untracked, when tracked outside every
root, and when placed in the rule-owning file;
- a missing root, a pnpm-free population, an unparseable manifest and a
non-git directory are each REFUSED.
Mutation probes on a throwaway copy, both red as intended:
- deleting one RED case → `battery "RED — …" registered 18 case(s),
below its pinned floor of 19`, exit 1;
- a `return` above the floor → `selfTest() returned without reaching its
verdict`, exit 1.
**Neighbouring gates run locally, all exit 0:**
- `check:pnpm-filter-targets` and `check:entry-guard`;
- `check:parse-guard` and `check-comment-mask-adoption`;
- `check-self-test-wired` and `check-aggregator-roster`;
- `check-ci-filter-parity` and `check:doc-authoring`;
- `check:watch-hint-literal` and `check:declared-population-live`;
- `check:workflow-step-name-quoting`, `check:required-contexts` and
`check:select-gate-families`;
- `bare-root-worklist --self-test`, `check:issue-citations` and
`check:pm-skill-id-lint`;
- `check:scripts-symbol-anchors` and `check:ratchet-remedy-authority`;
- `check-workflow-status-functions`;
- `check:pm-dispatch-gates`, with all fast-tier cases passing;
- eslint on the new file.
`check:agent-test-spelling` is red locally only on
`.claude/settings.local.json`, an untracked file that a global gitignore
excludes. CI never has that file, and the red is unrelated to this diff.
## Notes
- **Governed surface.** This diff touches `.github/workflows/lint.yml`,
a Tier H surface under AGENTS.md Prime Directive 14. The PR is a draft
and the landing is the maintainer's.
- **No changeset.** Only root `scripts/`, the root `package.json`
scripts block and a workflow change. None of them ships in a published
package, so the PR carries `skip-changeset`.
- **No full `pnpm test` run.** The diff touches no workspace package;
the gate's own `--self-test` is its test.
---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1904ee85-9672-427b-bc91-7e31318013c7)_
---------
Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>1 parent e375315 commit 7277c07
3 files changed
Lines changed: 1174 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4057 | 4057 | | |
4058 | 4058 | | |
4059 | 4059 | | |
| 4060 | + | |
| 4061 | + | |
| 4062 | + | |
| 4063 | + | |
| 4064 | + | |
| 4065 | + | |
4060 | 4066 | | |
4061 | 4067 | | |
4062 | 4068 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | 115 | | |
| |||
0 commit comments