check(cli-command-ids): every module under src/commands must BE a command, not just yield an id - #17906
Conversation
… src/commands to BE a command
The oclif command table is a glob over the emitted tree
(`"commands": { "strategy": "pattern", "target": "./dist/commands", "glob": "**/*.js" }`),
so every module under `packages/cli/src/commands/` is taken to be a command. A helper
placed beside the command it serves has no default-exported command class, and oclif
then writes a `findCommand ... not found` warning to stderr on EVERY `os` invocation,
whatever the user ran — which corrupts `os validate --json` for any consumer that reads
both streams.
`check-cli-command-ids.mjs` already walks that directory and already derives an id per
file; it asked the opposite question (does a command-id literal outside the CLI resolve
to a derivable path), and a module with no command class still yields a derivable path.
So the second duty lands in the same walk: no new traversal, no new verification surface.
- The predicate follows the INHERITANCE CHAIN, across files. `build.ts` extends `Compile`
and `migrate/index.ts` extends `MigratePlan`; a predicate accepting only
`extends Command` would false-red both, which is how a guard gets switched off.
- It PARSES (via `scripts/ts-parse.mjs`) rather than matching text: `create.ts`,
`generate.ts` and `init.ts` are scaffolders whose template literals spell
`export default ...` hundreds of lines before the module's own real class.
- It reads SOURCE, never `dist`, so it needs no build and inherits no stale artefact.
- The module population is the EMITTED set, deliberately wider than the id population: a
dotted or non-kebab base cannot be a command id but `tsc` still emits it and the glob
still loads it, and those are the shapes a misplaced helper takes.
- Exemptions are declarative and SELF-RETIRING — an entry reds once its file leaves the
population or starts exporting a command class.
- Zero examined modules is a refusal, not a pass.
Duty one is unchanged: the derived id and topic sets are set-equal to the previous
implementation (74 ids, 12 topics).
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-authored-by: Claude <noreply@anthropic.com>
…mmands-dir-command-class-guard
Contract review — PR #17906 (card #17869)Head reviewed:
① Derived judgments — measured here, ⛔ not ratified from the report1. ⭐ Duty one is unchanged BY CONSTRUCTION, and that is readable in the diff rather than taken on the report's word.
2. ⭐ The predicate PARSES, and it had to. It follows the inheritance chain across files via 3. The two inheritance cases are pinned BY NAME, which is what makes the guard survivable. 4. ⭐ Zero examined is a REFUSAL, and the examined count is PRINTED. The success line reads 「63 module(s) … examined」 with the reason in a comment beside it: 「 5. The failure message carries the CONSEQUENCE, not just the rule. It quotes the oclif glob from 6. The exemption ledger is declarative AND self-retiring on BOTH rot modes — read at source, not claimed. 7. SOURCE only ⇒ the stale- 8. ⛔ Nothing was weakened. All thirteen removed lines are the old walk body, its floor constant and one self-test description. 9. Surface axis, re-measured. One file, ② Changeset — NONE, and the exemption is the gate's own, verified at sourceNo published package changes, so there is nothing to grade. The
③ Boundary flagsa. b. ⛔ A falsified claim was removed from the record before landing. The acceptance notes carried 「the c. ③ is NOT yet met. At 23:22Z: 31 names, 20 success / 10 skipped / zero failures, with Independence pairTier: default judgment — 「余席条款②复核 = 默认判断档自审加门禁」. VerdictPASS. ⛔ No carrier to clear — the claim declared the axis Generated by Claude Code |
Fixes #17869
packages/cli/package.jsondeclares the oclif command table as a glob over the emitted tree:so every module under
src/commands/is taken to be a command. A helper placed beside the command it serves — the obvious place to put it — has no default-exported command class, and oclif then writes afindCommand ... not foundwarning to stderr on every singleosinvocation, whatever the user ran. That corruptsos validate --jsonfor any consumer reading both streams, and a misplaced file that happens not to break a--jsonparse ships the warning silently to every user of every command.Nothing caught it.
check-cli-command-ids.mjsalready walkspackages/cli/src/commands/**and already derives an id per file, but it asks the opposite question (does a command-id string literal outside the CLI resolve to a derivable command path, #12016) — and a module with no command class still yields a derivable path, so it reads as a valid id.So the second duty lands in the same walk: no new traversal, no new verification surface, and duty one is untouched. One file changed,
scripts/check-cli-command-ids.mjs.The ruled shape (triage
5648304561), item by item, with its reading1. The predicate follows the inheritance chain — proved by file name.
--listprints the resolved chain per module:Both are accepted, and neither extends
Commanddirectly — a predicate accepting onlyextends Commandwould false-red exactly these two. Pinned by name in--self-test, not by count.2. The gate is GREEN on the tree as delivered, having examined 63 modules. The examined count is printed, because
0 violationsover 0 modules and0 violationsover 63 are otherwise the same line and only one of them is a reading:A CLI package that yields zero modules is a refusal, not a pass —
discoverClisalready required itsoclif.bindeclaration and an existingsrc/commandsdir, so an empty walk means the traversal stopped.3. The failure message names
packages/cli/src/utils/as the home for helpers, and states the consequence (stderr warning on everyosinvocation;JSON.parsefailing for a--jsonconsumer; one red in 3247 cases on PR #17859, in a test with nothing to do with the misplaced module) so nobody reads it as cosmetic. Full text under §4 below.4. The exemption is declarative and self-retiring.
COMMAND_MODULE_EXEMPTIONSis one list in the gate, keyed by path, each entry carrying its reason. It is empty today, which is the tree's measured state. A stale entry reds — both ways it can rot are checked: the file left the walked population (moved, renamed, deleted), or the module now does export a command class. Same shape as the two ledgers already in this file.5. The gate reads SOURCE, never
dist. Deliberately, and that is why it needs no build and inherits no stale artefact:tscdoes not delete outputs for removed sources, so a deletedsrc/commands/x/helper.tsleavesdist/commands/x/helper.jsbehind that a local rebuild does not clear. Nothing here readsdist, so that trap is not inherited.Two things worth a reviewer's eye
The module population is deliberately WIDER than the id population. The id rule drops a dotted base (
foo.helpers.ts) and a base that is not lower-kebab (_shared.ts), because such a file cannot be a command id — buttscemits it and the glob loads it, so those are exactly the shapes a misplaced helper takes. A directory that cannot name a topic (_priv/) used to end the traversal and is now descended with id derivation switched off. Scoring this duty on the id population would have left the guard blind at precisely its own subject.Duty one is unchanged, and that is measured rather than argued — the old and new
commandSurfaceUnderwere imported side by side and compared on the live tree: 74 ids, 12 topics, set-equal, zero on either side only.It parses rather than matching text, through
scripts/ts-parse.mjs(check:parse-guard's sanctioned door).create.ts,generate.tsandinit.tsare scaffolders whose template literals spellexport default ...as emitted code — in all three, hundreds of lines before the module's own realexport default class. A text scan reading the first match answers about a string the scaffolder prints. All three resolve correctly here (Create -> Command,Generate -> Command,Init -> Command).The ablation leg, executed
The card's own repro, with the file placed at the id from the card's stderr sample. Restore is proved by state, not by an exit code.
Leg 0 — the tree as delivered:
EXIT=0,63 module(s) ... examined, all of them default-export a class.Mutation:
packages/cli/src/commands/migrate/file-column-move.ts, plain functions only. On-disk proof before any reading:exists=yes, marker occurrences=1.Leg 1 — with the misplaced module:
EXIT=1The self-test with the module present is also red:
1 of 57 case(s) failed,EXIT=1.Restore: file removed;
exists=no;git status --porcelainempty;git diff HEADempty.Leg 2 — after removal:
EXIT=0, back to63 module(s) ... examined.Verification
All readings below are on the merged head
71777c276(origin/mainfd8b2c049merged in; main had touchedpackages/cli/src/commands/info.ts, so the population was re-taken after the merge — still 63).The derived gate set: 31 families, 31 run, every one exit 0.
Every exit code was captured into a variable before any pipe. The family list was re-derived on the merged head and is identical.
Plus, by name:
pnpm lint(eslint . --no-inline-config, whole repo, 95s)pnpm check:cli-command-ids(the changed gate: self-test and audit)pnpm check:nul-bytes(8532 text files, 0 raw control bytes)pnpm check:parse-guard·check:entry-guard·check:watch-hint-literal·check:self-test-wiredpnpm check:pm-dispatch-gates·check:scripts-symbol-anchors·check:declaration-mirrorsNo command exited 3; no prerequisite was unmet.
pnpm lintran over the whole repository, so no narrowing was taken and none needs proving.Duty one, proved unchanged. The old and new
commandSurfaceUnderwere imported side by side and run againstpackages/cli/src/commands:The self-test grew 39 -> 57 cases, in two new batteries (
the command-class predicate, against a scratch tree×10,the command-module duty on the live repo, by name×8).SELF_TEST_BATTERY_FLOORwas raised 8 -> 10 so neither new battery can be deleted back to silence.Not measured, and deliberately so: the user-visible half — the
findCommand ... not foundline on stderr throughbin/run.js. It needspackages/clibuilt, and its dependency closure here is 58 packages behind a shared verify lock that a sibling dev currently holds. The order marks this optional-if-cheap; it is not cheap here, so it is skipped, not measured — ⛔ not reported as observed. The card already carries that measurement from PR #17859.Acceptance notes
Measured on the way through, and not this card's:
**git pathspec trap DOES reproduce — this bullet previously said it did not, and the seat measured it out. ⭐ Corrected in place by thedomain:cliseat (comment5649347543on cli: a non-Command module under src/commands makes EVERY os invocation warn on stderr, and nothing catches it #17869), ⛔ not edited away, because the retraction is the useful part. The delivered reading was real:git grep -l '' -- 'packages/cli/src/commands/**/*.ts'does return 63. The inference from it was not — those are a different 63. Five probes:git ls-files -- 'packages/cli/src/commands/**/*.ts'→ 62 files, of which 0 are top-level.git grep -l '' … -- <that pathspec>→ 63 files, and that set containscommands/build.ts0 times.git grep -l -E '.' … -- <that pathspec>(a pattern matching every line) → 63,build.tsagain 0.⇒ the pathspec selects only what lives in a subdirectory and excludes all twenty top-level command modules; a pattern matching every line does not rescue them, so the absence is not the pattern's doing. The seat's 43 was the subdirectory modules matching
^export default class.⭐ The lesson is this card's own, one level up: a COUNT IS NOT A READING OF A SET. Two populations of size 63, disjoint on twenty members, and only a membership probe — is
build.tsin there? — can tell them apart. Triage nearly mis-read its own probe by counting, the seat published 43 without saying which 43, and this bullet read63 == 63as agreement. Three instruments, one shape, one card, one day.⛔ Nothing in this PR depends on the disputed spelling: the gate's population comes from a directory walk, and this PR's own census came from
git ls-tree -r --name-only.grep -v testover the same list returns 62, silently droppingpackages/cli/src/commands/test.ts, which is a command. That file is now pinned into the gate's own self-test as an examined module, so the population can never quietly lose it again.packages/clibuilds withtsc -p tsconfig.build.json, not tsup. The card and the ruling both say "tsup does not delete removed outputs"; the stale-artefact hazard is identical undertscand the conclusion is unchanged, but the tool name in the card's prose does not match this package. Noted, not filed: a prose detail on a closed premise, no behaviour rides on it. Carrier: this PR.git ls-files. That is correct for this duty (oclif loads what is emitted, and the build reads the filesystem), and it means a dev sees the red before they commit rather than in CI. Noted as a property, not a finding.Nothing was filed as a new issue; nothing here is a reproducible defect, a contract violation, or a metadata-authoring trap.
Changeset
None, and that is measured.
scripts/check-cli-command-ids.mjssits in the root package, which isprivate: true— no published package'sfiles[]reaches the path. Positive control on the same instrument:packages/clideclaresfiles: ["dist","README.md","CHANGELOG.md"]andpackages/cli/dist/...matches, so the zero is a reading and not an empty query.packages/cli/package.jsonwas not touched.Check Changeset's own exemption is theskip-changesetlabel, which is applied on this PR; no changeset is invented for a package this diff does not change, and no level is raised to quiet a gate.Authored by Claude Code, session
session_01TSf4DV7ziu4V5j73e46b7c(https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c).Generated by Claude Code