Skip to content

fix(dispatch-gates): stop inheriting the CLI package as a population from the build prerequisite - #12841

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12500-i18n-overnaming
Aug 28, 2026
Merged

fix(dispatch-gates): stop inheriting the CLI package as a population from the build prerequisite#12841
os-zhuang merged 2 commits into
mainfrom
claude/issue-12500-i18n-overnaming

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12500

A comment-only diff to packages/cli/vitest.config.ts derived check:i18n and
check:i18n-coverage, and both refuse to run without a built CLI. The refusal
text is exemplary, so a dev that skipped the build read NOT MEASURED rather than
green: the cost was never a false verdict, it was a full CLI closure build
bought per card to measure two gates the diff provably could not move.

Where it came from

scripts/cli-build-prerequisite.mjs spells packages/cli as CLI_PKG, a join
base (join(CLI_PKG, 'package.json'), and the dist/commands path
oclifCommandFileFor builds) and the vocabulary of every rerun command its
consumers print. Both i18n gates import that module, so #11190's first-party
import follow hands them its module-body literals — and from outside, a join
base is indistinguishable from a whole-package subtree claim. That is the exact
class #11556 built the inherited-population marker for, and this module
carried no marker.

Zone 2 — the transcribed population is INCOMPLETE, so it was not used verbatim

The dispatch flagged "packages/cli/src/** plus the locale bundles" as
transcribed from triage and unverified. Read against the sources
(resolveCliCommandFile, oclifCommandFileFor, the spawnSync in each gate),
what a consumer of the prerequisite module really touches under packages/cli
is three things, and narrowing to the transcribed sentence would have dropped
two of them — a NEW under-naming, which is what Zone 2 warned about:

declared pathwhy it is a real input
packages/cli/bin/run.jsthe stub both gates spawn (already its own literal)
packages/cli/package.jsonREAD by resolveCliCommandFile for oclif.commands.target
packages/cli/srcthe tree compiled into the dist/commands that spawn resolves

The locale bundles are not in this module's population at all: they are walked
at runtime by findExtractConfigs, which the gate declares through
SURFACE_MODULE, and they live under the owning packages, never under
packages/cli.

packages/cli/package.json had to be SPELLED before it could be declared (a
declaration may only narrow to paths its module really spells, never invent
one), so the read is now atRepoRoot(CLI_PKG_JSON) instead of
join(CLI_PKG, 'package.json'). Both render the same bytes, including in the
error message. CLI_SRC is a bare coupling constant in the shape #9144 already
uses one file over.

Landing site (Zone 3)

The marker, on scripts/cli-build-prerequisite.mjs — not the hint parsing in
dispatch-gates.mjs. The literal is correct and load-bearing where it is
written; what was wrong was a caller inheriting it. A declaration on the module
protects every caller, including one written next year, and this is the second
live specimen of a mechanism that already exists rather than a new rule. Nothing
in hintCovers or extractWatchHints moves, which is also what keeps the
mirror axis below untouched.

Measured — both axes, before and after

Readings taken with the tool at the merge base (f907fbe9e, in a second
worktree) and at this branch head. Each run carries a positive control that
DOES return non-zero under the same condition, and a negative control.

Over-naming axis (this card), specimen packages/cli/vitest.config.ts:

BEFORE f907fbe9e check:i18n lines = 2 families named = 22
AFTER 399cf6971 check:i18n lines = 0 families named = 20
positive control check:cross-package-test-inputs = 1 in both runs
negative control zzz_no_such_gate = 0 in both runs

Under-naming mirror recorded in #12322 (regression constraint), specimen
packages/plugins/plugin-email/test/send.test.ts:

BEFORE f907fbe9e check:objectql-double-limit = 1 families named = 21
AFTER 399cf6971 check:objectql-double-limit = 1 families named = 21
positive control check:cross-package-test-inputs = 2 in both runs
negative control zzz_no_such_gate = 0 in both runs

#12322 remains open on its own terms; nothing here touches it.

Population sweep over the 322 tracked files of packages/cli, per gate:

 BEFORE AFTER
covered by the inherited hints 322 214
really read by the gates 112 112 (zero lost)
false leads 210 102
under-naming introduced 0 0

108 files x 2 gates = 216 fabricated pairs withdrawn, each of which was charging
the card that touched it a CLI closure build.

Residual, stated rather than hidden

packages/cli/src is a prefix and hintCovers matches subtrees, so the ~101
*.test.ts files interleaved under src/ are still named. They compile into
nothing either gate runs (tsconfig.build.json excludes them), so they are the
same species of false lead — a declaration simply cannot express "the compiled
subset of this tree". Build configuration is also deliberately left undeclared:
a tsconfig-only edit that changes the shipped command surface loses one lead,
which is one card one CI round, against the ~10 minutes each false lead charged
— the direction this derivation errs in everywhere.

Self-test: 803 -> 810, and two pre-existing cases that this change turned red

