From 00ae38f20e2971698973b8cd969657da24999c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:39:11 +0000 Subject: [PATCH] fix(cli): print the host-import classification the i18n load swallowed (#14118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .changeset/serve-i18n-load-classification.md | 28 ++ .../serve-i18n-load-diagnostic.test.ts | 377 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 98 ++++- 3 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 .changeset/serve-i18n-load-classification.md create mode 100644 packages/cli/src/commands/serve-i18n-load-diagnostic.test.ts diff --git a/.changeset/serve-i18n-load-classification.md b/.changeset/serve-i18n-load-classification.md new file mode 100644 index 0000000000..188eb373c8 --- /dev/null +++ b/.changeset/serve-i18n-load-classification.md @@ -0,0 +1,28 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `serve`'s optional i18n load prints the host-import classification it used to swallow (#14118) + +The auto-registration of `@objectstack/service-i18n` ended in a bare `catch {}`. +Tolerating the absence was right — a missing i18n package is a supported +configuration, and the kernel pre-injects its in-memory `i18n` fallback — but +the catch also discarded the classification `createHostImporter` had already +produced. An app that DECLARES the package and whose install is pruned, +unbuilt, or published with no loadable entry reached that catch as exactly the +same silence as an app that never installed it, so a broken install read as a +deliberate opt-out. + +The catch now captures the error, reads `hostImportFailureKind`, and warns on +**stderr** with the kind and the importer's own per-kind remedy text; an error +carrying no kind resolved and then crashed while evaluating, and says so with +its stack. Behaviour is unchanged in every branch — nothing is re-thrown, the +boot continues, and the consequence ("this boot serves i18n from the kernel +in-memory fallback") is stated ahead of the diagnosis. + +Only the kind token is interpolated locally; every word of remedy comes from +the importer, so the site is correct for all three kinds — including +`declared-no-loadable-entry` — without a branch table that can go stale. + +This is the same class PR #14042 repaired at the cluster-driver load, at the +one site that repair did not reach. diff --git a/packages/cli/src/commands/serve-i18n-load-diagnostic.test.ts b/packages/cli/src/commands/serve-i18n-load-diagnostic.test.ts new file mode 100644 index 0000000000..73607e3216 --- /dev/null +++ b/packages/cli/src/commands/serve-i18n-load-diagnostic.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The optional i18n service load PRINTS the classification it used to swallow + * (#14118) — and prints it as a diagnosis ahead of behaviour that does not + * change. + * + * ── What this closes ───────────────────────────────────────────────────── + * + * #13463 measured that `serve`'s cluster-driver load discarded the + * `objectstackHostImportFailureKind` classification in an empty catch, so a + * broken install surfaced later as a registry problem naming the wrong remedy. + * PR #14042 repaired that site. The same shape survived one block over, at the + * i18n service load, where the catch was bare: + * + * const { I18nServicePlugin } = await importFromHost(i18nPkg); + * … + * } catch { + * + * The catch was right to TOLERATE — a missing `@objectstack/service-i18n` is a + * supported configuration — and wrong to be silent, because tolerating an + * absence and discarding a classification are two different acts. An app that + * DECLARES the package and whose install is pruned, unbuilt, or published with + * no loadable entry reached that catch as the same silence as an app that never + * installed it. + * + * ── Why the assertions are shaped this way ─────────────────────────────── + * + * **Real errors, not hand-set properties.** Every classified error below comes + * out of `createHostImporter` against a temp host app, so what these pins read + * is the wording the importer actually composes per kind. A test that assigned + * `objectstackHostImportFailureKind` itself would keep passing through a change + * to how the classification is produced — the half most worth catching. + * + * **Three kinds, and the fourth.** #14271 (#14041) added a THIRD kind, + * `declared-no-loadable-entry`, after this seam's two-kind consumers were + * written — which is what #14270 is: three OTHER sites still pick a remedy with + * a two-way branch and hand the new kind the "declare it in your package.json" + * line, for a package that is already declared AND installed. The site pinned + * here cannot join that population, because it interpolates only the kind TOKEN + * and takes every word of remedy from `err.message`. `framing is identical + * across kinds` is the pin that keeps it that way: it fails the moment someone + * adds a per-kind sentence here, which is the first step toward the #14270 + * shape. The per-kind wording pins beside it prove the deferral WORKS — each + * kind's own remedy reaches the operator, and the two wrong ones do not. + * + * **Both halves of the channel.** `serve-stdio-stdout-purity.e2e.test.ts` + * requires that everything which is not an MCP protocol frame stays off stdout. + * That e2e boots `serve` and never fails this load, so it cannot see this line. + * Two pins cover it here instead: the site prints through `console.warn` (read + * from the source window, because the branch has no unit-level boot seam — + * #14054 records that gap for the sibling cluster site), and `console.warn` + * really does land on stderr with nothing on stdout (measured by swapping both + * stream writes, so it is a measurement rather than a restatement of Node's + * documentation). + * + * **The no-re-throw pin is deliberate.** The tolerance IS the point of this + * catch — `i18n` is a `core` service with a kernel in-memory fallback, it 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. A later edit that "improves" this diagnosis into a `throw` + * would turn a supported configuration into a refused boot, so the source + * window is asserted to contain no `throw`. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + createHostImporter, + hostImportFailureKind, + type HostImportFailureKind, +} from '@objectstack/types/node'; +import { formatI18nLoadDiagnostic } from './serve.js'; + +/** + * The package the site loads. Spelled here as the operator reads it; the pin + * that this is also the spelling `serve` PASSES is `site passes the package it + * actually loads` below, which reads the call site itself. + */ +const PKG = '@objectstack/service-i18n'; + +/** + * The name the CLASSIFIED fixtures below import — deliberately NOT `PKG`. + * + * `declared-unresolvable` is unproducible for a real workspace package inside + * this test run: `pnpm exec` puts the workspace store on `NODE_PATH`, so the + * CJS `require.resolve` that decides that kind finds the package no matter what + * a temp host app declares. That is #4719's hazard showing up in the test + * harness itself, and a fixture name no store can supply is the honest way + * around it — the same reason the `undeclared` leg injects its own rejecting + * fallback. The formatter takes the package name as a PARAMETER, so what these + * pins measure is unchanged; the spelling `serve` really passes is pinned at the + * call site instead, where it is a fact about the code rather than about this + * container's `node_modules`. + */ +const FIXTURE_PKG = '@objectstack-i18n-fixture/absent-service'; + +const KINDS: readonly HostImportFailureKind[] = [ + 'undeclared', + 'declared-unresolvable', + 'declared-no-loadable-entry', +]; + +const roots: string[] = []; +afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); +}); + +/** A throwaway host app — the root `createHostImporter` reads declarations from. */ +function hostRoot(tag: string, dependencies: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), `os-i18n-diag-${tag}-`)); + roots.push(root); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'i18n-diagnostic-host-fixture', type: 'module', dependencies }), + 'utf8', + ); + return root; +} + +/** Put a package with a chosen manifest shape into that host app's `node_modules`. */ +function installShapedPackage( + root: string, + name: string, + manifest: Record, + files: Record, +): void { + const dir = join(root, 'node_modules', ...name.split('/')); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', ...manifest }), + 'utf8', + ); + for (const rel of Object.keys(files)) { + const target = join(dir, rel); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, files[rel] as string, 'utf8'); + } +} + +/** + * A REAL failure of each kind, straight out of `createHostImporter`. + * + * The `undeclared` leg supplies a rejecting `fallbackImport` on purpose. That + * fallback is a caller-supplied seam by contract (#10943), so injecting one + * makes the case hermetic: without it the branch would depend on whether + * `@objectstack/service-i18n` happens to be reachable from whatever package + * this test file runs inside, and a workspace that hoisted it would turn this + * pin green by never failing at all. + */ +async function classifiedFailure(kind: HostImportFailureKind): Promise { + let importer: ReturnType; + if (kind === 'undeclared') { + importer = createHostImporter(hostRoot('undeclared'), { + fallbackImport: () => + Promise.reject( + Object.assign(new Error(`Cannot find package '${FIXTURE_PKG}'`), { + code: 'ERR_MODULE_NOT_FOUND', + }), + ), + }); + } else if (kind === 'declared-unresolvable') { + // Declared, and nothing installed: the INSTALL is the problem. + importer = createHostImporter(hostRoot('unresolvable', { [FIXTURE_PKG]: '^1.0.0' })); + } else { + // Declared AND installed, publishing only a `types` condition: no edit to + // the app and no install action can ever fix this — the PACKAGE is. + const root = hostRoot('no-entry', { [FIXTURE_PKG]: '^1.0.0' }); + installShapedPackage( + root, + FIXTURE_PKG, + { exports: { '.': { types: './dist/index.d.ts' } } }, + { 'dist/index.d.ts': 'export declare const BUILD: string;\n' }, + ); + importer = createHostImporter(root); + } + const err = await importer(FIXTURE_PKG).then( + () => undefined, + (e: unknown) => e, + ); + // The fixture's own control: if a case ever stops failing, every assertion + // built on it would pass over an error that never happened. + expect(err, `fixture for ${kind} did not fail at all`).toBeDefined(); + expect(hostImportFailureKind(err), `fixture produced the wrong kind for ${kind}`).toBe(kind); + return err; +} + +const firstLine = (s: string) => s.split('\n')[0]; +const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** + * The output with everything CASE-SPECIFIC removed — the kind token and the + * importer's own message. What is left is this site's own framing, which must + * be the same text for every kind. + */ +const framingOf = (rendered: string, kind: HostImportFailureKind, err: unknown): string => + rendered.split(messageOf(err)).join('').split(kind).join(''); + +describe('serve — the i18n load prints the host-import classification (#14118)', () => { + it.each(KINDS)('a %s failure names the package, the kind and the consequence', async (kind) => { + const err = await classifiedFailure(kind); + const rendered = formatI18nLoadDiagnostic(FIXTURE_PKG, err); + + expect(firstLine(rendered)).toBe( + `[i18n] ${FIXTURE_PKG} was requested but could not be loaded (${kind}).`, + ); + // The remedy is the importer's, verbatim — not a copy that can drift. + expect(rendered).toContain(messageOf(err)); + // The consequence, stated BEFORE the diagnosis: this is not a boot failure. + expect(rendered).toContain( + 'Unchanged: this boot serves i18n from the kernel in-memory fallback', + ); + // ⛔ The crash framing must not appear on a classified failure — that is the + // one distinction #14042 named, and it is the whole reason the kinds are read. + expect(rendered).not.toContain('resolved but threw while loading'); + }); + + it('the framing is IDENTICAL across all three kinds — only the token and the importer message differ', async () => { + const framings: string[] = []; + for (const kind of KINDS) { + const err = await classifiedFailure(kind); + framings.push(framingOf(formatI18nLoadDiagnostic(FIXTURE_PKG, err), kind, err)); + } + // One distinct framing ⇒ no per-kind sentence is written HERE. This is the + // structural half of not joining the #14270 population: a branch table that + // does not exist cannot go stale when a fourth kind lands. + expect(new Set(framings).size).toBe(1); + }); + + it("each kind's OWN remedy reaches the operator, and the other two do not (#14271 third kind)", async () => { + const rendered: Record = { + undeclared: formatI18nLoadDiagnostic(FIXTURE_PKG, await classifiedFailure('undeclared')), + 'declared-unresolvable': formatI18nLoadDiagnostic( + FIXTURE_PKG, + await classifiedFailure('declared-unresolvable'), + ), + 'declared-no-loadable-entry': formatI18nLoadDiagnostic( + FIXTURE_PKG, + await classifiedFailure('declared-no-loadable-entry'), + ), + }; + + // The app never declared it → declare it and install. + expect(rendered.undeclared).toContain('does not declare it'); + expect(rendered.undeclared).toContain("Declare it in that app's package.json"); + + // Declared, install broken → repair the INSTALL, and say the declaration is fine. + expect(rendered['declared-unresolvable']).toContain( + 'This is an INSTALL problem, not a declaration problem', + ); + expect(rendered['declared-unresolvable']).not.toContain('does not declare it'); + + // Declared AND installed, package publishes nothing loadable → the remedy is + // in the PACKAGE. Handing this case either of the two lines above is exactly + // the defect #14270 records at three other consumers of this classification. + const noEntry = rendered['declared-no-loadable-entry']; + expect(noEntry).toContain('publishes no entry that Node can load'); + expect(noEntry).toContain('The remedy lives in the package'); + expect(noEntry).not.toContain('This is an INSTALL problem'); + expect(noEntry).not.toContain('does not declare it'); + }); + + it('an error carrying NO kind resolved and then CRASHED — a different sentence, and the stack', () => { + const crash = new Error('i18n plugin blew up while evaluating'); + const rendered = formatI18nLoadDiagnostic(PKG, crash); + + expect(firstLine(rendered)).toBe( + `[i18n] ${PKG} resolved but threw while loading — this is the package's own failure, not a missing package.`, + ); + expect(rendered).not.toContain('could not be loaded ('); + expect(rendered).toContain( + 'Unchanged: this boot serves i18n from the kernel in-memory fallback', + ); + // The throwing frame IS the diagnosis for a crash, so the stack is carried. + expect(rendered).toContain(crash.stack ?? crash.message); + }); + + it('a non-Error throw is still reported rather than swallowed', () => { + const rendered = formatI18nLoadDiagnostic(PKG, 'a bare string was thrown'); + expect(firstLine(rendered)).toContain('resolved but threw while loading'); + expect(rendered).toContain('a bare string was thrown'); + }); +}); + +/** + * The call site itself. Read from source because this branch has no unit-level + * boot seam — #14054 records that no test boots `serve` for the sibling + * cluster-driver diagnosis branches either, and inventing one for a two-line + * catch is not what that card is for. The window is anchored on two literals + * that move only when the block itself is rewritten, which is when these pins + * SHOULD be re-read rather than silently satisfied. + */ +const SERVE_SOURCE = readFileSync(new URL('./serve.ts', import.meta.url), 'utf8'); + +const i18nCatchWindow = (): string => { + const start = SERVE_SOURCE.indexOf(`const i18nPkg = '${PKG}';`); + const end = SERVE_SOURCE.indexOf('} else if (!hasI18nPlugin && !configHasTranslations) {', start); + expect(start, 'the i18n load block moved — re-read these pins').toBeGreaterThan(-1); + expect(end, 'the i18n load block moved — re-read these pins').toBeGreaterThan(start); + return SERVE_SOURCE.slice(start, end); +}; + +describe('serve — the i18n catch prints on the right channel and still tolerates (#14118)', () => { + it('site passes the package it actually loads, through the formatter', () => { + expect(i18nCatchWindow()).toContain('console.warn(formatI18nLoadDiagnostic(i18nPkg, i18nErr));'); + }); + + it('nothing in that catch reaches stdout', () => { + const window = i18nCatchWindow(); + expect(window).not.toContain('console.log'); + expect(window).not.toContain('process.stdout'); + }); + + it('⛔ the catch does not re-throw — the tolerance IS the point of it', () => { + // Comment prose in the window legitimately contains the word, so this reads + // statements: a `throw` keyword at the head of one. + const statements = i18nCatchWindow() + .split('\n') + .map((l) => l.trim()) + .filter((l) => !l.startsWith('//') && !l.startsWith('*')); + expect(statements.some((l) => l.startsWith('throw '))).toBe(false); + }); + + it('`console.warn` lands on stderr, and puts nothing on stdout', () => { + const out: string[] = []; + const err: string[] = []; + const realOut = process.stdout.write; + const realErr = process.stderr.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + err.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + console.warn(formatI18nLoadDiagnostic(PKG, new Error('channel probe'))); + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } + expect(err.join('')).toContain('[i18n]'); + expect(out.join('')).toBe(''); + }); +}); + +describe('#14118 CONTROL — these pins can say no', () => { + it('a rendering that dropped the kind token fails the first-line comparison', async () => { + const err = await classifiedFailure('declared-unresolvable'); + const doctored = formatI18nLoadDiagnostic(FIXTURE_PKG, err).replace(' (declared-unresolvable)', ''); + expect(firstLine(doctored)).not.toBe( + `[i18n] ${FIXTURE_PKG} was requested but could not be loaded (declared-unresolvable).`, + ); + }); + + it('the identical-framing pin rejects a per-kind sentence added to one branch', async () => { + const framings: string[] = []; + for (const kind of KINDS) { + const err = await classifiedFailure(kind); + const rendered = formatI18nLoadDiagnostic(FIXTURE_PKG, err); + // Exactly the edit the pin exists to catch: one branch grows its own + // remedy line. If the pin could not see it, #14270's shape could land here. + const withLocalRemedy = + kind === 'declared-no-loadable-entry' ? `${rendered}\n ask the package to publish an entry` : rendered; + framings.push(framingOf(withLocalRemedy, kind, err)); + } + expect(new Set(framings).size).toBe(2); + }); + + it('the source window can say no — a literal absent from the block is reported absent', () => { + expect(i18nCatchWindow()).not.toContain('console.warn(formatClusterLoadDiagnostic('); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 98dc506c16..f618a92a82 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2878,15 +2878,19 @@ export default class Serve extends Command { || anyAppPluginHasTranslations ); if (!hasI18nPlugin && configHasTranslations && tierEnabled('i18n')) { + // Dynamic import with variable to prevent tsc from resolving the optional package. + // Host-anchored: `packages/cli` does NOT declare @objectstack/service-i18n, + // so a bare import here resolves against the CLI's own realpath and can + // only ever find the package by workspace hoisting — the same defect + // class that cost cloud#1013 and #10645 (#10769). An app that does not + // declare it still falls back to the CLI's resolution, so the quiet-skip + // path below is unchanged. + // + // Declared OUTSIDE the `try` so the catch can NAME the package it is + // reporting on: a diagnosis that cannot say which package it is about + // is the thing this site was fixed to stop printing. + const i18nPkg = '@objectstack/service-i18n'; try { - // Dynamic import with variable to prevent tsc from resolving the optional package. - // Host-anchored: `packages/cli` does NOT declare @objectstack/service-i18n, - // so a bare import here resolves against the CLI's own realpath and can - // only ever find the package by workspace hoisting — the same defect - // class that cost cloud#1013 and #10645 (#10769). An app that does not - // declare it still falls back to the CLI's resolution, so the quiet-skip - // path below is unchanged. - const i18nPkg = '@objectstack/service-i18n'; const { I18nServicePlugin } = await importFromHost(i18nPkg); const i18nCfg = config.i18n || config.manifest?.i18n || {}; await kernel.use(new I18nServicePlugin({ @@ -2894,8 +2898,17 @@ export default class Serve extends Command { fallbackLocale: i18nCfg.fallbackLocale || i18nCfg.defaultLocale || 'en', })); trackPlugin('I18nService'); - } catch { - // @objectstack/service-i18n not installed — kernel memory fallback will handle i18n + } catch (i18nErr) { + // NOT a re-throw and never becomes one: a missing + // @objectstack/service-i18n is a supported configuration (the kernel + // pre-injects `createMemoryI18n` for the unprovided `i18n` core + // service), so the tolerance stays and only the SILENCE goes. This + // catch used to discard the classification `createHostImporter` + // produced, which made "you did not install the optional package" + // and "the package you DO declare is broken" the same event — the + // #13463 class, repaired at the cluster-driver load two blocks up by + // PR #14042 and left standing here. + console.warn(formatI18nLoadDiagnostic(i18nPkg, i18nErr)); } } else if (!hasI18nPlugin && !configHasTranslations) { // No translations and no explicit i18n plugin — this is fine, kernel fallback works @@ -5015,6 +5028,71 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict { } +/** + * The diagnosis the optional i18n service load prints when it fails — a + * DIAGNOSIS ahead of behaviour that does not change (#14042's rule, applied at + * the one site its repair did not reach). + * + * The catch this feeds used to be bare. That was defensible on its stated + * ground — a missing `@objectstack/service-i18n` is a supported configuration, + * not a failure — and wrong about what it discarded: `createHostImporter` + * classifies WHY a host import failed, and an empty catch throws that + * classification away along with the absence it was written for. The two are + * not the same event. A package the app DECLARES and that then fails to load + * (a pruned install, a dist never built, a publish with no loadable entry) + * reaches this catch as silence indistinguishable from "you never installed the + * optional thing" — which is how a broken install reads as a deliberate opt-out. + * + * ⛔ It does NOT re-throw, and must not start to: the tolerance IS the point of + * the catch. `i18n` is a `core` service with a kernel fallback + * (`CORE_FALLBACK_FACTORIES` in `@objectstack/core` pre-injects + * `createMemoryI18n` for every unprovided core service), it has no + * `Serve.CAPABILITY_PROVIDERS` entry, and `requires: ['i18n']` opens the tier + * without ever reaching the fail-fast branch that makes a missing provider a + * hard boot error. So this is a FUNCTIONAL degradation in the AGENTS.md sense — + * the deployment is visibly smaller and nothing that claims to persist is lost + * — which is why it is `warn` and not `error`, and why the consequence is + * stated before the diagnosis rather than after it. + * + * ## The kind wording is the importer's, deliberately + * + * Only the kind TOKEN is interpolated here; every word of remedy comes from + * `err.message`, which `createHostImporter` composes per kind + * (`undeclaredMessage` / `unresolvableMessage` / `noLoadableEntryMessage`). + * That is what makes this site correct for all three kinds — and for the fourth + * — without a branch table to go stale: #14270 exists because three OTHER + * consumers picked their remedy with a two-way branch written when only two + * kinds existed, 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 join that population; deferring cannot. + * + * An error carrying NO kind resolved and then crashed while evaluating, which + * is a different fact and gets a different sentence — swallowing that one is + * how a package with a broken dependency reads as a package that was never + * installed. + * + * Returns PLAIN text; the caller owns the channel (`console.warn` ⇒ stderr, + * which `serve-stdio-stdout-purity.e2e.test.ts` requires of everything that is + * not an MCP protocol frame). + */ +export function formatI18nLoadDiagnostic(pkg: string, err: unknown): string { + const kind = hostImportFailureKind(err); + // The stack is carried ONLY for the crash branch, where the throwing frame is + // the diagnosis. A classified failure's `message` is already the whole + // operator-facing text and its stack points at the importer, not at the cause. + const detail = err instanceof Error + ? (kind === undefined ? (err.stack ?? err.message) : err.message) + : String(err); + const consequence = + ' Unchanged: this boot serves i18n from the kernel in-memory fallback, so what follows\n' + + ' is why the file-based service is absent — not a boot failure.\n'; + return kind !== undefined + ? `[i18n] ${pkg} was requested but could not be loaded (${kind}).\n${consequence}${detail}` + : `[i18n] ${pkg} resolved but threw while loading — this is the package's own failure, ` + + `not a missing package.\n${consequence}${detail}`; +} + + /** * The operator-facing prose that names the multi-org runtime, as SEAMS rather * than string expressions buried inside `run()` — the same reason