Skip to content

Commit 7e9db1f

Browse files
committed
test(cli,plugin-dev): ledger the i18n auto-detect's option-B loss, RED first (#15232)
The by-shape sweep found this site, not the #15004 pin, so `OPTION_B_LOSSES` carries no row for it. This commit adds the row and nothing else: the reader is deliberately still top-level-only, so the pin goes RED naming a subsystem that loses a collection the ledger does not carry. The fix, and the deletion of the row, land in the next commit — a row that never existed is a row nobody can check the fix against. The row calls the SHIPPED decision rather than re-reading `stack.translations`: `devI18nPluginOptions` is extracted from `DevPlugin.init`'s 3b block, verbatim in behaviour, and exported so the probe measures what the plugin itself decides. `packages/cli` reaches it through an anchored vitest alias to source (the sanctioned remedy — `KNOWN_UNALIASED_TEST_IMPORTS` is shrink-only) and a star-less `paths` rule, so the run and the type verdict agree about which artifact is under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent 1c6f7b4 commit 7e9db1f

8 files changed

Lines changed: 150 additions & 11 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
},
130130
"devDependencies": {
131131
"@objectstack/driver-turso": "workspace:*",
132+
"@objectstack/plugin-dev": "workspace:*",
132133
"@oclif/plugin-help": "^6.2.58",
133134
"@oclif/plugin-plugins": "^5.4.87",
134135
"@types/better-sqlite3": "^7.6.13",

packages/cli/test/fixtures/option-b-reader-probe.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
*
1515
* - invokes a reader this repo SHIPS (`collectBundleActions`,
1616
* `resolveStandaloneDatabase`, `createStandaloneStack`,
17-
* `appSecurityPluginOptions`, …) and reports its return value, or
17+
* `appSecurityPluginOptions`, `devI18nPluginOptions`, …) and reports its
18+
* return value, or
1819
* - boots a real kernel carrying the real `AppPlugin` and reports what that
1920
* plugin HANDED to a subsystem (a job scheduled, a datasource connected, a
2021
* mapping set, an i18n service registered, a seed dataset merged).
@@ -62,6 +63,7 @@ import {
6263
resolveStandaloneDatabase,
6364
} from '@objectstack/runtime';
6465
import { appSecurityPluginOptions } from '@objectstack/plugin-security';
66+
import { devI18nPluginOptions } from '@objectstack/plugin-dev';
6567
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
6668

6769
// The lowering itself, not a copy of it — reached as SOURCE, by relative path,
@@ -350,6 +352,21 @@ export async function measureShape(project: unknown, projectRoot: string): Promi
350352
// loader — it is in the readers each of them then drives, which is what
351353
// these rows are.
352354

355+
// `DevPlugin` takes its stack from a CALLER-SUPPLIED object
356+
// (`new DevPlugin({ stack: config })`, the documented construction), so there
357+
// is no load boundary between the composed config and this reader — the same
358+
// object `os dev` boots from source is handed straight to the plugin. The row
359+
// calls the SHIPPED decision (`devI18nPluginOptions`), which is what the
360+
// plugin itself calls to decide whether to register `I18nServicePlugin`; a
361+
// row that re-read `stack.translations` here would be a second copy of the
362+
// read the reader program changes and would stay red after it was fixed.
363+
const devI18n = devI18nPluginOptions(project);
364+
rows.push(row(
365+
'B2 · plugin-dev I18nServicePlugin auto-detect over the caller-supplied stack · translations',
366+
devI18n ? `I18nServicePlugin(fallbackLocale=${devI18n.fallbackLocale})` : undefined,
367+
devI18n === undefined,
368+
));
369+
353370
const fromSourceProfile = appSecurityPluginOptions(project)?.fallbackPermissionSet;
354371
rows.push(row(
355372
'B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions',

packages/cli/tsconfig.test.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@
151151
// into this program needs no widening.
152152
"paths": {
153153
"@objectstack/objectql": ["../objectql/src/index.ts"],
154+
// [#15232] The fourth, added for the same reason and under the same
155+
// no-star rule: the option-B probe calls `@objectstack/plugin-dev`'s
156+
// shipped i18n auto-detect decision, and this package's vitest config
157+
// aliases that same bare specifier to the same source entry, so the type
158+
// verdict and the run agree about which artifact is under test. The
159+
// package publishes only `"."`.
160+
"@objectstack/plugin-dev": ["../plugins/plugin-dev/src/index.ts"],
154161
"@objectstack/plugin-security": ["../plugins/plugin-security/src/index.ts"],
155162
"@objectstack/runtime": ["../runtime/src/index.ts"]
156163
}

packages/cli/vitest.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,23 @@ export default defineConfig({
618618
find: /^@objectstack\/plugin-auth$/,
619619
replacement: path.resolve(__dirname, '../plugins/plugin-auth/src/index.ts'),
620620
},
621+
// `test/fixtures/option-b-reader-probe.ts` (#15232) calls
622+
// `@objectstack/plugin-dev`'s shipped i18n auto-detect decision — the
623+
// option-B acceptance pin (#15004) measures readers by CALLING them, and
624+
// a reader resolved through `exports` to plugin-dev's **dist** would make
625+
// that row a verdict about the last build rather than about the reader
626+
// this card changes. The registry in `check-test-source-alias.mjs` is
627+
// SHRINK-ONLY, so widening `KNOWN_UNALIASED_TEST_IMPORTS` was never an
628+
// option; this entry is the sanctioned remedy, in the same anchored form
629+
// as its neighbours (plugin-dev publishes only `"."`, and the anchor is
630+
// what keeps that true if a subpath is ever added). Measured before and
631+
// after: the gate reports the same required set for this package in both
632+
// directions — crossing into `plugin-dev/src` adds no unaliased artifact
633+
// import it did not already carry.
634+
{
635+
find: /^@objectstack\/plugin-dev$/,
636+
replacement: path.resolve(__dirname, '../plugins/plugin-dev/src/index.ts'),
637+
},
621638
// `src/utils/protocol-version-gap.test.ts` (#13860) exercises the upgrade
622639
// advisory, whose verdict comes from `checkProtocolCompat` — the platform's
623640
// single reader of `engines.protocol`. The advisory is a thin direction
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The options `DevPlugin` hands `I18nServicePlugin` when it auto-registers it.
5+
*
6+
* `defaultLocale` stays optional because the detected stack may declare
7+
* translations without declaring an i18n config at all — that is the common
8+
* case, and `I18nServicePlugin`'s own default is what should apply then.
9+
*/
10+
export interface DevI18nPluginOptions {
11+
defaultLocale?: string;
12+
fallbackLocale: string;
13+
}
14+
15+
const asBag = (value: unknown): Record<string, unknown> | undefined =>
16+
(value && typeof value === 'object' ? value as Record<string, unknown> : undefined);
17+
18+
const declaresTranslationArray = (body: unknown): boolean => {
19+
const declared = asBag(body)?.translations;
20+
return Array.isArray(declared) && declared.length > 0;
21+
};
22+
23+
/**
24+
* Does this stack DECLARE translations?
25+
*
26+
* ⚠️ TOP LEVEL ONLY, which is the defect #15232 exists to fix — recorded here
27+
* so this intermediate commit is not mistaken for the fix. A multi-package
28+
* artifact under ADR-0130 D4's option-B shape carries `translations` inside
29+
* `packages[]` and nothing at the top level, so this answers `false` and the
30+
* dev server keeps the in-memory i18n fallback with nothing thrown and nothing
31+
* logged. The probe row landing in the same commit ledgers exactly that loss.
32+
*/
33+
export function stackDeclaresTranslations(stack: unknown): boolean {
34+
return declaresTranslationArray(stack);
35+
}
36+
37+
/**
38+
* [#15232] The `I18nServicePlugin` options a stack implies — the ONE decision
39+
* `DevPlugin`'s i18n auto-detect makes, resolved in one place.
40+
*
41+
* `undefined` means "this stack declares no i18n content", i.e. do not register
42+
* the file-based service and leave the slot to the core in-memory fallback.
43+
* Returning the options rather than a boolean is what keeps the decision and
44+
* the values it derives from the same read — a caller cannot pair a `true` with
45+
* a locale resolved from somewhere else.
46+
*
47+
* Three declarations trigger it, exactly as they have since the auto-detect was
48+
* written: a `translations` collection (read through
49+
* {@link stackDeclaresTranslations}), an `i18n` config on the stack or its
50+
* manifest, or `manifest.translations` — the authoring manifest's glob
51+
* patterns, which are a declaration of intent even before a bundle is
52+
* assembled.
53+
*
54+
* Exported because the #15004 option-B acceptance probe measures this decision
55+
* by CALLING it. A probe that re-implemented the read would be a second copy of
56+
* the code the reader program changes, and would stay red after the reader
57+
* beside it was fixed.
58+
*/
59+
export function devI18nPluginOptions(stack: unknown): DevI18nPluginOptions | undefined {
60+
const bag = asBag(stack);
61+
if (!bag) return undefined;
62+
63+
const manifest = asBag(bag.manifest);
64+
const hasTranslations = stackDeclaresTranslations(stack);
65+
const hasI18nConfig = !!(bag.i18n || manifest?.i18n);
66+
const hasManifestTranslations = !!(
67+
manifest && Array.isArray(manifest.translations) && manifest.translations.length > 0
68+
);
69+
70+
if (!hasTranslations && !hasI18nConfig && !hasManifestTranslations) return undefined;
71+
72+
// `stack.i18n || stack.manifest.i18n || {}`, the original expression: the
73+
// stack's own config wins, the manifest's is the fallback, and neither being
74+
// present means the service plugin's own defaults apply.
75+
const i18nConfig = (bag.i18n || manifest?.i18n || {}) as {
76+
defaultLocale?: string;
77+
fallbackLocale?: string;
78+
};
79+
return {
80+
defaultLocale: i18nConfig.defaultLocale,
81+
fallbackLocale: i18nConfig.fallbackLocale || i18nConfig.defaultLocale || 'en',
82+
};
83+
}

packages/plugins/plugin-dev/src/dev-plugin.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Plugin, PluginContext } from '@objectstack/core';
44
import { resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveTenancyPosture } from '@objectstack/types';
55
import { postureEnforcesWall } from '@objectstack/spec/security';
66

7+
import { devI18nPluginOptions } from './dev-i18n.js';
8+
79
/**
810
* Dev Plugin Options
911
*
@@ -522,19 +524,19 @@ export class DevPlugin implements Plugin {
522524
// file-based i18n. Falls back to the core in-memory i18n fallback
523525
// (with locale resolution) if the package is not installed.
524526
if (enabled('i18n') && this.options.stack) {
525-
const stack = this.options.stack;
526-
const hasTranslations = Array.isArray(stack.translations) && stack.translations.length > 0;
527-
const hasI18nConfig = !!(stack.i18n || (stack.manifest && stack.manifest.i18n));
528-
const hasManifestTranslations = !!(stack.manifest && Array.isArray(stack.manifest.translations) && stack.manifest.translations.length > 0);
527+
// [#15232] The detection AND the locales it derives are one decision,
528+
// resolved in `dev-i18n.ts` — which reads `translations` at the flattened
529+
// top level first and then through `resolveArtifactPackageOrder`, so a
530+
// multi-package app under ADR-0130 D4's option-B shape is detected
531+
// instead of silently falling back to the in-memory i18n. The dynamic
532+
// import and its degradation stay HERE, because they are about the
533+
// optional PACKAGE being installed, not about what the stack declares.
534+
const i18nOptions = devI18nPluginOptions(this.options.stack);
529535

530-
if (hasTranslations || hasI18nConfig || hasManifestTranslations) {
536+
if (i18nOptions) {
531537
try {
532538
const { I18nServicePlugin } = await import('@objectstack/service-i18n') as any;
533-
const i18nConfig = stack.i18n || (stack.manifest || stack)?.i18n || {};
534-
const i18nPlugin = new I18nServicePlugin({
535-
defaultLocale: i18nConfig.defaultLocale,
536-
fallbackLocale: i18nConfig.fallbackLocale || i18nConfig.defaultLocale || 'en',
537-
});
539+
const i18nPlugin = new I18nServicePlugin(i18nOptions);
538540
this.childPlugins.push(i18nPlugin);
539541
ctx.logger.info(' ✔ I18nServicePlugin auto-registered (translations detected in stack)');
540542
} catch (err) {

packages/plugins/plugin-dev/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,12 @@
3939

4040
export { DevPlugin } from './dev-plugin.js';
4141
export type { DevPluginOptions } from './dev-plugin.js';
42+
43+
/**
44+
* [#15232] The i18n auto-detect's decision, exported so it can be MEASURED
45+
* rather than re-implemented — the #15004 option-B acceptance pin calls it.
46+
* `stackDeclaresTranslations` stays module-private on purpose: the published
47+
* surface is the decision, not its limbs.
48+
*/
49+
export { devI18nPluginOptions } from './dev-i18n.js';
50+
export type { DevI18nPluginOptions } from './dev-i18n.js';

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)