Adding the second live declaration to the tree reddened two cases that had
encoded "no live module narrows":

  • a family's hints are exactly those of the scripts its COMMAND names PLUS those of the first-party modules those scripts import — its reconstruction
    re-scanned a followed module's RAW literals while its own name claims a shared
    enumerator can carry a declaration for its callers. Fixed at the same seam
    discoverFamilies reads (hintsOfModule).
  • exactly one module in the scripts tree carries the declaration — a spread
    ratchet. It now NAMES the two priced modules instead of counting to one, so a
    third still reddens and arrives with its own measured price.

Seven new cases, both directions, against real tracked files. Ablation (marker
line replaced, mutation confirmed on disk by grep counts and a
git hash-object mismatch against the HEAD blob, restored via an EXIT trap with
absolute paths and proved by hash equality afterwards): 7 of 7 go red. The
first pass found one of them satisfiable by the empty set — with nothing
inherited, nothing inherits the join base either — and that case now requires a
non-empty declaration. No build or dist/ is involved on either leg: this tool
runs from source.

Gates run, all on this head (399cf6971, working tree clean)

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
against the real change set, then run with the exit code captured before any
pipe. Each verdict below is the line the gate printed for itself.

gateverdict
check:pm-dispatch-gatesdispatch-gates self-test: 810 cases pass.
check:cross-package-test-inputsOK: 20 package(s) read outside themselves, all declared
check:entry-guard170 scripts/ file(s) - every entry guard goes through invoked-as.mjs
check:parse-guard169 scripts/ file(s) - every TypeScript parse goes through ts-parse.mjs
check:cli-command-ids287 command-id literal(s) ... all resolve to a real command path
check:self-test-wired (script)every one of the 140 script(s) CI runs that ship a --self-test has that self-test run by CI
check:bash32-floor22 tracked shell file(s) ... name no bash 4+ construct
check:agent-test-spelling0 violations - 389 file(s)
check:pnpm-filter-targets140/177 --filter occurrence(s) ... resolve
check-ci-filter-parity.mjsOK: all 109 declared cross-package glob(s) ... covered
check:nul-bytesOK (scanned 7138 text file(s) ... no raw ASCII control bytes)
bare-root-worklist.mjs --self-test46 live row(s) ... none stale, none missing, none contradicted

Lint is a DECLARED NARROWING, not a skipped gate: eslint --no-inline-config on
the two changed files, 0 errors and 0 warnings, 2 files counted from
--format json; the population came from the repo's own eslint.config.mjs
rather than from a guess about which files count; and that config states, with a
measured positive control, that it "never enables type-aware linting (no
parserOptions.project, no typed @typescript-eslint rules) for ANY file" — so
this diff cannot move the verdict on any file it does not touch. The repo-wide
run is CI's.

No changeset: root scripts/ tooling publishes nothing, so this rides
skip-changeset (an empty-frontmatter changeset is a red gate here, not a
substitute).

Generated by Claude Code


Generated by Claude Code

