Skip to content

Commit c108823

Browse files
committed
fix(plugin-dev): degrade on a refused package list, ask the cheap limbs first, document what throws (#15232)
Contract review (#15282) measured three things wrong with the first cut, and this is all three: 1. REACHABILITY. "Today's artifacts never reach the packages[] pass" was FALSE. The flattened read short-circuits only when `translations` is non-empty, so every multi-package stack that does not translate reaches the gate on every boot. The claim is corrected wherever it appeared and pinned by a case that counts the reads (2 when the gate is reached, 0 when it short-circuits). 2. A REPRODUCED REGRESSION. A package manifest still carrying authoring glob `objects` is refused by `ArtifactPackageSchema` by design; such a project boots today and would have stopped booting on this reader — thrown from the block whose only job is deciding whether to register a translation service, while `new AppPlugin(...)` twenty lines above degrades the very same refusal to a log line. That inversion is indefensible, so `DevPlugin` now catches, prints its own line naming the METADATA defect and carrying the envelope verbatim, and boots on the in-memory fallback. ⛔ Not `reportOptionalLoadFailure` (it names a PACKAGE — the #7926 mis-attribution) and ⛔ never silent. `dev-plugin.ts`'s AppPlugin try/catch is untouched: whether DevPlugin should refuse malformed metadata at all is a separate maintainer question. 3. EVALUATION ORDER. The three limbs are now asked cheapest-first, so a stack that already declares its locales in `i18n` is no longer refused over a `packages` list its answer never needed. `||` is commutative — the answer is unchanged, only what can throw is. Both functions gain `@throws`, including the BARE `Error` (no code, no status) `resolvePluginOrder` raises for a dependency cycle, now asserted by a case. The guard divergence with the sibling reader (`Array.isArray` vs absent-key) is recorded as a program-level split rather than unilaterally aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent c60f2b4 commit c108823

3 files changed

Lines changed: 267 additions & 18 deletions

File tree

packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@
2323
// - the option-B artifact, whose ONLY copy is under `packages[]`, is now
2424
// detected — the row this card added to the #15004 ledger and then deleted.
2525
//
26+
// Two more, added after an adversarial contract review measured the first draft
27+
// of this file claiming more than it pinned:
28+
//
29+
// - a composed multi-package stack with NO i18n anywhere DOES reach
30+
// `resolveArtifactPackageOrder` (counted, not argued). The short-circuit is
31+
// real only for a stack whose flattened `translations` is non-empty.
32+
// - therefore the gate's refusals are reachable on the ORDINARY path, so a
33+
// project the gate refuses — one whose package manifest still carries
34+
// authoring globs — must keep booting. It does, loudly.
35+
//
2636
// The last case boots the real `DevPlugin` rather than only calling the
2737
// decision, because what a developer experiences is the SERVICE: the plugin has
2838
// to reach `new I18nServicePlugin(...)` with the locales the detection derived.
@@ -128,6 +138,24 @@ const modulePackage = (): ObjectStackDefinition =>
128138
const additiveProject = (): Record<string, unknown> =>
129139
composeStacks([modulePackage(), corePackage()], { manifest: 'preserve' }) as unknown as Record<string, unknown>;
130140

141+
/**
142+
* The SAME composition with no i18n anywhere — no `translations` at any level,
143+
* no `i18n` config, no `manifest.translations`. This is the ordinary
144+
* multi-package app that simply does not translate, and it is the shape the
145+
* reachability claim turns on.
146+
*/
147+
const additiveNoI18nProject = (): Record<string, unknown> => {
148+
const composed = composeStacks(
149+
[modulePackage(), { ...corePackage(), translations: undefined } as ObjectStackDefinition],
150+
{ manifest: 'preserve' },
151+
) as unknown as Record<string, unknown>;
152+
delete composed.translations;
153+
for (const entry of composed.packages as Array<{ manifest?: Record<string, unknown> }>) {
154+
delete entry.manifest?.translations;
155+
}
156+
return composed;
157+
};
158+
131159
/** The ruled option-B shape, for the one collection this reader reads. */
132160
const optionBProject = (): Record<string, unknown> => {
133161
const composed = additiveProject();
@@ -138,12 +166,13 @@ const optionBProject = (): Record<string, unknown> => {
138166
const mockCtx = () => {
139167
const registered = new Map<string, unknown>();
140168
const info: string[] = [];
169+
const errors: string[] = [];
141170
const ctx = {
142171
logger: {
143172
info: (line: unknown) => { if (typeof line === 'string') info.push(line); },
144173
debug: () => undefined,
145174
warn: () => undefined,
146-
error: () => undefined,
175+
error: (line: unknown) => { if (typeof line === 'string') errors.push(line); },
147176
},
148177
getService: (name: string) => {
149178
if (registered.has(name)) return registered.get(name);
@@ -155,7 +184,7 @@ const mockCtx = () => {
155184
trigger: () => undefined,
156185
getKernel: () => undefined,
157186
};
158-
return { ctx, info };
187+
return { ctx, info, errors };
159188
};
160189

161190
describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', () => {
@@ -179,10 +208,11 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
179208
expect(devI18nPluginOptions(optionBProject())).toEqual({ defaultLocale: undefined, fallbackLocale: 'en' });
180209
});
181210

182-
it('the flattened level answers FIRST — `packages[]` is not even traversed', () => {
183-
// Two things at once, and the malformed `packages` is what proves the
184-
// first: the original expression short-circuits, so today's additive
185-
// artifact cannot start refusing anything it accepted before.
211+
it('the flattened level answers FIRST **when it has something to say** — `packages[]` is not traversed then', () => {
212+
// ⚠️ Scope, stated because an earlier draft of this file read this case as
213+
// proof of something wider: it pins the short-circuit for a stack whose top
214+
// level ALREADY declares translations. It says nothing about a stack that
215+
// declares none — that case is the one below, and it reaches the gate.
186216
let reads = 0;
187217
const stack = {
188218
manifest: { id: CORE_ID, name: 'x', version: '1.0.0', type: 'app' },
@@ -206,6 +236,69 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
206236
expect(reads).toBe(1);
207237
});
208238

239+
it('a composed multi-package stack with NO i18n DOES reach the artifact gate — and answers undefined without throwing', () => {
240+
// The measurement that falsified this PR's first draft ("for every artifact
241+
// the platform produces today the packages[] pass is not even reached").
242+
// It is reached, on the ordinary path, for every multi-package app that
243+
// does not translate — so the walk is real work and its refusals are
244+
// reachable in ordinary use, which is why `DevPlugin` degrades on them.
245+
//
246+
// `packages` is read TWICE when the gate is reached and ZERO times when the
247+
// flattened level short-circuits: once by this reader's own absent-key
248+
// guard, once inside `resolveArtifactPackageOrder`. That second read is the
249+
// discriminator, so the assertion is on it and not on "at least one".
250+
let packagesReads = 0;
251+
const project = additiveNoI18nProject();
252+
const counted = new Proxy(project, {
253+
get(target, key, recv) {
254+
if (key === 'packages') packagesReads += 1;
255+
return Reflect.get(target, key, recv);
256+
},
257+
});
258+
259+
expect(project.translations).toBeUndefined();
260+
expect((project.packages as unknown[]).length).toBe(2);
261+
expect(() => devI18nPluginOptions(counted)).not.toThrow();
262+
expect(devI18nPluginOptions(counted)).toBeUndefined();
263+
expect(packagesReads).toBeGreaterThanOrEqual(2);
264+
});
265+
266+
it('a stack that already declares its locales is answered WITHOUT walking `packages[]`', () => {
267+
// The limbs are asked cheapest-first: an `i18n` config answers the question
268+
// on its own, so a `packages` list that answer never needed cannot refuse
269+
// it. Before the reorder this threw INVALID_ARTIFACT_PACKAGES.
270+
const stack = {
271+
...additiveNoI18nProject(),
272+
i18n: { defaultLocale: 'zh-CN' },
273+
packages: 'not an array — would be refused if this limb were reached',
274+
};
275+
expect(devI18nPluginOptions(stack)).toEqual({ defaultLocale: 'zh-CN', fallbackLocale: 'zh-CN' });
276+
});
277+
278+
it('a dependency CYCLE between two packages throws a BARE Error — no `code`, no `status`', () => {
279+
// Documented under @throws because it is the one refusal here that does not
280+
// carry the ADR-0112 envelope: it comes from `resolvePluginOrder`, the
281+
// platform's one topological sorter, not from the artifact gate. A caller
282+
// matching on `code` alone would miss it — `DevPlugin`'s catch does not.
283+
const cyclic = {
284+
manifest: { id: 'a', name: 'A', version: '1.0.0', type: 'app' },
285+
packages: [
286+
{ manifest: { id: 'a', name: 'A', version: '1.0.0', type: 'app', dependencies: { b: '^1.0.0' } } },
287+
{ manifest: { id: 'b', name: 'B', version: '1.0.0', type: 'module', dependencies: { a: '^1.0.0' } } },
288+
],
289+
};
290+
let caught: (Error & { code?: unknown; status?: unknown }) | undefined;
291+
try {
292+
devI18nPluginOptions(cyclic);
293+
} catch (err) {
294+
caught = err as Error & { code?: unknown; status?: unknown };
295+
}
296+
expect(caught).toBeInstanceOf(Error);
297+
expect(caught?.message).toContain('Circular dependency detected');
298+
expect(caught?.code).toBeUndefined();
299+
expect(caught?.status).toBeUndefined();
300+
});
301+
209302
it('an EMPTY top-level `translations` is not an answer — `packages[]` supplies it', () => {
210303
// `[]` is falsy for the original expression (`length > 0`), so this is the
211304
// case where `packages[]` legitimately supplies what the top level lacks.
@@ -244,7 +337,7 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
244337

245338
const bootWith = async (stack: Record<string, unknown> | undefined) => {
246339
i18nConstructions.length = 0;
247-
const { ctx, info } = mockCtx();
340+
const { ctx, info, errors } = mockCtx();
248341
await new DevPlugin({
249342
seedAdminUser: false,
250343
stack,
@@ -254,7 +347,7 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
254347
'file-storage': false, realtime: false,
255348
},
256349
}).init(ctx as never);
257-
return { constructions: [...i18nConstructions], info };
350+
return { constructions: [...i18nConstructions], info, errors };
258351
};
259352

260353
it('BOOT — a multi-package app under option B gets the file-based I18nServicePlugin', async () => {
@@ -263,6 +356,39 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
263356
expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(true);
264357
});
265358

359+
it('BOOT — a project the ADR-0130 D4 gate REFUSES still boots, loudly, on the fallback', async () => {
360+
// The regression this posture exists to prevent, reproduced: one package's
361+
// authoring manifest still declares glob `objects` (`ManifestSchema`'s
362+
// written form, and the repo's own CONFIG_GLOBS fixture in
363+
// packages/cli/test/build-multi-package-artifact.e2e.test.ts). Such a body
364+
// is refused by `ArtifactPackageSchema` BY DESIGN
365+
// (packages/spec/src/assembled-package-body.test.ts). That project boots
366+
// today; a reader that threw here would have stopped it booting — and from
367+
// the block whose only job is deciding whether to register a translation
368+
// service, while `new AppPlugin(...)` twenty lines above degrades the very
369+
// same refusal to a log line.
370+
const refused = additiveNoI18nProject();
371+
(refused.packages as Array<{ manifest: Record<string, unknown> }>)[0]
372+
.manifest.objects = ['./src/objects/*.object.ts'];
373+
374+
// The reader itself still refuses — the gate travels with the read.
375+
expect(() => devI18nPluginOptions(refused)).toThrow();
376+
377+
// The PLUGIN does not. It boots, says exactly what is wrong, and registers
378+
// nothing.
379+
const { constructions, info, errors } = await bootWith(refused);
380+
expect(constructions).toEqual([]);
381+
expect(info.some((l) => l.includes('I18nServicePlugin auto-registered'))).toBe(false);
382+
const line = errors.find((l) => l.includes('i18n auto-detect could not read'));
383+
expect(line, `no diagnosis line; errors were:\n${errors.join('\n')}`).toBeDefined();
384+
// ⛔ Never silent, and never mis-attributed to a missing package (#7926):
385+
// the line names the metadata defect and carries the refusal verbatim.
386+
expect(line).toContain('PACKAGE LIST is malformed');
387+
expect(line).toContain('INVALID_ARTIFACT_PACKAGE_ENTRY');
388+
expect(line).toContain('packages[0]');
389+
expect(line).not.toContain('not installed');
390+
});
391+
266392
it('BOOT — a stack declaring no copy at all still gets no I18nServicePlugin', async () => {
267393
// The negative control. Without it the assertion above would pass for a
268394
// detection that fires unconditionally.

0 commit comments

Comments
 (0)