Skip to content

Commit e3aca15

Browse files
committed
fix(plugin-dev): the i18n auto-detect resolves translations from packages[] (#15232)
`DevPlugin.init`'s 3b block read `options.stack.translations` alone, so a multi-package app under ADR-0130 D4's option-B shape — every definition carried once inside `packages[]`, no flattened top level — was read as declaring no copy at all. `I18nServicePlugin` was never registered and `os dev` served message keys, or last release's strings, from the core in-memory fallback. Nothing threw and nothing logged. The detection now reads the flattened top level FIRST and consults each package body only where that came back falsy, in the order `resolveArtifactPackageOrder` (`@objectstack/core`, ADR-0130 D4+D5) registers them. Today's additive artifact answers bit-identically: the original expression `Array.isArray(t) && t.length > 0` is preserved rather than re-expressed, and it short-circuits before `packages[]` is touched. A stack with no `packages` key never reaches the traversal at all, so its `translations` is still read exactly once. A malformed `packages` raises the same ADR-0112 envelope the registration path raises for it. The ledger row added two commits ago goes green and is deleted, so `OPTION_B_LOSSES` ends this branch at the 24 rows it started with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent 64906c6 commit e3aca15

4 files changed

Lines changed: 407 additions & 18 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/plugin-dev": patch
3+
---
4+
5+
fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232)
6+
7+
`DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else.
8+
For a multi-package app under the ADR-0130 D4 option-B shape — where
9+
`packages[]` carries each definition exactly once and the flattened top-level
10+
copy is gone — that read returns `undefined`, the detection concludes "this app
11+
declared no copy", and the boot continues. Nothing throws and nothing logs.
12+
13+
What the developer gets instead is the wrong strings. `I18nServicePlugin`
14+
(`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the
15+
core in-memory fallback: `os dev` serves message KEYS, or last release's copy,
16+
for an app that declared real translations. It reads as "the translations are
17+
broken", not as "a collection went missing", which is why it is a reader fix
18+
rather than a footnote.
19+
20+
The detection now reads the flattened top level FIRST and then each package
21+
body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`,
22+
ADR-0130 D4+D5) registers them:
23+
24+
- **Every artifact the platform emits today answers bit-identically.** The
25+
flattened level still answers first and short-circuits, so the `packages[]`
26+
pass can only supply a declaration the top level did not have. This is the
27+
reader half of the ruled order (readers first, emitter last, the artifact
28+
additive throughout), so it lands with no change to what any command emits.
29+
- **The caller's original expression is preserved, not re-expressed.**
30+
`Array.isArray(t) && t.length > 0` still decides the top level, per package
31+
body as well — re-expressing a gate as a resolved-and-counted traversal is
32+
what silently changes the verdict for a stack that declares the key empty.
33+
- **`stack.packages` is not iterated directly.**
34+
`resolveArtifactPackageOrder` is the platform's one traversal and also the
35+
GATE that parses each entry, so a second traversal would disagree with the
36+
load path about which artifacts are loadable. An artifact with no `packages`
37+
key is left entirely on the old path — the key's absence is checked before
38+
the call, because D4's second branch would otherwise hand the caller's own
39+
object back and read the same `translations` twice.
40+
- **A malformed `packages` is refused, not skipped.** A non-array `packages`,
41+
an entry inlined instead of wrapped under `manifest:`, or a duplicate package
42+
id raises the same ADR-0112 envelope (`code` + `status: 422`) that
43+
`ObjectQL.registerApp` raises for the same object later in the same boot.
44+
45+
The decision — detection plus the locales it derives — is now one exported
46+
function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin
47+
measures it by CALLING it rather than re-implementing the read. `DevPlugin`
48+
keeps the dynamic import and its degradation: those are about the optional
49+
package being installed, which is a different question from what the stack
50+
declares.

packages/cli/test/option-b-reader-acceptance.pin.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,6 @@ const OPTION_B_LOSSES: readonly string[] = [
143143
'B2 · AppPlugin ql.setDatasourceMapping (object routing) (from source) · datasourceMapping',
144144
'B2 · AppPlugin seed datasets merged (from source) · data',
145145
'B2 · AppPlugin translation loading into the i18n service (from source) · translations',
146-
// [#15232] The by-shape sweep (#15210) found this site, not this pin — so it
147-
// is LEDGERED here first, red, before the reader beside it is touched. The
148-
// fix and this line's deletion land in the next commit.
149-
'B2 · plugin-dev I18nServicePlugin auto-detect over the caller-supplied stack · translations',
150146
'B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions',
151147
'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions',
152148
'B2 · runtime collectBundleFunctionEntries over the from-source config · functions',
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #15232 — the i18n auto-detect reads `translations` from `packages[]` too.
4+
//
5+
// ── The defect ─────────────────────────────────────────────────────────────
6+
//
7+
// `DevPlugin`'s 3b block decided whether to register the file-based
8+
// `I18nServicePlugin` from `stack.translations` alone. A multi-package artifact
9+
// under ADR-0130 D4's option-B shape carries each definition once, inside
10+
// `packages[]`, with no flattened top level — so the read returned `undefined`,
11+
// the detection said "this app declares no copy", and `os dev` booted on the
12+
// core in-memory i18n fallback. Nothing throws. Nothing logs. The developer
13+
// sees message keys, or last release's strings, where the app declared real
14+
// translations.
15+
//
16+
// ── What is pinned here, and in which direction ────────────────────────────
17+
//
18+
// Both directions, because only the pair is a discrimination:
19+
//
20+
// - today's ADDITIVE artifact answers exactly as it did before (the flattened
21+
// level answers FIRST and short-circuits — the caller's original expression
22+
// is preserved, not re-expressed, which is the trap #15006 measured);
23+
// - the option-B artifact, whose ONLY copy is under `packages[]`, is now
24+
// detected — the row this card added to the #15004 ledger and then deleted.
25+
//
26+
// The last case boots the real `DevPlugin` rather than only calling the
27+
// decision, because what a developer experiences is the SERVICE: the plugin has
28+
// to reach `new I18nServicePlugin(...)` with the locales the detection derived.
29+
// `@objectstack/service-i18n` is mocked to a recording double for that arm
30+
// (present and constructible), and every other optional package is mocked
31+
// ABSENT — the #3060 convention in this package's sibling tests, which keeps a
32+
// dev-assembly boot off the vite transform hot path.
33+
34+
import { describe, it, expect, vi } from 'vitest';
35+
import { composeStacks, defineStack, type ObjectStackDefinition } from '@objectstack/spec';
36+
37+
import { devI18nPluginOptions } from './dev-i18n';
38+
import { DevPlugin } from './dev-plugin';
39+
40+
const absent = (name: string): Error =>
41+
Object.assign(new Error(`Cannot find package '${name}'`), { code: 'ERR_MODULE_NOT_FOUND' });
42+
43+
/** The one package that must be PRESENT: the arm under test constructs it. */
44+
const i18nConstructions = vi.hoisted(() => [] as unknown[]);
45+
vi.mock('@objectstack/service-i18n', () => ({
46+
I18nServicePlugin: class {
47+
name = 'com.objectstack.service.i18n';
48+
type = 'service' as const;
49+
version = '1.0.0';
50+
constructor(options: unknown) { i18nConstructions.push(options); }
51+
async init(): Promise<void> { /* the recorder needs no behaviour */ }
52+
},
53+
}));
54+
55+
vi.mock('@objectstack/objectql', () => { throw absent('@objectstack/objectql'); });
56+
vi.mock('@objectstack/runtime', () => { throw absent('@objectstack/runtime'); });
57+
vi.mock('@objectstack/driver-memory', () => { throw absent('@objectstack/driver-memory'); });
58+
vi.mock('@objectstack/service-storage', () => { throw absent('@objectstack/service-storage'); });
59+
vi.mock('@objectstack/service-realtime', () => { throw absent('@objectstack/service-realtime'); });
60+
vi.mock('@objectstack/plugin-auth', () => { throw absent('@objectstack/plugin-auth'); });
61+
vi.mock('@objectstack/plugin-security', () => { throw absent('@objectstack/plugin-security'); });
62+
vi.mock('@objectstack/plugin-hono-server', () => { throw absent('@objectstack/plugin-hono-server'); });
63+
vi.mock('@objectstack/rest', () => { throw absent('@objectstack/rest'); });
64+
vi.mock('@objectstack/setup', () => { throw absent('@objectstack/setup'); });
65+
vi.mock('@objectstack/account', () => { throw absent('@objectstack/account'); });
66+
67+
// ─── The two-package fixture, in both shapes ────────────────────────────────
68+
69+
const CORE_ID = 'com.example.i18n.core';
70+
const MODULE_ID = 'com.example.i18n.orders';
71+
72+
const corePackage = (): ObjectStackDefinition =>
73+
defineStack({
74+
manifest: {
75+
id: CORE_ID,
76+
name: 'I18n Probe Core',
77+
namespace: 'i18nprobe',
78+
version: '1.0.0',
79+
type: 'app',
80+
},
81+
objects: [
82+
{
83+
name: 'i18nprobe_account',
84+
label: 'Account',
85+
pluralLabel: 'Accounts',
86+
sharingModel: 'private',
87+
fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } },
88+
},
89+
],
90+
// The whole point: the app's declared COPY lives in a package.
91+
translations: [
92+
{ en: { objects: { i18nprobe_account: { label: 'Account (translated)' } } } },
93+
],
94+
});
95+
96+
const modulePackage = (): ObjectStackDefinition =>
97+
defineStack({
98+
manifest: {
99+
id: MODULE_ID,
100+
name: 'I18n Probe Orders',
101+
namespace: 'i18nprobe',
102+
version: '1.0.0',
103+
type: 'module',
104+
dependencies: { [CORE_ID]: '^1.0.0' },
105+
},
106+
objects: [
107+
{
108+
name: 'i18nprobe_order',
109+
label: 'Order',
110+
pluralLabel: 'Orders',
111+
sharingModel: 'private',
112+
fields: { name: { name: 'name', type: 'text', label: 'Number', required: true } },
113+
},
114+
],
115+
});
116+
117+
/** Today's emitted shape: flattened top level PLUS `packages[]`. */
118+
const additiveProject = (): Record<string, unknown> =>
119+
composeStacks([modulePackage(), corePackage()], { manifest: 'preserve' }) as unknown as Record<string, unknown>;
120+
121+
/** The ruled option-B shape, for the one collection this reader reads. */
122+
const optionBProject = (): Record<string, unknown> => {
123+
const composed = additiveProject();
124+
delete composed.translations;
125+
return composed;
126+
};
127+
128+
const mockCtx = () => {
129+
const registered = new Map<string, unknown>();
130+
const info: string[] = [];
131+
const ctx = {
132+
logger: {
133+
info: (line: unknown) => { if (typeof line === 'string') info.push(line); },
134+
debug: () => undefined,
135+
warn: () => undefined,
136+
error: () => undefined,
137+
},
138+
getService: (name: string) => {
139+
if (registered.has(name)) return registered.get(name);
140+
throw new Error(`service not found: ${name}`);
141+
},
142+
getServices: () => new Map(),
143+
registerService: (name: string, svc: unknown) => { registered.set(name, svc); },
144+
hook: () => undefined,
145+
trigger: () => undefined,
146+
getKernel: () => undefined,
147+
};
148+
return { ctx, info };
149+
};
150+
151+
describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', () => {
152+
it('CONTROL — the fixture really carries the translations under `packages[]`', () => {
153+
// Anti-vacuity: every "detected" below is a READER resolving `packages[]`,
154+
// never a fixture that quietly kept a flattened copy.
155+
const optionB = optionBProject();
156+
expect(optionB.translations).toBeUndefined();
157+
const bodies = (optionB.packages as Array<{ manifest?: { id?: string; translations?: unknown[] } }>);
158+
expect(bodies.map((p) => p.manifest?.id).sort()).toEqual([CORE_ID, MODULE_ID]);
159+
expect(bodies.find((p) => p.manifest?.id === CORE_ID)?.manifest?.translations).toHaveLength(1);
160+
});
161+
162+
it("BASELINE — today's additive artifact answers exactly as it did before", () => {
163+
const additive = additiveProject();
164+
expect(Array.isArray(additive.translations)).toBe(true);
165+
expect(devI18nPluginOptions(additive)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
166+
});
167+
168+
it('THE FIX — the option-B artifact is detected through `packages[]`', () => {
169+
expect(devI18nPluginOptions(optionBProject())).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
170+
});
171+
172+
it('the flattened level answers FIRST — `packages[]` is not even traversed', () => {
173+
// Two things at once, and the malformed `packages` is what proves the
174+
// first: the original expression short-circuits, so today's additive
175+
// artifact cannot start refusing anything it accepted before.
176+
let reads = 0;
177+
const stack = {
178+
manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' },
179+
get translations() { reads += 1; return [{ en: { objects: {} } }]; },
180+
packages: 'not an array — this would be refused if it were reached',
181+
};
182+
expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
183+
expect(reads).toBe(1);
184+
});
185+
186+
it('a single-package stack reads its `translations` ONCE, and never throws', () => {
187+
// D4's second branch returns the CALLER'S OWN OBJECT as the single package
188+
// body, so an unguarded walk would read the same key a second time. The
189+
// guard is what keeps every single-package stack on exactly the old path.
190+
let reads = 0;
191+
const stack = {
192+
manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' },
193+
get translations() { reads += 1; return []; },
194+
};
195+
expect(devI18nPluginOptions(stack)).toBeUndefined();
196+
expect(reads).toBe(1);
197+
});
198+
199+
it('an EMPTY top-level `translations` is not an answer — `packages[]` supplies it', () => {
200+
// `[]` is falsy for the original expression (`length > 0`), so this is the
201+
// case where `packages[]` legitimately supplies what the top level lacks.
202+
const stack = { ...optionBProject(), translations: [] };
203+
expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
204+
});
205+
206+
it('locales still come from the stack `i18n` config, which option B does not move', () => {
207+
// `i18n` is an artifact ENVELOPE key, not a package-owned collection, so it
208+
// stays at the top level in both shapes and this limb loses nothing.
209+
const stack = { ...optionBProject(), i18n: { defaultLocale: 'zh-CN', fallbackLocale: 'en-US' } };
210+
expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: 'zh-CN', fallbackLocale: 'en-US' });
211+
});
212+
213+
it('a malformed `packages[]` is REFUSED with an ADR-0112 envelope, not skipped', () => {
214+
// The gate travels with the read: `resolveArtifactPackageOrder` is also
215+
// what refuses this artifact at registration, so swallowing it here would
216+
// resolve an i18n posture out of a package list nothing else accepts.
217+
const stack = {
218+
manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' },
219+
// An entry inlined instead of wrapped as `{ manifest: { … } }`.
220+
packages: [{ id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' }],
221+
};
222+
let caught: (Error & { code?: string; status?: number }) | undefined;
223+
try {
224+
devI18nPluginOptions(stack);
225+
} catch (err) {
226+
caught = err as Error & { code?: string; status?: number };
227+
}
228+
expect(caught?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY');
229+
expect(caught?.status).toBe(422);
230+
expect(caught?.message).toContain('packages[0]');
231+
});
232+
233+
// ── What the developer actually gets: the SERVICE ─────────────────────────
234+
235+
const bootWith = async (stack: Record<string, unknown> | undefined) => {
236+
i18nConstructions.length = 0;
237+
const { ctx, info } = mockCtx();
238+
await new DevPlugin({
239+
seedAdminUser: false,
240+
stack,
241+
services: {
242+
objectql: false, driver: false, auth: false, setup: false, server: false,
243+
rest: false, dispatcher: false, security: false, storage: false,
244+
'file-storage': false, realtime: false,
245+
},
246+
}).init(ctx as never);
247+
return { constructions: [...i18nConstructions], info };
248+
};
249+
250+
it('BOOT — a multi-package app under option B gets the file-based I18nServicePlugin', async () => {
251+
const { constructions, info } = await bootWith(optionBProject());
252+
expect(constructions).toEqual([{ defaultLocale: undefined, fallbackLocale: 'en' }]);
253+
expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(true);
254+
});
255+
256+
it('BOOT — a stack declaring no copy at all still gets no I18nServicePlugin', async () => {
257+
// The negative control. Without it the assertion above would pass for a
258+
// detection that fires unconditionally.
259+
const bare = { manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' } };
260+
const { constructions, info } = await bootWith(bare);
261+
expect(constructions).toEqual([]);
262+
expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(false);
263+
});
264+
});

0 commit comments

Comments
 (0)