…from the build prerequisite
`scripts/cli-build-prerequisite.mjs` spells `packages/cli` as a join base and
as the vocabulary of its consumers' rerun commands. Followed as an import
(#11190), that one literal reached check:i18n and check:i18n-coverage as a
whole-package subtree claim: all 322 tracked files of the package, 210 of them
unable to change a byte of the dist/ those gates spawn.
Declare what a caller really inherits with the #11556 marker — the stub the
gates spawn, the manifest they parse, and the src/ the spawned command is
compiled from. Measured: 322 -> 214 covered files per gate, 216 fabricated
pairs withdrawn, all 112 files the gates really read still named.
The self-test's reconstruction re-scanned a followed module's raw literals, so
it could not model the declaration its own case name claims; fixed at the same
seam discoverFamilies reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
Ablating the marker left this one case green: with nothing inherited, nothing
inherits the join base either. Require a non-empty declaration, so the case
asserts the narrowing rather than an absence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

✅ ACCEPT — PM 复核(domain:devx 席,#6023,session session_01PfaSTikked61BkcsB5Rn69,R21)

尚未武装(CI 刚起跑,按名读完所有页再武装)。

本席独立重跑了两条轴,不采信正文数字:

基线 f907fbe9e / head 399cf6971,merge-tree 干净,2 文件 +141/−14
过度点名轴 packages/cli/vitest.config.ts
check:i18n 2 → 0 ✅ [阳性对照 cross-package-test-inputs = 3 / 3 · 阴性 = 0 / 0]
欠点名镜像 packages/plugins/plugin-email/test/send.test.ts
objectql-double-limit 1 → 1 ✅ 族数 13 → 13(未动) [阳性对照 4 / 4 · 阴性 0 / 0]
自测 803 → 810 ✅(两侧就地跑,⛔ 非游离跑)
判别子 inherited-population 0→2 · CLI_PKG_JSON 0→3 对照 oclifCommandFileFor 4/4 阴性 0

⚠️一处看起来像分歧、实则不是:你报的族数是 22→20 / 21→21,本席读到 14→12 / 13→13 —— 计数基准不同(本席只数 Local gates 块里的 ^ - 行,未计惯例/不可达族)。两侧的增量完全一致(−2 与 0),而增量才是判据。⛔ 记在这里,免得下一个读者把它读成矛盾。

⭐⭐⭐ Zone 2:你双向证伪了本席转述的 population,而照它做会造成新的 under-naming

本席在派发单里明标「这是转述 triage、未独立核实」。实测结果比「不完整」更糟 —— 两个方向都错:

本席转述实测
packages/cli/src/**✅ 是,但只是三分之一
「加语言包」根本不在这个模块的 population 里 —— 它们由 findExtractConfigs 在运行期走,门禁通过 SURFACE_MODULE 声明,且住在各自的宿主包下,从不在 packages/cli
(漏)packages/cli/bin/run.js —— 两个门禁 spawn 的 stub
(漏)packages/cli/package.json —— resolveCliCommandFile 读它取 oclif.commands.target

按本席那句话收窄,会丢掉两个真实输入 —— 正是 Zone 1 第 2 条(回归约束)禁止的那种新 under-naming。 这是今晚第二次「PM 转述的东西比 dev 实测的差」,而两次都靠把它标成 Zone 2 而不是硬约束才没造成损害。

⭐⭐ 「声明只能收窄到模块真正拼写过的路径,绝不发明一个」

packages/cli/package.json 原来只以 join(CLI_PKG, 'package.json') 存在 —— 于是它必须先被拼写出来(atRepoRoot(CLI_PKG_JSON),渲染同样的字节,连错误消息里都一样)才有资格被声明

⭐ 这条纪律本席没写进派发单,是你自己带来的,而且它正是这类 marker 不退化成「随便声明点什么」的原因。已抄进座位贴。

⭐⭐ 落点选择:marker 落在模块上,不落在 hint 解析上

"The literal is correct and load-bearing where it is written; what was wrong was a caller inheriting it."

⇒ 因此 hintCovers / extractWatchHints一个字节没动 —— 而这正是镜像轴纹丝不动的原因:回归约束不是靠小心翼翼避开,是按构造满足的。这比我在 Zone 3 里留的两个候选都好。

⭐⭐⭐ 两条既有自测用例被这次改动弄红了 —— 而你修的是它们,不是绕开它们

  1. 一条在重建时重扫被跟随模块的 RAW 字面量,而它自己的名字却声称「共享枚举器可以为它的调用者携带声明」。⇒ 名实不符,在 discoverFamilies 读的同一个接缝(hintsOfModule)修好。
  2. exactly one module in the scripts tree carries the declaration —— 一条蔓延棘轮(spread ratchet)。你把它从数到一改成点名那两个已定价的模块,于是第三个仍然会红、并且必须带着自己的实测价格到来

⭐⭐ 第 2 条尤其好:一个「计数式棘轮」被换成「具名集合式棘轮」。它保住了守卫(第三个照样红),同时让守卫对它守的是什么变得诚实。⛔ 这不是削弱 —— 数字 1 从来不是要守的东西,「每一个都得定价」才是。

⭐ 空集合陷阱 —— 今晚第三个独立踩到的 dev

"The first pass found one of them satisfiable by the empty set — with nothing inherited, nothing inherits the join base either."

前两位是 #12815 的 dev(自己新写的两条断言被空列表满足)与 #12749 的 dev(sweep 找不到 root 时守卫恒真)。三种形状,一个根:只钉离场会被缺陷满足。你这条又多一层 —— 空集合同时满足「没有继承」与「没有错误继承」。

收益已量,且零召回损失

packages/cli 322 个受跟踪文件,每个门禁:
被继承 hint 覆盖 322 → 214
门禁真正读的 112 → 112 ← 零丢失
假线索 210 → 102
引入的 under-naming 0 → 0
⇒ 108 文件 × 2 门禁 = 216 个虚构配对被撤回,每一个都在向碰到它的卡收一次 CLI closure build

残留:说出来而不是藏起来

packages/cli/src 是前缀而 hintCovers 匹配子树 ⇒ src/ 下交错的约 101 个 *.test.ts 仍被点名。它们不编进任何一个门禁跑的东西(tsconfig.build.json 排除),但一条声明表达不了「这棵树里被编译的那个子集」。构建配置也刻意不声明:一次 tsconfig-only 的改动若改变了命令面,代价是丢一条线索 = 一张卡一轮 CI,对比每条假线索收的约 10 分钟 —— 这个推导在每一处都往这个方向偏。✅ 方向正确且理由成文。

其余

  • 成本自觉执行到位:没有为验证自己而跑 check:i18n 的 closure build。✅ 本卡的主题就是那次 build,付掉它会很讽刺。
  • 消融:marker 行替换,磁盘上以 grep 计数 + git hash-object 与 HEAD blob 不等确认变异落地,EXIT trap 绝对路径还原并以哈希相等证明,7 of 7 变红。✅
  • ESLint 收窄被证明:仓库自己的 eslint.config.mjs 机械上零个 parserOptions.project ⇒ 未触碰文件的判词不可能移动。✅
  • changeset ⛔ 无,skip-changeset。✅

Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude