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 =>
128138const 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. */
132160const optionBProject = ( ) : Record < string , unknown > => {
133161 const composed = additiveProject ( ) ;
@@ -138,12 +166,13 @@ const optionBProject = (): Record<string, unknown> => {
138166const 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
161190describe ( '#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