fix(cli): print the host-import classification the i18n load swallowed - #14380
Conversation
#14118) `serve`'s auto-registration of `@objectstack/service-i18n` ended in a bare `catch {}`. The tolerance was right — a missing i18n package is a supported configuration; `@objectstack/core` pre-injects `createMemoryI18n` for the unprovided `i18n` core service, i18n has no `Serve.CAPABILITY_PROVIDERS` entry, and `requires: ['i18n']` opens the tier without reaching the fail-fast branch that makes a missing provider a hard boot error. The SILENCE was not: the catch threw away the classification `createHostImporter` had already produced, so an app that declares the package and whose install is pruned, unbuilt, or published with no loadable entry reached it as the same silence as an app that never installed it. The catch now captures the error and prints a diagnosis ahead of behaviour that does not change — the same shape PR #14042 gave the cluster-driver load for #13463, at the one site that repair did not reach. Nothing is re-thrown. Only the kind TOKEN is interpolated here; every word of remedy comes from `err.message`, which the importer composes per kind. That is what makes the site correct for all three kinds — `declared-no-loadable-entry` included — without a two-way branch to go stale, which is exactly what #14270 records at three other consumers of this classification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…rve-i18n-catch-classification
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 07139d1ad31d988377da1e5d07814b6ed660cde7 && git checkout 07139d1ad31d988377da1e5d07814b6ed660cde7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 26408929123da4452ab887ba3006936cdf1f96b0 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 26408929123da4452ab887ba3006936cdf1f96b0
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a
|
Fixes #14118
serve's optional i18n load ended in a barecatch {}. PR #14042 repaired thesame shape at the cluster-driver load for #13463; this is the one site that
repair did not reach.
The defect, and what stays exactly as it was
The catch was right to tolerate and wrong to be silent. Those are two
different acts, and the empty block did both:
createHostImporterhad already classified why the import failed. Discardingthat classification made "you never installed the optional package" and "the
package you DO declare is broken" the same event, so a pruned install, an
unbuilt dist, or a publish with no loadable entry read as a deliberate opt-out.
That is the trap #14042 named at the cluster site: "a driver with a broken
dependency reported as 'not registered', sending operators to look for a package
already installed."
Nothing is re-thrown, and the diagnostic is a diagnosis printed ahead of
unchanged behaviour — every branch continues the boot exactly as before.
Checked first: the i18n path really is optional by design
The card asked for this to be confirmed on the current tree before implementing,
because "required in some configuration" would make it a larger card. Four
independent readings on
origin/main, all in agreement:i18nitselfCORE_FALLBACK_FACTORIESinpackages/core/src/fallbacks/index.tsmapsi18n: createMemoryI18n, pre-injected for every unprovidedcoreservice. Contrastjob, deliberately absent from that map precisely so its absence throwsServe.CAPABILITY_PROVIDERShas noi18nkey, and theCAPABILITY_TO_TIERdocblock says so in as many words —requires: ['i18n']opens the tier, and never reaches thedeclaredRequiresbranch that turns a missing provider into a hard boot errorloadOptionalServicePlugin(..., { required: true })is reached only by@objectstack/service-aiand@objectstack/service-ai-studiopackages/runtime/src/app-plugin.tsauto-registers the in-memory fallback atinfo— "Install I18nServicePlugin from @objectstack/service-i18n for file-based / production use" — an upgrade, not a requirement.packages/cli/package.jsondeclares the package in no dependency field⇒ a missing
@objectstack/service-i18nis a supported configuration, so thetolerance stays and the card is implementable as filed.
That reading also settles the log level under the AGENTS.md rule: this is a
functional degradation — the deployment is visibly smaller and nothing that
claims to be persisted is lost — so it is
warn, noterror.All three kinds, by construction rather than by branch table
#14271 (#14041) landed a third
HostImportFailureKind,declared-no-loadable-entry, after this seam's two-kind consumers were written.The new site branches on known kind vs no kind and interpolates only the kind
token — every word of remedy comes from
err.message, which the importercomposes per kind (
undeclaredMessage/unresolvableMessage/noLoadableEntryMessage).That is deliberate, and it is why this site cannot join #14270's population:
those three consumers pick a remedy with a two-way branch and now hand
declared-no-loadable-entrythe "declare it in your package.json" line for apackage that is already declared and installed. A local re-wording here
would be the same defect; deferring cannot be, and it is correct for a fourth
kind too.
What an operator sees, with the consequence stated before the diagnosis:
An error carrying no kind resolved and then crashed while evaluating — a
different fact, a distinct sentence, and its stack carried, because for a crash
the throwing frame is the diagnosis.
Diagnostics go to stderr via
console.warn;serve-stdio-stdout-purity.e2e.test.tsrequires that of everything which is notan MCP protocol frame.
Tests — red-first, both legs proven on disk
packages/cli/src/commands/serve-i18n-load-diagnostic.test.ts, 14 assertions.Every classified error comes out of the real
createHostImporteragainst atemp host app, so the pins read the wording the importer actually composes; a
test that hand-set
objectstackHostImportFailureKindwould survive a change tohow the classification is produced.
Two ablations, each with its mutation proven on disk (grep counts for the
injected and removed text, plus a
git hash-objectcomparison) and each restoreproven byte-identical to the
HEADblob before anything else was measured:catch {}site passes the package it actually loads, through the formatter(13 passed)hostImportFailureKind(err)→undefined)Fixed tree: 14 passed (14).
One fixture note worth recording:
declared-unresolvableis unproducible for areal workspace package inside a
pnpm exectest run, because the workspacestore is on
NODE_PATHand the CJSrequire.resolvethat decides that kindfinds the package whatever a temp host app declares — #4719's hazard showing up
in the harness itself. The classified fixtures therefore import a name no store
can supply; the spelling
servereally passes is pinned at the call site, whereit is a fact about the code rather than about a container's
node_modules.serve-organizations-message-spelling.test.tsis untouched and unaffected — noremedy formatter was modified, and the cluster-driver site's bytes are unchanged.
Census the card asked for, and did not itself run
The card measured only
serve.ts(1 bare catch of 6importFromHostsites) andasked whoever took it to say whether the other in-repo consumers of the
declared leg carry the same class. They were examined:
packages/verify/src/harness.ts(~:524) — not this class. It readshostImportFailureKind(e)and carriese.messageinto the error it raises.packages/qa/dogfood/test/enterprise-organizations.ts(~:119) — not thisclass, same shape.
Neither swallows anything. Both do carry the stale two-way kind branch, which
is already filed as #14270 (verified by reading that card — it names both files
explicitly), so nothing new was filed and neither file was touched.
Verification
Everything below was run at head
2640892912— the final commit, whichmerges current
origin/maininto the branch. The union was re-derived afterthat merge (
scripts/pm/dispatch-gates.mjsitself changed in it), and came backidentical.
Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackon the actual change set after the final commit — 37families, harvested with
--commandsso neither spelling nor theconvention-triggered section could be dropped.
PREREQUISITE NOT METbranch(exit 3, which those scripts document as explicitly not a finding):
check-test-completeness.mjsgrades a savedturbo run testlog that only CIproduces, and
pm/check-half-states.mjsneeds repo-scoped egress this seatdoes not have.
pnpm check:i18n,check:i18n-coverage,check:dual-build-cjs-loadsandcheck:type-check-debtfirst refused on an unbuilt workspace; re-run greenafter
turbo run buildacross./packages/*,./packages/*/*,./examples/*(71/71 tasks).
pnpm --filter @objectstack/cli typecheckgreen, andtsc --listFilesconfirms both edited files are in that program (1 hit each) — the package
excludes no tests, so the verdict really covers the new test file.
pnpm lint— the whole-repo eslint scan (eslint . --no-inline-config),run in full rather than narrowed, exit 0.
pnpm check:nul-bytesgreen (7833 files scanned), plus a directgrep -naPcontrol-character sweep of the three changed files.serve-i18n-load-diagnostic.test.ts+serve-organizations-message-spelling.test.ts— 25 passed (25).🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code