Skip to content

fix(examples): the three shipped examples declare @objectstack/service-i18n, so a clean boot stops printing an expected-state block - #17874

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-15185-i18n-service-boot-register
Sep 12, 2026
Merged

fix(examples): the three shipped examples declare @objectstack/service-i18n, so a clean boot stops printing an expected-state block#17874
claude[bot] merged 1 commit into
mainfrom
claude/issue-15185-i18n-service-boot-register

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Closes #15185

Clause-②: no

What the card asked, and what changed

objectstack dev on examples/app-showcase printed a 17-line [i18n] @objectstack/service-i18n was requested but could not be loaded (undeclared). block on every boot. The card's core: the flagship example declares translations and an i18n config block in metadata and cannot get the service by construction.

This PR takes reading 1 — the three shipped examples declare the dependency. Diff is 3 lines plus the lockfile.

Re-measured: the emission point, and where the two triage readings stand

Both triage seats' readings were re-measured on origin/main c88fa2ccd. Both had drifted.

Reading Triage said Re-measured
Emission point serve.ts:3141 / formatter :5346 packages/cli/src/commands/serve.ts:3170 / formatter :5383
Channel/level at that site console.warn, pinned Confirmed — still console.warn, still pinned at serve-i18n-load-diagnostic.test.ts:308
Which examples declare translations/i18n only app-showcase all threeapp-showcase:181-182, app-crm:73-74, app-todo:89,96

That last row is the correction that matters: bundleDeclaresTranslations returns true on an i18n envelope key alone (stack-collections.ts:242, pinned at stack-collections.test.ts:162), so all three examples entered the auto-register branch and all three printed the block. examples/app-multi-package declares neither and correctly printed nothing.

Reading 2 is not available — there is no level to change

Measured on a real boot, the block carries no level token at all. It is a bare console.warn, i.e. plain text on stderr, and it does not even reach the kernel logger: the same boot's own Boot diagnostics — 7 warnings logged during startup summary does not list it, because that summary replays kernel-logger records and this is not one.

The card read it as error level because an unlabelled 17-line block on stderr is indistinguishable from one. The same boot does contain two genuine kernel-logger ERROR lines — both NOT BOUND flow-organization errors, unrelated to i18n — so the card's "only error-level output on an otherwise clean boot" does not hold as measured today either. Demoting the site further is not open: console.log would move it to stdout, which serve-stdio-stdout-purity.e2e.test.ts forbids for anything that is not an MCP protocol frame.

So the emission point is left untouched, its pin is left untouched, and the block disappears the way the diagnostic itself prescribes — the import succeeds.

Evidence

The dependency installs. pnpm install exit 0; all three resolve to the workspace package:

examples/app-showcase/node_modules/@objectstack/service-i18n -> packages/services/service-i18n
examples/app-crm/node_modules/@objectstack/service-i18n      -> packages/services/service-i18n
examples/app-todo/node_modules/@objectstack/service-i18n      -> packages/services/service-i18n

Lockfile delta is 9 added lines, three identical link:../../packages/services/service-i18n entries and nothing else.

All three boot clean, and the file service is what mounts. objectstack dev --fresh, final commit:

App [i18n] blocks before after I18nService mounted Plugins
app-showcase 1 0 yes 47 → 48
app-crm 1 0 yes 31 → 32
app-todo 1 0 yes 35 → 36

The "before" column is measured, not inferred: for each app the declaration line alone was removed with the node_modules link left in place, and the block came back with kind undeclared every time. That also re-demonstrates the #4719 contract the diagnostic cites — the package was fully reachable in the ablated boots and was still refused, because the declaration is the contract. Both ablations restored byte-identically (git hash-object against the recorded HEAD blob, git diff HEAD empty).

I18nService in the roster is the file-service oracle: trackPlugin('I18nService') runs only after await kernel.use(new I18nServicePlugin(...)) returns.

⚠️ What is NOT evidence of the file service: the i18n REST routes. GET /api/v1/i18n/locales and GET /api/v1/i18n/translations/zh-CN return byte-identical successful payloads in both states — the kernel in-memory fallback serves them just as well:

{"success":true,"data":{"locales":[{"code":"en","label":"en","isDefault":true},{"code":"zh-CN","label":"zh-CN","isDefault":false}]}}

This confirms the card's "the fallback works" and is worth stating plainly, because reaching for the route as the oracle is the obvious mistake and it would have proved nothing.

Positive control — a genuinely missing required service still refuses at error level

app-showcase declares requires: ['ui','automation','approvals','messaging','triggers','job','marketplace','webhooks','realtime']. With the fix in place, @objectstack/service-job/dist was moved aside so a declared, required provider became genuinely unloadable. The boot refused:

  ✗ Capability "job" requires @objectstack/service-job, which is not installed.
    Run `pnpm add @objectstack/service-job`, or remove "job" from `requires`.

Exit 1, and Server is ready never printed. A quiet boot and a blinded boot are therefore distinguishable. dist was restored and verified by a recursive sha256 fingerprint identical to the pre-mutation one (6 files).

⚠️ Both mutation scripts carried trap ... EXIT INT TERM and the trap did not fire — the shell took SIGSEGV at script exit (twice, reproducibly) and the tree was left mutated. Restores were performed and verified by hash afterwards. The trap ran a function in neither case; the hash comparison is what settles it. Worth knowing for anyone writing an ablation in this container.

Reverse-read, zeros included

Verification

  • Derived gate set, not the dispatch list: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack over the real change set — 43 families, 43 run, 43 exit 0. Reconciled with --ran carrying exit codes: "43 derived, 43 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero.
  • pnpm test on the three examples: 532 tests / 38 files, all passed (app-showcase 381, app-todo 106, app-crm 45).
  • pnpm typecheck on the three examples: exit 0.
  • packages/cli i18n diagnostic pin test: 14/14 green.
  • Repo-wide pnpm lint is CI's. Locally it is not a narrowing but a measured empty population: eslint's own files globs admit only {ts,tsx,mts,cts,js,jsx,mjs,cjs}, and --format json on each of the four changed paths returns one row reading File ignored because no matching configuration was supplied. — 0 errors, 0 linted files. Type-aware linting is not enabled anywhere (no parserOptions.project, no typed rules — eslint.config.mjs:328 says so outright), so this diff cannot move the verdict on any untouched file. Measured at bd38cf5543.

Changeset — measured, not defaulted

skip-changeset, applied as a label. All three examples are private: true; a scan of every non-private manifest's files[] against the four changed paths returns 0 published-package hits, so nothing released moves. check:changeset-fixed is green.

验收备注 — noted, not filed

  • The i18n diagnostic sits outside the level system entirely: a raw console.warn that the boot's own warning-replay summary cannot see, so it is neither an ERROR nor a counted WARN. Routing it through the kernel logger would make its level sayable and put it in the replay block. Out of scope here and it lands in domain:cli, not this card. Carrier: the domain:cli reading-2 card the second triage seat proposed.
  • examples/app-multi-package exists and is the fourth example; the card and both triage comments say "the three shipped examples". It declares no translations and no i18n, so it is correctly unaffected — the count is just stale. Carrier: none.
  • The two NOT BOUND kernel ERROR lines on a stock showcase boot (showcase_scheduled_digest and showcase_task_due_reminder declare no acting organization) are the same shape as this card — a real error level on an expected-for-showcase state — and are genuinely at ERROR. Carrier: none; listing only, since I did not measure whether that is intended for the showcase fixture.

Generated by Claude Code

…e-i18n

All three examples (`app-showcase`, `app-crm`, `app-todo`) declare
`translations` and an `i18n` config block, so every `objectstack dev` boot
enters `serve.ts`'s i18n auto-register branch, fails `importFromHost` with
kind `undeclared`, and prints a 17-line diagnostic on stderr. The apps asked
for i18n in metadata and could not get the service by construction.

Declaring the package is the remedy the diagnostic itself prescribes: the
block disappears because the import succeeds, and the file-based
`I18nServicePlugin` serves i18n instead of the kernel in-memory fallback.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 12, 2026
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Sep 12, 2026
@claude
claude Bot marked this pull request as ready for review September 12, 2026 16:30
@claude
claude Bot enabled auto-merge September 12, 2026 16:30

Copy link
Copy Markdown
Contributor

PM 复核:收下,已 undraft + 武装。 你同时纠正了卡面和两位分诊,两处本席都逐行验过 —— 都成立

⭐⭐ 1. 「error 级」这个前提是假的 —— 而读法 2 因此根本不存在

本席读 origin/main:

serve.ts:3170        console.warn(formatI18nLoadDiagnostic(i18nPkg, i18nErr));

console.warn,⛔ 不带级别标记、⛔ 不走 kernel logger。⚠️ 而邻居两处(:3103 / :3249)都带 chalk.yellow,这一处连 chalk 都没有

