Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/plugin-dev-i18n-detect-packages-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/plugin-dev": patch
---

fix(plugin-dev): the i18n auto-detect resolves `translations` from `packages[]`, not only the flattened top level (#15232)

`DevPlugin.init`'s 3b block read `options.stack.translations` and nothing else.
For a multi-package app under the ADR-0130 D4 option-B shape — where
`packages[]` carries each definition exactly once and the flattened top-level
copy is gone — that read returns `undefined`, the detection concludes "this app
declared no copy", and the boot continues. Nothing throws and nothing logs.

What the developer gets instead is the wrong strings. `I18nServicePlugin`
(`@objectstack/service-i18n`) is never registered, so the `i18n` slot keeps the
core in-memory fallback: `os dev` serves message KEYS, or last release's copy,
for an app that declared real translations. It reads as "the translations are
broken", not as "a collection went missing", which is why it is a reader fix
rather than a footnote.

The detection now reads the flattened top level FIRST and then each package
body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`,
ADR-0130 D4+D5) registers them:

- **Every artifact the platform emits today answers bit-identically.** The
flattened level still answers first and short-circuits, so the `packages[]`
pass can only supply a declaration the top level did not have. This is the
reader half of the ruled order (readers first, emitter last, the artifact
additive throughout), so it lands with no change to what any command emits.
- **The caller's original expression is preserved, not re-expressed.**
`Array.isArray(t) && t.length > 0` still decides the top level, per package
body as well — re-expressing a gate as a resolved-and-counted traversal is
what silently changes the verdict for a stack that declares the key empty.
- **⛔ `stack.packages` is not iterated directly.**
`resolveArtifactPackageOrder` is the platform's one traversal and also the
GATE that parses each entry, so a second traversal would disagree with the
load path about which artifacts are loadable. An artifact with no `packages`
key is left entirely on the old path — the key's absence is checked before
the call, because D4's second branch would otherwise hand the caller's own
object back and read the same `translations` twice.
- **A malformed `packages` is refused, not skipped.** A non-array `packages`,
an entry inlined instead of wrapped under `manifest:`, or a duplicate package
id raises the same ADR-0112 envelope (`code` + `status: 422`) that
`ObjectQL.registerApp` raises for the same object later in the same boot.

The decision — detection plus the locales it derives — is now one exported
function, `devI18nPluginOptions`, so the #15004 option-B acceptance pin
measures it by CALLING it rather than re-implementing the read. `DevPlugin`
keeps the dynamic import and its degradation: those are about the optional
package being installed, which is a different question from what the stack
declares.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
},
"devDependencies": {
"@objectstack/driver-turso": "workspace:*",
"@objectstack/plugin-dev": "workspace:*",
"@oclif/plugin-help": "^6.2.58",
"@oclif/plugin-plugins": "^5.4.87",
"@types/better-sqlite3": "^7.6.13",
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/test/fixtures/option-b-reader-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
*
* - invokes a reader this repo SHIPS (`collectBundleActions`,
* `resolveStandaloneDatabase`, `createStandaloneStack`,
* `appSecurityPluginOptions`, …) and reports its return value, or
* `appSecurityPluginOptions`, `devI18nPluginOptions`, …) and reports its
* return value, or
* - boots a real kernel carrying the real `AppPlugin` and reports what that
* plugin HANDED to a subsystem (a job scheduled, a datasource connected, a
* mapping set, an i18n service registered, a seed dataset merged).
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
resolveStandaloneDatabase,
} from '@objectstack/runtime';
import { appSecurityPluginOptions } from '@objectstack/plugin-security';
import { devI18nPluginOptions } from '@objectstack/plugin-dev';
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';

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

// `DevPlugin` takes its stack from a CALLER-SUPPLIED object
// (`new DevPlugin({ stack: config })`, the documented construction), so there
// is no load boundary between the composed config and this reader — the same
// object `os dev` boots from source is handed straight to the plugin. The row
// calls the SHIPPED decision (`devI18nPluginOptions`), which is what the
// plugin itself calls to decide whether to register `I18nServicePlugin`; a
// row that re-read `stack.translations` here would be a second copy of the
// read the reader program changes and would stay red after it was fixed.
const devI18n = devI18nPluginOptions(project);
rows.push(row(
'B2 · plugin-dev I18nServicePlugin auto-detect over the caller-supplied stack · translations',
devI18n ? `I18nServicePlugin(fallbackLocale=${devI18n.fallbackLocale})` : undefined,
devI18n === undefined,
));

const fromSourceProfile = appSecurityPluginOptions(project)?.fallbackPermissionSet;
rows.push(row(
'B2 · plugin-security appSecurityPluginOptions over the from-source config (default permission set) · permissions',
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
// into this program needs no widening.
"paths": {
"@objectstack/objectql": ["../objectql/src/index.ts"],
// [#15232] The fourth, added for the same reason and under the same
// no-star rule: the option-B probe calls `@objectstack/plugin-dev`'s
// shipped i18n auto-detect decision, and this package's vitest config
// aliases that same bare specifier to the same source entry, so the type
// verdict and the run agree about which artifact is under test. The
// package publishes only `"."`.
"@objectstack/plugin-dev": ["../plugins/plugin-dev/src/index.ts"],
"@objectstack/plugin-security": ["../plugins/plugin-security/src/index.ts"],
"@objectstack/runtime": ["../runtime/src/index.ts"]
}
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,23 @@ export default defineConfig({
find: /^@objectstack\/plugin-auth$/,
replacement: path.resolve(__dirname, '../plugins/plugin-auth/src/index.ts'),
},
// `test/fixtures/option-b-reader-probe.ts` (#15232) calls
// `@objectstack/plugin-dev`'s shipped i18n auto-detect decision — the
// option-B acceptance pin (#15004) measures readers by CALLING them, and
// a reader resolved through `exports` to plugin-dev's **dist** would make
// that row a verdict about the last build rather than about the reader
// this card changes. The registry in `check-test-source-alias.mjs` is
// SHRINK-ONLY, so widening `KNOWN_UNALIASED_TEST_IMPORTS` was never an
// option; this entry is the sanctioned remedy, in the same anchored form
// as its neighbours (plugin-dev publishes only `"."`, and the anchor is
// what keeps that true if a subpath is ever added). Measured before and
// after: the gate reports the same required set for this package in both
// directions — crossing into `plugin-dev/src` adds no unaliased artifact
// import it did not already carry.
{
find: /^@objectstack\/plugin-dev$/,
replacement: path.resolve(__dirname, '../plugins/plugin-dev/src/index.ts'),
},
// `src/utils/protocol-version-gap.test.ts` (#13860) exercises the upgrade
// advisory, whose verdict comes from `checkProtocolCompat` — the platform's
// single reader of `engines.protocol`. The advisory is a thin direction
Expand Down
Loading
Loading