Skip to content

fix(cli): print the host-import classification the i18n load swallowed - #14380

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-14118-serve-i18n-catch-classification
Sep 2, 2026
Merged

fix(cli): print the host-import classification the i18n load swallowed#14380
os-trump merged 2 commits into
mainfrom
claude/issue-14118-serve-i18n-catch-classification

Conversation

@os-trump

@os-trump os-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14118

serve's optional i18n load ended in a bare catch {}. PR #14042 repaired the
same 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:

const { I18nServicePlugin } = await importFromHost(i18nPkg);

} catch {
  // @objectstack/service-i18n not installed — kernel memory fallback will handle i18n
}

createHostImporter had already classified why the import failed. Discarding
that 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:

reading evidence
the kernel supplies i18n itself CORE_FALLBACK_FACTORIES in packages/core/src/fallbacks/index.ts maps i18n: createMemoryI18n, pre-injected for every unprovided core service. Contrast job, deliberately absent from that map precisely so its absence throws
no capability provider entry Serve.CAPABILITY_PROVIDERS has no i18n key, and the CAPABILITY_TO_TIER docblock says so in as many words — requires: ['i18n'] opens the tier, and never reaches the declaredRequires branch that turns a missing provider into a hard boot error
never loaded as required loadOptionalServicePlugin(..., { required: true }) is reached only by @objectstack/service-ai and @objectstack/service-ai-studio
the runtime says it out loud packages/runtime/src/app-plugin.ts auto-registers the in-memory fallback at info"Install I18nServicePlugin from @objectstack/service-i18n for file-based / production use" — an upgrade, not a requirement. packages/cli/package.json declares the package in no dependency field

⇒ a missing @objectstack/service-i18n is a supported configuration, so the
tolerance 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, not error.

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 importer
composes 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-entry the "declare it in your package.json" line for a
package 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:

[i18n] @objectstack/service-i18n was requested but could not be loaded (declared-unresolvable).
  Unchanged: this boot serves i18n from the kernel in-memory fallback, so what follows
  is why the file-based service is absent — not a boot failure.
Cannot find module '@objectstack/service-i18n': the host app DECLARES it …

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.ts requires that of everything which is not
an 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 createHostImporter against a
temp host app, so the pins read the wording the importer actually composes; a
test that hand-set objectstackHostImportFailureKind would survive a change to
how 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-object comparison) and each restore
proven byte-identical to the HEAD blob before anything else was measured:

ablation result
revert the catch to the pre-fix bare catch {} 1 redsite passes the package it actually loads, through the formatter (13 passed)
discard the classification in the formatter (hostImportFailureKind(err)undefined) 5 red — all three kind cases, the identical-framing pin, and its control (9 passed)

Fixed tree: 14 passed (14).

One fixture note worth recording: declared-unresolvable is unproducible for a
real workspace package inside a pnpm exec test run, because the workspace
store is on NODE_PATH and the CJS require.resolve that decides that kind
finds 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 serve really passes is pinned at the call site, where
it is a fact about the code rather than about a container's node_modules.

serve-organizations-message-spelling.test.ts is untouched and unaffected — no
remedy 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 6 importFromHost sites) and
asked 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 reads
    hostImportFailureKind(e) and carries e.message into the error it raises.
  • packages/qa/dogfood/test/enterprise-organizations.ts (~:119) — not this
    class, 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, which
merges current origin/main into the branch. The union was re-derived after
that merge (scripts/pm/dispatch-gates.mjs itself changed in it), and came back
identical.

Gate union derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the actual change set after the final commit — 37
families, harvested with --commands so neither spelling nor the
convention-triggered section could be dropped.

  • 35 green.
  • 2 NOT MEASURED, each by the gate's own PREREQUISITE NOT MET branch
    (exit 3, which those scripts document as explicitly not a finding):
    check-test-completeness.mjs grades a saved turbo run test log that only CI
    produces, and pm/check-half-states.mjs needs repo-scoped egress this seat
    does not have.
  • pnpm check:i18n, check:i18n-coverage, check:dual-build-cjs-loads and
    check:type-check-debt first refused on an unbuilt workspace; re-run green
    after turbo run build across ./packages/*, ./packages/*/*, ./examples/*
    (71/71 tasks).
  • pnpm --filter @objectstack/cli typecheck green, and tsc --listFiles
    confirms 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-bytes green (7833 files scanned), plus a direct
    grep -naP control-character sweep of the three changed files.
  • Target suite at this head: serve-i18n-load-diagnostic.test.ts +
    serve-organizations-message-spelling.test.ts25 passed (25).

🤖 Generated with Claude Code

https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza


Generated by Claude Code

#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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 2 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 07139d1ad31d988377da1e5d07814b6ed660cde7 — the merge of head 26408929123da4452ab887ba3006936cdf1f96b0 into base d62f990a985eb15152579ad34c99f43e76e3657a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d62f990a985eb15152579ad34c99f43e76e3657a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants