Skip to content

feat(devx): gate CLI command-id string literals outside the CLI package - #12225

Merged
yinlianghui merged 5 commits into
mainfrom
claude/issue-12016-cli-command-id-coupling
Aug 25, 2026
Merged

feat(devx): gate CLI command-id string literals outside the CLI package#12225
yinlianghui merged 5 commits into
mainfrom
claude/issue-12016-cli-command-id-coupling

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#12016

The card offered three shapes and said plainly "⛔ not a recommendation — triage's call". Triage queued it without picking, and set a go/no-go: count the population first. Option 1's whole argument was that the driver constant "is unlikely to stay the only such string" — a claim about a population nobody had counted. This PR counts it, and the count chose the option.

The measurement that settled it

Command ids derived from the oclif filesystem convention, then every non-comment string literal in non-test source outside packages/cli checked against them:

command ids derivable from packages/cli/src/commands/**71 (61 from command files, 11 topic dirs; migrate is both)
static aliases anywhere in the tree0
command-id literals outsidepackages/cli274, across 98 files
in packages/spec/src/migrations/registry.ts40
in packages/drivers/driver-sql/src/schema-drift.ts14
in packages/drivers/driver-sql/src/sql-driver.ts10

274, not 1. The card's prediction was right by two orders of magnitude — and it is right inside the finding's own file. MULTI_VALUE_COLUMN_REMEDY_COMMAND is one of fourteen command-id literals in schema-drift.ts, and the same warning message that interpolates it also spells "os migrate apply" inline on line 756, as a bare literal with no constant, no export and no pin at all. A per-constant pin (option 2, already declined in #12012) would have covered 1 of 274 and left the identical hazard three lines down in the same template literal.

So: option 1, on the number.

Option 3 was falsified — it is already done, and it never covered the finding

The card's option 3 was "record the coupling in the docs-audit affected-docs ledger so a rename drags the docs page in". That mechanism exists, and it is already wired: scripts/docs-audit/affected-docs.mjs has had a command anchor kind since #9230, deriving os meta resync from packages/cli/src/commands/meta/resync.ts. Verified against this repo rather than assumed:

$ node scripts/docs-audit/affected-docs.mjs --json 0e5bea6128~1
has multi-value-columns anchor: true
cli.mdx in docs: true

A diff touching the command file already lists content/docs/deployment/cli.mdx. There was no ledger entry to add. And its declared scope is content/docs/**/*.mdx — so it never reached schema-drift.ts, which is the finding. Option 3 was both already-done and orthogonal.

The gate

scripts/check-cli-command-ids.mjs — every command-id string literal outside an oclif package must resolve to a real command path inside one.

Everything is derived from declared data, never a table: which packages are CLIs (oclif.bin declared, not a hardcoded packages/cli path — a second CLI package is covered the day it lands), which binary names count (oclif.bin plus every bin key, which is how objectstack is covered as well as os), and which ids exist (src/commands/** paths, topic/index.ts collapsing to the topic).

The delimiter rule is the whole precision story. A candidate must open immediately after a ', " or backtick. That was measured too: the loose form ("a bin name anywhere on a quoted line") produced 9 unresolvable hits of which 6 were noise — Spanish translation prose where \b fired inside envíos diarios, a Python import os from 'os' example, and the sentence "carry an os validate-clean security posture". Every one has a letter or a space before the os. The delimiter rule drops all six with no carve-out.

Bare topics resolve.os meta has no meta/index.ts, but meta/ exists and oclif serves a topic as topic help, not an unknown-command error — so plugin-auth's 'os meta' run prose is correct and flagging it would be the gate fabricating a finding.

The self-test caught the gate being blind to its own purpose

Per the dispatch, the gate ships with a self-test pinning a known-bad literal so it can be shown to fail. That self-test earned its keep immediately.

The first draft resolved by plain longest-prefix — two words, else one. Under that rule, rename migrate/multi-value-columns.ts and 'os migrate multi-value-columns' quietly falls back to migrate, which is a real id. The gate would have stayed green through exactly the rename #12016 exists to catch. The --self-test rename case is what caught it; the fallback is now conditional on whether the first word is a topic (a following word is a subcommand attempt) or a leaf command (a following word is an argument).

38 cases, including: the same literal resolving before a rename and resolving to nothing after it; a known-bad os migrate nonexistent-command beside a good os migrate apply; and all six measured noise shapes staying out.

Two ledgers, both self-retiring — and one real defect found on the first live run

Both are keyed by file and exact text, and mainfails if a listed entry no longer reproduces — so neither can rot into a claim about a line that has since been fixed. That is packages/cli's own EXCLUDED idiom from #10967 / #11465: an exemption that asserts its own cause still holds.

  • FIXTURE_EXEMPTIONS — 3 entries, all negative-case fixtures in scripts/docs-audit/* self-tests where an unresolvable id is the point.
  • BASELINED_VIOLATIONS — 1 entry, printed on every green run. The gate's first live run found a real defect: packages/spec/src/api/endpoint.zod.ts:39 tells an author, in a present-tense refusal message, that "publication state is managed by objectstack publish" — a command retired with the direct-to-environment path. Filed as packages/spec: an author-facing refusal message routes to objectstack publish, a command retired with the direct-to-environment path #12223 with the two lower-severity sibling sites; not fixed here, because packages/spec is not this card's lane.

⭐ Four invisibility bugs, one shape — and two of them are findings about the tooling

This PR hit the same failure mode four times at four levels. The gate itself is the small half; the pattern is the interesting one.

  1. The gate was invisible to itself.populationFiles reads git ls-files, so it ran green while its own source was untracked and reded on the first run after being committed — every negative fixture in a gate for unresolvable ids is unresolvable by construction. Fixed by excluding its own source, pinned by a self-test case.

  2. The gate was invisible to dispatch-gates — caught only as red CI. hintCovers refuses a bare single-segment literal as too generic (a measured refusal, +139084 fabricated pairs), and populationFiles spelled its admission predicate as f.startsWith('scripts/'), which collapses to scripts. The gate would have "landed already invisible": never named for a card touching its own population. Remedy (a), not (b), and the difference is what the gate reads: 'scripts/' here is an admission predicate, not an exclusion or a fixture path — every tracked source file under that root is in the population with no further test, and seven already carry findings. So ROOT_DIR_WATCH_HINTS = ['scripts/**'] is a true statement about the scan. packages/** is deliberately not declared: this gate opens each package's src subtree and its package.json, not the root entire, and naming a root the gate does not read is a fabricated lead. Roots are hoisted to POPULATION_ROOTS so predicate and declaration cannot drift, with four self-test cases pinning both directions — including that the declaration must be a source literal, since a computed `${r}/**` builds no hint at all.

  3. ⭐ The obligation itself is undiscoverable, and that is measurable. The rule in (2) is enforced by check:pm-dispatch-gates — which is not derivable for a card that adds a gate under scripts/. Its own tool says so:

    - pnpm check:pm-dispatch-gates names: scripts/pm/dispatch-gates.mjs, scripts/i18n-bundle-surface.mjs, ...
    ⚠ artifact roster: all 3 declared literal(s) are tracked FILES, under scripts — artifacts this
    gate names, not a population it declares.
    ⛔ One of YOUR paths is under scripts. ... this `silent` is not evidence about your path in
    EITHER direction — it is the shape that reads as a clearance and is not.
    

    So the gate that enforces "declare your subtree or land invisible" cannot itself be named for the cards that need it, and the obligation surfaces only as red CI after the PR is open. This is the second undiscoverable landing obligation for a new gate, in a second tool.[finding] "A new gate that assembles a root must record a bare-root verdict" is not derivable — two independent new gates hit the identical CI red within one hour #12074 documents the first — bare-root-worklist's TRIAGE FRESH verdict, hit by two devs within one hour. This is a different species (escapable-literal) in a different tool, and [finding] "A new gate that assembles a root must record a bare-root verdict" is not derivable — two independent new gates hit the identical CI red within one hour #12074 does not mention it, so this PR is evidence the class is larger than that card measured.

  4. ⭐ The branch was invisible to the drift guard that watches exactly this — see Verification below. Local ran a 580-case dispatch-gates; CI ran the 595-case one; the 15-case gap was the signal and it sat in the report unread. Being written up on [finding] "A new gate that assembles a root must record a bare-root verdict" is not derivable — two independent new gates hit the identical CI red within one hour #12074 alongside (3).

    Note the shape running through all four: in (1) the gate could not see itself; in (2) the derivation could not see the gate; in (3) the tool that decides which gates a card must run cannot name the gate that would have told the author; in (4) the guard that reports staleness is the one the stale tree predated. (3) and (4) are left as findings, not fixed here — dispatch-gates's ledger is shrink-only and the tool is another lane's.

Relationship to #11465 / #12177 — complementary, not overlapping

The same class was measured insidepackages/cli on the same days: 536 invocations in 107 sources, 6 unresolved. That card fixed two and declared two in place, and #12177's declarations say in prose that "a sweep over the documented CLI invocations in this package will flag both of them — leave them."

This gate's population starts where that one stops: every oclif-declaring package is excluded from its own scan, so the two never touch the same line and #12177's declared exemptions are out of reach by construction. Two of its lessons are built in rather than rediscovered — a bare topic resolves (its os datasource false positive cannot occur here), and an exemption asserts its own cause.

Scope

Per Zone 1: packages/cli and packages/drivers/driver-sql are untouched, packages/spec is untouched (#12223 stays a baselined entry, not a fix here), the string constant is not removed, and nothing imports the CLI from the driver. Option 2 stays declined.

Verification

⚠️The first two rounds of numbers on this PR were measured on a tree 13 commits behind origin/main, and one of them was wrong because of it. That reading is left here rather than swapped out, because it is the point.

Round 2 reported ✓ dispatch-gates self-test: 580 cases pass. The CI run that reded reported 1 of 595 case(s) failed. 595 → 580 was not the fix; it was 15 cases the branch did not have. The branch's merge-base was 8a7d070dba, and it predated PR #11934 (merged a187fe612b), which added dispatch-gates' stale-tree drift guard — "594 includes 15 new cases for the drift guard". CI checks out the merge of head into main, so local and CI were running different programs: a green measured on a tree that would never be merged, offered as evidence about the tree that would. ⚠️ The remedy was therefore designed against the 580-case program and, until this round, validated against the 595-case one only by CI — the re-run below is what makes it a deliberate result rather than a lucky one.

⭐ And the guard that prints exactly this warning —

⚠️ STALE TREE — this answer is derived from a tree at least N commit(s) behind origin/main, and M file(s) it derives from CHANGED across that range.

— watches scripts/pm/dispatch-gates.mjs among its files. The guard that would have caught it is the guard the tree predated. That is invisibility bug (4) above, and the reason this round merged rather than re-ran.

origin/main is now merged in (a merge commit, not a rebase; nothing force-pushed), and everything below is re-measured on the merged tree at 9fbb5a29d0 — not re-asserted from the stale round. The guard was also used as intended mid-round: after the first merge it fired again naming three newly-changed files, and a second merge cleared it.

checkresult on the merged tree
pnpm check:pm-dispatch-gates (the CI red)exit 0✓ dispatch-gates self-test: 595 cases pass, zero lines
the assertion that reded✓ no gate has NEWLY joined the escapable-literal species — now proven against the post-#11934 code
stale-tree guard on this treesilent — no STALE TREE warning
pnpm check:cli-command-ids✓ … 274 command-id literal(s) across 98 file(s) … all resolve to a real command path
check:cli-command-ids --self-test✓ … 38 cases pass
derived union (re-derived at the merged commit)24 families, unchanged across the 13 commits
union + check:nul-bytes24 of 25 green — the one exception is check:type-check-debt, below

Positive control, re-run on the merged tree (the fix proven, not merely un-red): with a brand-new scripts/ file present, dispatch-gates names this gate via gate source 'scripts/**' — the declaration is doing the work, not the gate's own filename. Probe file removed; git status clean.

One did not run: check:type-check-debt refuses to measure without the full built workspace closure (turbo run build --filter='./packages/*', which lint.yml performs before that step) — a declared prerequisite, not a finding. Declared narrowing, with its warrant: it enters the derivation only via .github/workflows/lint.yml as a gate source path, and this diff contains zero.ts/.tsx files, zerotsconfig*, and one added check:* line in package.json that touches no typecheck wiring — so nothing in its measured population changed and its verdict cannot move. Its sibling leg check:type-check-coverage, which validates the wiring and needs no built closure, ran green.

Second declared narrowing, on process rather than coverage: this round's union was run without the shared heavy-verify lock. A request for it timed out after 9 minutes without ever acquiring (VERDICT queue-timeout (exit 99)), behind a full-repo turbo run build. These 24 families are light static-analysis scripts — the whole union measured at 65s wall — not the build/test work that lock exists to serialize. Every exit code was still captured to a file before any pipe, and all 25 logs are free of PREREQUISITE NOT MET. Flagged because an earlier run in this round did silently read a stale /tmp/gate-results.txt from before pnpm install completed, which is exactly the shape of error this PR is about; the results file is now truncated per run.

skip-changeset: scripts/ + .github/workflows/ + one package.json script line. Nothing user-visible is published.

Back-links: #11535 · PR #12012 (the coupling) · #11465 / PR #12177 (the sibling sweep) · #12223 (the defect this gate found) · #11934 (the drift guard this branch predated) · #12074 (the undiscoverable-landing-obligation class; this PR contributes a second species and the staleness mechanism).


Generated by Claude Code

)
A CLI command id spelled as a string literal outside `packages/cli` has
nothing tying it to the command file it names. Rename the command, update
the CLI and the docs but not the engine, and `driver-sql`'s drift warning
keeps printing a route that no longer exists — a stale hint inside an
otherwise-correct message, which no suite reads as wrong: the driver's own
pin asserts the message CONTAINS the constant, and the constant still
matches itself.
The card offered the gate on the argument that the constant "is unlikely to
stay the only such string" — a claim about a population nobody had counted.
Counted first: 274 command-id literals across 98 files outside the CLI
package, 14 of them in `schema-drift.ts` itself. The same warning message
that interpolates the named constant also spells "os migrate apply" inline,
as a bare literal with no constant and no pin at all.
The gate derives ids from declared data (`oclif.bin` plus every `bin` key;
`src/commands/**` paths), never a table, and resolves topic directories as
well as command files. Its self-test pins the #12016 rename itself: the same
literal that resolves before the rename resolves to nothing after it. That
case is what caught a plain longest-prefix rule quietly rescuing
`os migrate multi-value-columns` as `os migrate` — blind to exactly the
failure the gate exists for.
Two declared ledgers, both self-retiring: three self-test fixtures, and one
real violation the first live run found in `packages/spec` (#12223).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_af22b339-91b5-5814-8b9b-2453fc5b3f68
…lation
A checker cannot be its own evidence. Every negative fixture in this file —
`os migrate nonexistent-command`, `os demo`, `os nope nope` — is an
unresolvable id ON PURPOSE, because that is what a self-test for this gate
is made of. Scanning them made the gate red in proportion to how well it was
tested, with "delete the tests" as the only way back to green.
Found the honest way rather than reasoned out: `populationFiles` reads
`git ls-files`, so the gate ran green while its own source was still
UNTRACKED and reded on the first run after it was committed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_af22b339-91b5-5814-8b9b-2453fc5b3f68
…ch can name it
`dispatch-gates` derives WHICH gates a card must run by matching path literals
in each gate's source against the card's changed files, and `hintCovers`
REFUSES a bare single-segment literal as too generic — a measured refusal
(+139084 fabricated pairs) that stays. `populationFiles` spelled its admission
predicate as `f.startsWith('scripts/')`, which collapses to `scripts`, so this
gate would have "landed already invisible": never named for a card touching its
own population, scoring the same quiet green for every card in the tree.
Remedy (a), not (b), and the difference is what the gate READS. `'scripts/'`
here is an ADMISSION predicate, not an exclusion or a fixture path: every
tracked source file under that root is in the population with no further test,
and seven of them already carry findings. So the subtree spelling is a true
statement about the scan, and declaring it is the `ROOT_DIR_WATCH_HINTS` idiom
(`check-role-word.mjs`, `check-examples-live-imports.mjs`), not a fabricated
lead.
The roots are hoisted to POPULATION_ROOTS so the predicate and the declaration
cannot drift, and four self-test cases pin both directions: every
separator-less root is declared as `<root>/**`, nothing is declared that the
gate does not walk whole, the declaration is a source LITERAL (a computed
`${r}/**` builds no hint at all), and the population really reaches across the
root. `packages/**` is deliberately NOT declared — this gate opens each
package's `src` subtree and its package.json, not the root entire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_af22b339-91b5-5814-8b9b-2453fc5b3f68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] driver-sql hardcodes the os migrate multi-value-columns CLI command id in an operator warning, and nothing fails if the command is renamed

2 participants

@yinlianghui@claude