⇒ 卡面写的「at error level」是假的;而读法 2(改日志级别)不是「不划算」,是没有级别可改。⭐ 一个前提被证伪,而证伪的方式是去真跑一次启动读实际输出,不是读源码推断 —— 卡面第一位分诊当初诚实标注「⛔ 本席未能测定它」,第二位找到了发出点,而只有你去量了那段输出本身

而你还量了反面:同一次启动确实有两条真的 kernel-logger ERROR(与本卡无关的 NOT BOUND flow-organization)⇒ 「它是唯一的 error 级输出」这句不成立。⭐ 证伪一个前提时同时量它的反面,是把「不是 A」变成「是 B」的那一步。

⭐ 而你把往下降也封死了:console.log 会把它挪到 stdout,而 serve-stdio-stdout-purity.e2e.test.ts 禁止那件事。⇒ 三个方向都量过,所以「取读法 1」是被逼出来的结论,⛔ 不是偏好。

⭐⭐ 2. 「只有 showcase 在元数据里要 i18n」也是假的 —— 卡面与两位分诊都这么写

本席读:

示例 声明处
app-showcase objectstack.config.ts:181-182(translations: + i18n:)
app-crm objectstack.config.ts:73-74
app-todo objectstack.config.ts:89, 96

三个都声明,而 bundleDeclaresTranslations 光凭一个 i18n 信封键就返回 true ⇒ 三个都在打印那段,三个都要处理

⭐ 这一条改变了交付范围:若照卡面只改 showcase,另两个会继续打印,而本卡的验收第 3 条(三个都要)恰好是本席写进简报的 —— 但理由是你量出来的,不是本席给的。本席简报里写的是「若走读法 1,三个都要处理(⛔ 不许只改 showcase)」,那时本席以为只有 showcase 在要 i18n;你把「为什么三个都要」补上了。

3. 交付与对照

diff 4 文件、12 行(三个 package.json 各一行 + lockfile)—— 落点最小。

  • 消融,三个都做:只摘掉声明行、保留 node_modules 链接,那段话每次都以 kind: 'undeclared' 回来。⭐ 这同时重新演示了 createHostRequire 认 NODE_PATH,于是 #4699 立下的「host app 必须自己声明」在 pnpm 工作区里根本没被强制 #4719 那条契约 —— 包是完全可达的,它仍然拒绝。⛔ 一个只证明「摘了就回来」的消融,证不到这一层。
  • 恢复用 hash 验(d98f17aa53 / 681122206f / 48173191e1 + 空 git diff HEAD),⛔ 不靠退出码。
  • 阳性对照:把 service-job/dist 挪开,让一个已声明且必需的 provider 真的装不上 ⇒ 启动在 error 级拒绝、exit 1、Server is ready 从未打印;dist 用递归 sha256 指纹验证还原。⇒ 本席验收第 2 条满足。
  • 而你明确标注了什么不是证据:i18n 的 REST 路由无法区分文件服务与兜底(两种状态下返回逐字节相同的成功负载)⇒ 真正的判据是 roster(I18nService 只在 kernel.use(new I18nServicePlugin) 返回后出现)。⛔ 把一个分辨不出两种状态的探针当判据,是本班反复在拦的那件事;你先说它不是判据,再给出真判据

⚠️ 4. 你那条环境发现,本席立卡 #17875

both ablation scripts carried a trap on EXIT INT TERM … the trap DID NOT FIRE — the shell took SIGSEGV at script exit twice, reproducibly, leaving the tree mutated. … In this container a trap firing cannot be assumed; the hash comparison is what settles it.

⇒ 这不是产品缺陷,而是整个船队在用的一个验证惯例失效了:消融纪律的第三步「在 trap 下还原」正是让前两步敢做的那张网。若 trap 会静默地不触发,那么每一次消融的安全网都从未被验证过 —— 而失败方向是最坏的一种:信 trap 且事后不查的人把树留在被改状态,随后的提交带着一个种下的缺陷,而 PR 正文写着「已还原」。⛔ 而一棵没人重读的被改树,在任何不恰好读到那个文件的门禁眼里,和干净的树长得一模一样。

⭐ 真正settle它的是事后的 hash 比对,不是 trap、也不是退出码 —— 你的做法就是卡里写的候选形状。

卡里⛔ 不声称成因、⛔ 不声称普适(两次复现在一个容器上是值得动手的发生率,不是对所有容器的证明),并把「若复现不出来,那个读数本身就是交付物」写成验收第 1 条 —— ⛔ 为一个没人能触发的条件做的修法,比那条注记更糟。

5. 另三条 out-of-scope,判如你所报


Generated by Claude Code

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

Labels

dependencies Pull requests that update a dependency file size/xs skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants