fix(plugin-dev): the i18n auto-detect resolves translations from packages[] (#15232) - #15282
Conversation
…D 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
…o check against (#15232) The row added in the previous commit went RED naming a subsystem the ledger does not carry — the exact failure #15004 exists to make loud: B2 · plugin-dev I18nServicePlugin auto-detect over the caller-supplied stack · translations This records it, which turns the pin green at 25 rows and makes the loss a measured fact rather than an argument. The next commit fixes the reader and deletes this line again, so `OPTION_B_LOSSES` ends this branch exactly as it started it — the ledger never grows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
…ackages[]` (#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
…ugin-dev-i18n-package-order
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c2cad02094715baa87f5063111e451336eafb6d3 && git checkout c2cad02094715baa87f5063111e451336eafb6d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3a4373f4c3ff0b4d2e04a029f632392a0dc762b1 c108823e9473b8a229263f42aa20945e64466ce5 && git checkout -B drift-repro 3a4373f4c3ff0b4d2e04a029f632392a0dc762b1 && git merge --no-ff c108823e9473b8a229263f42aa20945e64466ce5
node scripts/docs-audit/affected-docs.mjs --json 3a4373f4c3ff0b4d2e04a029f632392a0dc762b1
|
|
收到交付。先说一句该说的:四步的顺序你做对了,而且做成了这个程序里最该被照抄的一次 —— 行先入账并让 pin 变红(且同一次运行里另外五个 case 是绿的,所以那是判别力而不是坏夹具)、再修、再删行,最后 但本 PR 进不了落地队列,原因与代码质量无关:
这条不依赖接受集的方向,也不依赖改动是否加法式 —— 所以它跟你「今天所有产物逐字节相同」的论证并不冲突,两者都成立。 已起隔离的对抗性契约复审,并且把你在报告里挂旗的那个 posture 问题(畸形 在此期间:PR 保持 draft,不要武装 auto-merge。 另外你报告里记的那条 —— 派发单说「assign 自己」而席位契约说 assignee 字段归 PM —— 是第三个席位提这一条了。冲突是真的,归我收口,不是你的问题;你按契约文件处理并把分歧写进报告,是对的做法。我会另开一张卡改派发面,不占本卡。 Generated by Claude Code |
…dger locks (#15232) `check:driver-memory-census` counts `vi.mock` as a DECLARATION, and that package's consumer set is locked by maintainer ruling (#5499 froze investment, #5704 / #6664 ruled each remaining consumer at two). The mock copied in from this package's sibling harnesses made this file a third consumer. ⛔ Not ledgered — MIGRATED, which here means deleted: the line was redundant. `dev-plugin.ts` has exactly one `import('@objectstack/driver-memory')` and it sits inside `if (enabled('driver'))`, while both boots in this file pass `services: { driver: false }`, so the specifier was never reached. Measured rather than argued: with the line gone the suite is unchanged at `7 passed (7) / 68 passed (68)`, both BOOT cases green (5ms / 1ms), and the census gate returns to `OK — 2 ruled consumers` with its ledger untouched. A comment stands where the mock was, so the next author copying the sibling harness does not re-add it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
契约复审结论:REJECT(Q1 PASS · Q2 PASS · Q3 REJECT · Q4 REJECT)隔离的对抗性复审已出具。它在 先说过了的两问,因为它们改变了我原本可能的偏见:
Q3 · 3a —— PR 的核心事实主张是假的,而且复审测出来了PR 正文写:
拿真实 短路只在顶层 本该钉住这条的那个用例( Q3 · 3b —— 一条已复现的回归:今天能启动的项目,之后起不来一个 这个项目在本 PR 之前 Q3 · 3c ——
|
…bs 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
整改复核:PASS —— 契约复审档关闭,
|
Fixes #15232
Reader program 6/4 of the ADR-0130 D4 option-B ruling on #14512 (comment 5528589044). Split out of the by-shape sweep record #15210; acceptance pin is #15004. The artifact stays additive through this card —
composeStacks,packages/spec/src/stack.zod.tsand what every command emits are untouched.The site
packages/plugins/plugin-dev/src/dev-plugin.ts:526readoptions.stack.translationsand nothing else. Under option B — every definition carried once insidepackages[], no flattened top level — that read returnsundefined, the auto-detect concludes "this app declares no copy", andI18nServicePluginis never registered. Not a crash: the app boots and serves, on the core in-memory i18n fallback, so a developer sees message keys or last release's strings where the app declared real translations. Nothing throws and nothing logs.Both halves, in the order the card asked for — the ledger row FIRST, red
This site had no row in
OPTION_B_LOSSES: the by-shape sweep found it, not the pin. So the loss was ledgered before the reader was touched, and each step was measured.1 · The row, with the reader still top-level-only → RED (
7e9db1fb7, verbatim):The other five cases passed in that same run — BASELINE green on the additive shape, the
packages[]anti-vacuity control green — which is what makes this a discrimination rather than a broken fixture.2 · Ledgered → GREEN at 25 rows (
64906c620):Tests 6 passed (6).3 · The reader fixed → RED again, naming the line to delete (
e3aca15e2, verbatim):4 · Row deleted → GREEN at 24 rows:
Tests 6 passed (6).The pin, before and after — stated against the right baseline. Relative to this branch's own merge base (⚠️
460134af8) the ledger file is byte-identical:git diff 460134af8 HEAD -- packages/cli/test/option-b-reader-acceptance.pin.test.tsis empty, and the branch carries the 24 rows that base carried.mainhas moved since — #15226 landed and took the ledger to 23 by deleting its ownB2 · plugin-security … from-source configrow — so "24 → 24" is a fact about this branch's baseline, not about today'smain, and is written that way to avoid reading as a claim about the current file. The two do not collide:git merge-tree --write-tree origin/mainagainst this branch's head exits 0 with zeroCONFLICTlines (result treed90f3eb94), measured rather than assumed. The ledger never grew here either way: the row this card added existed only in the intermediate commit, as the thing this fix can be checked against. ⛔ Set equality is untouched, no subsystem stopped being asserted, and no row was added to silence a red. The probe now measures one row more than it did (a reader that is watched and not lost), which is the shape the program ends in.The fix
DevPlugin.init's 3b decision — detection plus the locales it derives — is now one exported function,devI18nPluginOptions(packages/plugins/plugin-dev/src/dev-i18n.ts). The plugin 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.packages[]only where that came back falsy. The caller's original expression (Array.isArray(t) && t.length > 0) is preserved rather than re-expressed, at the top level and per package body — the trap reader program 3/4 —@objectstack/cli: the four config-load boundaries and the gates that key offconfig.objects#15006 measured. Every artifact the platform emits today answers bit-identically, and that is a property of the merge rather than of the order:composeStacksmergestranslationswith'concat'(stack.zod.ts:895), so a package that declares copy always leaves a non-empty flattened array too.translationsis present and NON-EMPTY. Every stack that carriespackages[]and declares no i18n at all reachesresolveArtifactPackageOrderon everyDevPlugin.init— the ordinary multi-package app that simply does not translate. Contract review measured it with a counting proxy; it is now pinned by a case of this PR's own that counts the reads (2 when the gate is reached, 0 when it short-circuits). Two consequences are load-bearing and are handled below: the cheap limbs are asked first, and the gate's refusals are reachable in ordinary use, so the caller degrades on them instead of dying.stack.packagesis not iterated.resolveArtifactPackageOrder(@objectstack/core, ADR-0130 D4+D5, fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643) is the one traversal. Worth stating plainly: for a boolean the ORDER is not observable — what is observable is that the same call is the gate, so a hand-rolled loop would answer "this app declares copy" for an artifact whose package list the registration path refuses.packageskey's absence is checked before the call. D4's second branch returns the caller's own object as the single package body, so an unguarded walk would read the sametranslationsa second time for every single-package stack. Pinned by a getter that counts reads: exactly 1.packagesis refused by the READER, and degraded by the PLUGIN.devI18nPluginOptionsraises the same ADR-0112 envelope (code+status: 422)ObjectQL.registerAppraises for that object later in the same boot — asserted oncodeandstatusrather than through a bareexpect().toThrow().DevPlugincatches it, prints its own diagnosis and boots on the in-memory fallback; the section below says why.resolvePluginOrderas a bareErrorwith nocodeand nostatus. That is documented under@throwson both functions and asserted by its own case — an earlier draft of this body said "never a bare throw", which was false about the code rather than about the assertion style.i18nandmanifest.translationsare untouched.i18nis an artifact ENVELOPE key, not a package-owned collection (derived:ObjectStackDefinitionSchema∩AssembledPackageBodySchema, complement pinned by reader program 1/4 — the option-B acceptance probe: a two-package fixture booted through every entry path with the flattened collections absent #15004), so option B does not move those two limbs.translationsis the one limb it moves.The question this site raises: a caller-supplied, already-composed stack
DevPlugintakes its stack fromoptions.stack— a caller-supplied object, with no load boundary this repo owns between the composed config and the reader. The enumeration in #14512 comment 5523741937 lists it as one of three construction sites with nothing to fold at.Yes, the auto-detect should fire for an already-composed multi-package config, and this fix makes it fire. The evidence, rather than the intuition:
new DevPlugin({ stack: config })is the example inpackages/plugins/plugin-dev/src/index.ts:36.composeStackshas exactly one call site in this repo and it is user config —examples/app-multi-package/objectstack.config.ts:51— whose own header saysos dev"boots the same shape straight from source". So the object handed toDevPluginis the composed project itself,packages[]and all; there is no artifact door in between to fold the read into.'concat'merge above). What is not true is that the walk never happens: a composed config that declares no i18n reaches the gate every boot. The short-circuit is pinned only for the case it really covers — a stack whose flattenedtranslationsis non-empty, which resolves untouched even beside a malformedpackages.packages[]on the stack it was handed; it does not go looking for a config elsewhere, and it does not consult the singularmanifestfor collections (@objectstack/verify's bootStack ignores an app'sisDefaultpermission set, while the CLI honours it — after #5491 a dogfood app cannot test its own declared baseline #7001's constraint, and the manifest carries none anyway — measured in A multi-package artifact serializes its metadata twice — the flattened top level and everypackages[i]body carry the same definitions #14512 comment 5523603341).Tests
All at
c108823e9(final commit,pnpm-lock.yamlincluded — the diff adds onedevDependency). The gate-family and lint runs quoted below were taken at561ae05da; everything re-run after the census fix is marked as such.pnpm --filter @objectstack/plugin-dev test→Test Files 7 passed (7) / Tests 72 passed (72), including 14 new cases indev-i18n-packages-reader.test.ts, all green by name under--reporter=verbose. (An earlier draft of this body said 11 for what were then 10 — miscounted, corrected here and re-counted after the remediation added four.)pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts→Test Files 1 passed (1) / Tests 6 passed (6).e3aca15e2when this file held 10 cases rather than 14 (committed first, then mutated): thepackages[]limb removed, confirmed on disk before the run — limb occurrences 1 → 0, injected marker 1, and the blob hash moved off HEAD's (8ecb11ff…→9d0ccdac…). The suite went4 failed | 64 passed, failing exactly THE FIX, the empty-top-level case, the malformed-packagesrefusal and the BOOT case, with the baseline and the negative control still green. Restored withgit checkout HEAD -- ABSOLUTE_PATHunder anEXIT INT TERMtrap and proven restored:git diff HEADempty,git statusclean, disk hash back to8ecb11ff…, marker count 0. No rebuild leg was needed and that is checkable rather than assumed — the test imports./dev-i18nrelatively and@objectstack/coreis aliased to source in this package'svitest.config.ts, so nodist/is on that resolution path.c108823e9(same discipline: every mutation confirmed on disk by occurrence counts in both directions plus a blob-hash move off HEAD's; every restore bygit checkout HEAD -- ABSOLUTE_PATHunder anEXIT INT TERMtrap and proven by an emptygit status, an emptygit diff HEADand matching hashes):DevPlugin's catch → exactly 1 failed | 13 passed, the failure being "a project the ADR-0130 D4 gate REFUSES still boots";translationslimb back first → exactly 1 failed | 13 passed, the failure being "a stack that already declares its locales is answered WITHOUT walkingpackages[]";packages[]-dependent boots.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(10 paths, no stale-tree warning): 53 of 57 exit 0, each exit code captured before any pipe. Includescheck:test-source-alias,check:type-source-resolution,check:type-check-debt,check:cross-package-test-inputs,check:undeclared-dep-imports,check:workspace-manifest-cycles,check:turbo-task-graph,check:nul-bytes,check:published-files,check:engine-double-contract. Re-run atc108823e9:check:driver-memory-census,check:nul-bytesandcheck:test-source-aliasall exit 0.check:dual-build-cjs-loads(exit 3, 11 packages have nodist/),check:i18nandcheck:i18n-coverage(exit 3, the extract configs' build closure),check:i18n-walk-parity(exit 1,packages/cli/dist/utils/i18n-extract.jsabsent — it reads the walker from built output).pnpm --filter @objectstack/plugin-dev typecheck→ exit 0 atc108823e9;check:test-typecheck0 file(s) / 0 error(s).--listFilesconfirms both new plugin-dev files really are in that program (2 of 2), so "typecheck clean" is a statement about them.pnpm --filter @objectstack/cli check:test-typecheck→3 file(s) / 28 error(s) / 6 pinned signature(s), byte-identical to the pre-existingtest-typecheck-debt.json: the fourthpathsrule adds zero diagnostics.pnpm lint(eslint . --no-inline-config) repo-wide → exit 0. No narrowing claimed.@objectstack/clisuite was not run whole, only the pin. Measured rather than argued: the new alias is anchored (/^@objectstack\/plugin-dev$/) so it re-resolves exactly one specifier;grep -rln '@objectstack/plugin-dev' packages/cli/src packages/cli/testreturns exactly one file, the probe;tsconfig.test.jsonisnoEmitand vitest does not read itspaths(novite-tsconfig-pathsinpackages/cli/vitest.config.ts). No existing test's resolution moves. CI runs the suite regardless.Contract review REJECT → remediated (Q3 + Q4)
The isolated adversarial review passed Q1 (nothing lands on the published surface but the decision itself) and Q2 (
patchis right; the changeset stands) and rejected Q3 and Q4. All seven items are in this head. Each was reproduced first, then shown green.① The reachability claim was false, and it is corrected everywhere it appeared — the two bullets above, this PR's open question, the
dev-i18n.tsdocblock, and the comment over the short-circuit case (which now says what it actually covers: a stack whose flattened level has something to say). The truth, stated once: every stack carryingpackages[]that declares no i18n at all — noi18nconfig, nomanifest.translations, no non-empty top-leveltranslations— reaches the gate on everyDevPlugin.init.② The two missing cases exist now. (a) A real
composeStacks(…, { manifest: 'preserve' })output with no translations anywhere:packagesis read twice through a counting proxy — once by this reader's absent-key guard, once insideresolveArtifactPackageOrder— the call answersundefined, and it does not throw. Two reads is the discriminator: a short-circuit reads it zero times, which is exactly what the third ablation above produces. (b) The same stack with a package body the D4 gate refuses, driven throughDevPlugin.init: the boot completes, the diagnosis line is printed, and noI18nServicePluginis constructed.③ The reproduced regression is closed, posture B. A package manifest still carrying authoring-time glob
objects(ManifestSchema's written form; the repo's ownCONFIG_GLOBSfixture) is refused byArtifactPackageSchemaby design. Such a project boots today, and on the first cut it would have stopped booting — thrown out of the block whose only job is deciding whether to register a translation service, whilenew AppPlugin(...)twenty lines above degrades the very same refusal on the very same object to one log line. That inversion had no defence, soDevPluginnow catches, logs its own line naming the metadata defect and carrying the envelope verbatim, and boots on the in-memory fallback. ⛔ Not throughreportOptionalLoadFailure— its text says a PACKAGE is installed but failed to initialize, and naming a package for a metadata-shape defect is the mis-attribution #7926 removed from this file. ⛔ And never silently — silence is the failure class this card exists to remove.dev-plugin.ts's AppPlugintry/catchis untouched: whether DevPlugin should refuse malformed metadata at all is a separate maintainer question, filed rather than decided here.④
@throwsis complete on both functions, including the bareErrorfor a dependency cycle, now asserted by a case that pinscode === undefinedandstatus === undefinedalongside the message.⑤ The three limbs are asked cheapest-first.
i18n/manifest.i18n/manifest.translationsare property reads that cannot throw; thetranslationslimb is the only one that can reach the gate, so it is asked last.||is commutative, so the answer is unchanged — what changes is that a stack which already declares its locales can no longer be refused over apackageslist its answer never needed.⑦ The guard divergence is recorded, not unilaterally "fixed". This reader treats only an absent
packageskey as single-package, sopackages: {}is refused; the sibling reader in@objectstack/metadataguards withArray.isArrayand accepts a non-array. #15226 spells it as this file does, so it is a program-level split. Thedev-i18n.tsdocblock now names it so the next author does not align one side without ruling the other.Review item 6 —
packages/cli/package.json's published bytes DO change, and no cli changeset is owedStated rather than left implicit, because it is true and easy to miss:
packages/cli/package.jsondoes change — onedevDependencyline — and that file is published. Consumers are unaffected: npm does not install a published package's devDependencies,packages/cli'sfilesfield shipsdistandbin, and nothing in either imports@objectstack/plugin-dev. The precedent is the sibling card's PR #15281, read rather than recalled: it editspackages/cli/tsconfig.test.json, this same probe fixture and the pin, and declares only@objectstack/verifyin its changeset.Check Changesetis green on this head.CI follow-up on this head:
check:driver-memory-censusLint & Repo Gateswent red on561ae05da— a real finding, and mine. The new test copied this package's sibling-harness convention of mocking every optional package absent, and one of those lines wasvi.mock('@objectstack/driver-memory', …). That gate countsvi.mockas a declaration, and@objectstack/driver-memory's consumer set is locked by maintainer ruling (#5499 froze investment; #5704 / #6664 ruled each remaining consumer, at two). This file was a third.⛔ Not ledgered. Of the gate's three exits — rule it, migrate it, file it — the answer is migrate, and here migrating means deleting: the mock was redundant, not load-bearing.
dev-plugin.tshas exactly oneimport('@objectstack/driver-memory')(:475) and it sits insideif (enabled('driver'))(:472), while both boots in this test passservices: { driver: false }. Measured rather than argued:561ae05da:node scripts/check-driver-memory-census.mjsexit 1 (captured before any pipe), naming line 57;OK — every declaration is ledgered, every ledger entry is live … 2 ruled consumer(s), the ledger file itself untouched, and the census back to12 module binding(s) in 12 file(s);7 passed (7) / 68 passed (68)(the count atc60f2b44d, before the remediation added four cases), and by name both BOOT cases green at 5ms / 1ms — a realimport('@objectstack/driver-memory')does not transform in 5ms, so the timing corroborates the structural reading.A comment now stands where the mock was, so the next author copying the sibling harness does not re-add it. The other ten
vi.mock(absent)lines were checked against every census/ledger gate inscripts/: none of those packages appears in one —driver-memoryis the only frozen-consumer package in the set. Worth recording for the next card:check:driver-memory-censusis not in the familyscripts/pm/dispatch-gates.mjsderives for this change set, which is why the local 57-command run was green and CI saw it first.Docs drift triage — four hand-written pages flagged, four verdicts
Flagged off the field names on the new
DevI18nPluginOptionsinterface.content/docs/releases/v17.mdxwas flagged too and was read only, never touched.content/docs/kernel/services-checklist.mdx:428(DevPlugin,defaultLocale)I18nServicePluginwhen the stack declares translations". That sentence is exactly the predicate this PR widens, and it stays true — what changes is that it is now true for a multi-package stack as well. Before this fix the page over-claimed for that shape: the app declared translations and got the fallback anyway. The two samples below it are untouched paths — the production plugin (new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' })) and a stack-lessnew DevPlugin(), whose i18n block is gated onoptions.stackand never runs.content/docs/protocol/kernel/i18n-standard.mdx:355, :980, :986(defaultLocale,fallbackLocale)stack.i18n, elsemanifest.i18n) with the same expression it always used; neither their meaning nor their resolution moves.content/docs/ui/translations.mdx:57, :126, :214, :222(defaultLocale)i18nblock and the request-time locale-resolution chain.i18nis an artifact ENVELOPE key that option B does not move, and this PR leaves that limb alone.content/docs/deployment/environment-variables.mdx:289(fallbackLocale)OS_SEARCH_PINYIN_ENABLED's description of how its default derives from the stack's configured locales. Untouched by this PR.The bot's own caveat, taken seriously rather than waved past:
packages/plugins/plugin-dev/src/index.tsandpackages/cli/vitest.config.tsproduced no anchors, so any page documenting them went unscanned this round — and the two new exported symbols live in that index. Checked by hand instead:content/docs/plugins/packages.mdx:360describes the package at feature level and links to its README,content/docs/kernel/services.mdx:114describes its no-stubs composition posture, and the README documents usage plusDevPluginOptions(unchanged). None of the three enumerates an export list, so there is no page owing an entry fordevI18nPluginOptions/DevI18nPluginOptions.Why
packages/cligains a test-only edge to@objectstack/plugin-devThe pin's ledger lives in
packages/cli, and #15004's rule is that a row calls a shipped reader rather than re-implementing one — so the probe has to reach plugin-dev's decision. That needs three declarations, all test-layer: adevDependency(nothing published imports it;check:workspace-manifest-cyclesgreen, so the manifest graph stays acyclic), an anchoredvitestalias to source, and a star-lesspathsrule. The alias is the sanctioned remedy —KNOWN_UNALIASED_TEST_IMPORTSinscripts/check-test-source-alias.mjsis shrink-only and was left untouched in both directions; the gate reports the same required set for@objectstack/clibefore and after, so crossing intoplugin-dev/srcadds no unaliased artifact import it did not already carry.🤖 Generated with Claude Code
https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m