32 KiB
i18n Modular Split — @suaveplan/i18n → 14 Sibling Packages
- Date: 2026-05-05
- Status: DESIGN APPROVED — pending spec-review loop, then transitions to OpenSpec proposals + implementation plan
- Scope: Split the current
@suaveplan/i18nmonolith into 14 sibling packages underpackages/i18n/matching the granularity of the rest of the genesis monorepo (cf.crypto-*,drive-*,network-plugin-*) - Branch:
genesis - Supersedes / amends (existing OpenSpec changes): Folds the four already-shipped i18n changes (
i18n-storage-backends,i18n-formatters,i18n-spec-conformance,i18n-completion— currently misplaced underopenspec/changes/archive/2026-05-04-*/) into a coherent multi-package layout. Existing capability spec atopenspec/specs/i18n/spec.mdsurvives but is split across new capability files.
1. Overview
@suaveplan/i18n today is a single ~121-source-file package that bundles eight unrelated concerns (catalog, parser, interpolation, pluralization, formatters, backends, detector, observability) behind one entry point. Every other domain in the monorepo with comparable surface area splits into many fine-grained sibling packages — crypto-hash, crypto-curve, crypto-cipher, crypto-utils, crypto-vault, crypto-cli, crypto-bin, crypto-sodium, crypto-browser, crypto-core, crypto; drive, drive-template, drive-mesh-protocol, drive-plugin-chunks, drive-sharing, drive-transport-https; network plus a dozen network-plugin-* siblings. The four OpenSpec changes that built the monolith were even named like sub-packages (i18n-storage-backends, i18n-formatters) — strongly implying split intent — but the implementation collapsed them into one package.
This change brings i18n in line with the monorepo pattern: 13 new sibling packages plus a slimmed-down core, in dependency order so each layer commits before the next, and the core rewrite is the very last step.
1.1. Decision summary
| Topic | Decision | Section |
|---|---|---|
| Number of packages | 14 (1 shared runtime + 1 ICU parser + 8 formatters + 1 detector + 5 backends + 1 slim core; plus types-only additions to @suaveplan/types) |
§3 |
| Granularity for backends | Per-backend (matches monorepo "split when external deps differ" rule — storage pulls @suaveplan/storage, fs-multifile is server-only) |
§6 |
| Granularity for formatters | Per-formatter (matches user-stated lean toward fine-grained; trivial overhead since each is a thin Intl.* wrapper) |
§5 |
| Where shared atoms live | New @suaveplan/i18n-shared (runtime constants + I18nError + I18N_CODES) plus type-only additions to @suaveplan/types |
§4 |
| Backend interface location | @suaveplan/types/i18n (zero runtime — same pattern as the cross-package crypto contract) so backend packages do not depend on @suaveplan/i18n core |
§4.1, §6 |
| Core slim scope | Catalog + plugin slot (createI18n / I18nInstance) + middleware + observability + key utils + missing-key flow + schemas + conformance harness |
§7 |
| Back-compat | Slim @suaveplan/i18n root barrel re-exports the entire pre-split surface from the new packages — every existing import { X } from "@suaveplan/i18n" keeps working |
§8 |
| Wave order | 1 foundation → 2 parser → 3 formatters → 4 detector → 5 backends → 6 core (LAST) | §9 |
| Crash-safety | Waves 1–5 are pure additions (zero edits to packages/i18n/i18n/src/). Each wave commits before the next. Only Wave 6 is destructive. |
§9 |
1.2. Final package layout
packages/i18n/
i18n/ ← Wave 6: slim core (catalog + plugin + middleware + observability)
i18n-shared/ ← Wave 1: constants + I18N_CODES + I18nError class
i18n-icu-parser/ ← Wave 2: parser + interpolation + pluralization
i18n-formatter-number/ ← Wave 3: Intl.NumberFormat wrapper
i18n-formatter-relative-time/ ← Wave 3: Intl.RelativeTimeFormat wrapper
i18n-formatter-list/ ← Wave 3: Intl.ListFormat wrapper
i18n-formatter-display-name/ ← Wave 3: Intl.DisplayNames wrapper
i18n-formatter-collator/ ← Wave 3: Intl.Collator wrapper (NEW — not in monolith)
i18n-formatter-segmenter/ ← Wave 3: Intl.Segmenter wrapper (NEW — not in monolith)
i18n-formatter-date/ ← Wave 3: Intl.DateTimeFormat wrapper
i18n-formatter-currency/ ← Wave 3: Intl.NumberFormat (currency) wrapper + ISO 4217 metadata
i18n-detector/ ← Wave 4: locale detection chain
i18n-backend-memory/ ← Wave 5: in-memory backend
i18n-backend-http/ ← Wave 5: fetch-based backend
i18n-backend-storage/ ← Wave 5: AbstractStorage backend (depends on @suaveplan/storage)
i18n-backend-fs-multifile/ ← Wave 5: server-only Node fs backend
i18n-backend-chained/ ← Wave 5: chained-fallback backend orchestrator
locale-data/ ← UNCHANGED: ISO 3166 / ISO 639 static data
2. Why now
- Architectural drift. The monolith violates the monorepo's own splitting pattern. Every package audit and parity check has to special-case i18n.
- Tier leakage.
i18n-backend-fs-multifileusesnode:fs, but the monolith is a universal-tier package — consumers in browser-tier code can accidentally tree-shake-ineligible imports. Split eliminates this. - Optional-deps awkwardness. Today
@suaveplan/storageis a hard dep of@suaveplan/i18nbecause of one backend. Consumers who never touch the storage backend still pay the install cost. - The original change names already implied this layout.
i18n-storage-backends,i18n-formatters,i18n-completion,i18n-spec-conformance— every prior change was named per-feature. The implementation simply forgot to split. - Crash-safety for in-flight work. With the monolith, any session that crashes mid-edit can leave the only i18n package in a half-state. With the split, leaf packages commit independently — a crashed Wave 6 leaves Waves 1–5 fully-shipped on disk.
- Downstream React stack.
@suaveplan/react-i18nis the binding layer — splitting i18n now (before more downstream React packages land) means React consumers can pull only the formatters / backends they need.
3. The 14 packages — role and dependency map
| # | Package | Tier | Role | Direct deps (within i18n family) |
|---|---|---|---|---|
| 1 | @suaveplan/i18n-shared |
universal | Constants, error codes, error class | none |
| 2 | @suaveplan/i18n-icu-parser |
universal | ICU parser, interpolation engine, pluralization rules, ICU render with FormatBridge DI seam |
@suaveplan/i18n-shared |
| 3 | @suaveplan/i18n-formatter-number |
universal | Intl.NumberFormat wrapper |
@suaveplan/i18n-shared |
| 4 | @suaveplan/i18n-formatter-relative-time |
universal | Intl.RelativeTimeFormat wrapper |
@suaveplan/i18n-shared |
| 5 | @suaveplan/i18n-formatter-list |
universal | Intl.ListFormat wrapper |
@suaveplan/i18n-shared |
| 6 | @suaveplan/i18n-formatter-display-name |
universal | Intl.DisplayNames wrapper |
@suaveplan/i18n-shared |
| 7 | @suaveplan/i18n-formatter-collator |
universal | Intl.Collator wrapper |
@suaveplan/i18n-shared |
| 8 | @suaveplan/i18n-formatter-segmenter |
universal | Intl.Segmenter wrapper |
@suaveplan/i18n-shared |
| 9 | @suaveplan/i18n-formatter-date |
universal | Intl.DateTimeFormat wrapper |
@suaveplan/i18n-shared |
| 10 | @suaveplan/i18n-formatter-currency |
universal | Currency wrapper + ISO 4217 metadata | @suaveplan/i18n-shared, @suaveplan/i18n-formatter-number |
| 11 | @suaveplan/i18n-detector |
universal | Locale detection chain | @suaveplan/i18n-shared, @suaveplan/locale-data |
| 12 | @suaveplan/i18n-backend-memory |
universal | In-memory Backend impl |
@suaveplan/i18n-shared |
| 13 | @suaveplan/i18n-backend-http |
universal | Fetch-based Backend impl |
@suaveplan/i18n-shared |
| 14 | @suaveplan/i18n-backend-storage |
universal | AbstractStorage-backed Backend impl |
@suaveplan/i18n-shared, @suaveplan/storage |
| 15 | @suaveplan/i18n-backend-fs-multifile |
server | Node fs multi-file Backend impl |
@suaveplan/i18n-shared |
| 16 | @suaveplan/i18n-backend-chained |
universal | Chained-fallback Backend orchestrator |
@suaveplan/i18n-shared |
| 17 | @suaveplan/i18n |
universal | Slim core: catalog + plugin slot + middleware + observability + key utils + missing-key flow | All of 1–16 (re-exported by root barrel) |
Plus, types-only additions to @suaveplan/types (Wave 1a — not a new package):
packages/primitives/types/src/i18n/
i18n.types.ts ← Backend, BackendCapabilities, TranslationRecord,
LocaleNamespace, MissingKeyHandler, MessageLoader,
NamespacedCatalogMap, TranslationCatalogMap, LookupResult
index.ts
3.1. Dependency graph (ASCII)
@suaveplan/types ──────────┬─── (every package — types-only)
│
@suaveplan/i18n-shared ────┼─── i18n-icu-parser
├─── i18n-formatter-{number,relative-time,list,display-name,
│ collator,segmenter,date}
├─── i18n-formatter-currency ── i18n-formatter-number
├─── i18n-detector ── @suaveplan/locale-data
├─── i18n-backend-{memory,http,fs-multifile,chained}
├─── i18n-backend-storage ── @suaveplan/storage
│
▼
@suaveplan/i18n (slim core, re-exports everything)
No cycles. Every leaf depends only on @suaveplan/i18n-shared plus (optionally) one external for tier-specific work. The slim core depends on all 16 leaves.
4. Wave 1a — @suaveplan/types/i18n (types-only)
The Backend interface and its peers move into @suaveplan/types so backend packages can implement it without depending on @suaveplan/i18n. This mirrors the cross-package crypto contract already in @suaveplan/types (Authority, KeySlotPublic/Private, SlotId, DomainTag, AuthorityVerifier, AeadOps).
Add packages/primitives/types/src/i18n/i18n.types.ts:
export interface TranslationRecord {
readonly [key: string]: string | TranslationRecord;
}
export interface LocaleNamespace {
readonly locale: string;
readonly namespace: string;
}
export interface BackendCapabilities {
readonly supportsBulkRead: boolean;
readonly supportsWrite: boolean;
readonly supportsLastModified: boolean;
}
export interface Backend {
readonly name: string;
readonly capabilities: BackendCapabilities;
read(locale: string, namespace: string): Promise<TranslationRecord>;
readMulti?(
locale: string,
namespaces: readonly string[],
): Promise<Record<string, TranslationRecord>>;
save?(
locale: string,
namespace: string,
record: TranslationRecord,
): Promise<void>;
lastModified?(locale: string, namespace: string): Promise<number | undefined>;
init?(): Promise<void>;
}
export type MessageLoader = (
locale: string,
namespace?: string,
) => Promise<TranslationRecord>;
export type MissingKeyHandler = (info: {
readonly locale: string;
readonly namespace: string;
readonly key: string;
}) => string | undefined;
export interface LookupResult {
readonly value: string | undefined;
readonly fellBackTo: string | undefined;
}
export type NamespacedCatalogMap = Map<string, TranslationRecord>;
export type TranslationCatalogMap = Map<string, NamespacedCatalogMap>;
packages/primitives/types/src/index.ts adds: export type * from "./i18n/i18n.types.js";
This is a zero-runtime, additive change — every existing consumer continues to work unchanged.
5. Wave 1b — @suaveplan/i18n-shared (runtime atoms)
@suaveplan/types cannot hold runtime — constants and the I18nError class need a runtime home. New leaf package:
packages/i18n/i18n-shared/src/
constants/
locale-bcp47-map.ts ← LOCALE_BCP47_MAP
rtl-locales.ts ← RTL_LOCALES, RTL_LOCALE_LIST
locale-descriptors.ts ← LOCALE_DESCRIPTORS, DEFAULT_LOCALES, DefaultLocale
interpolation-defaults.ts ← DEFAULT_INTERPOLATION
index.ts ← explicit re-exports
errors/
i18n-error.ts ← I18nError extends SuaveplanError<I18nErrorCode>
error-codes.ts ← I18N_CODES via defineCodes (full registry, ~14 codes)
index.ts
index.ts ← namespace exports
index.test.ts
Dependencies: @suaveplan/error, @suaveplan/types, tslib, zod (for any boundary schemas the shared layer needs). Zero deps on other @suaveplan/i18n* packages.
The full I18N_CODES registry moves here in one shot — every leaf package throws codes that already exist before any leaf is implemented. This avoids the chicken-and-egg of "which package owns which code."
5b. ICU render FormatBridge (small architectural wedge)
Today packages/i18n/i18n/src/parser/message/message-render.ts imports formatNumber, formatCurrency, formatDate, formatTime directly to render ICU {value, number}, {amount, number, currency=USD}, {date, date, short} placeholders. That makes the parser depend on the formatters, which would invert Waves 2 and 3.
The split introduces a tiny DI seam: i18n-icu-parser declares a FormatBridge interface and the renderer accepts one; if a bridge is not supplied, the renderer returns the raw value coerced to string (no Intl call). Concretely:
export interface FormatBridge {
number?(value: number, locale: string, style?: NumberFormatStyle): string;
currency?(value: number, currency: string, locale: string): string;
date?(value: Date | number, locale: string, style?: DateStyle): string;
time?(value: Date | number, locale: string, longForm?: boolean): string;
relativeTime?(value: number, locale: string, style?: RelativeTimeStyle): string;
}
export function renderNodes(
nodes: readonly MessageNode[],
values: InterpolationValues,
locale: string,
options: RenderOptions & { formatBridge?: FormatBridge },
): string;
The slim core (Wave 6) constructs a default FormatBridge by importing the formatter packages and passing it to every renderNodes call. Consumers who wire createI18n({...}) get formatters automatically. Consumers who construct a MessageParser directly can pass their own bridge or accept the no-Intl fallback.
This is the only API surface change in the entire split; every other module moves byte-identical.
Every backend package has the same shape (only <name> and the implementation differ):
packages/i18n/i18n-backend-<name>/
package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types,
@suaveplan/error, @opentelemetry/api,
(one external if needed: @suaveplan/storage / node:fs)
src/
backend.ts ← implements Backend from @suaveplan/types/i18n
backend.types.ts
backend.schemas.ts ← Zod for options
backend.test.ts
backend.spec-conformance.test.ts
backend.md
factory.ts ← <name>Backend(options): Backend factory function
factory.test.ts
factory.md
index.ts ← explicit named re-exports
README.md
tsconfig.json ← extends @suaveplan/typescript-config/library.json
vite.config.ts ← createTestConfig() from @suaveplan/testing/config
Each backend's factory.ts exports a lower-case factory (memoryBackend, httpBackend, etc.) that returns a Backend. The backend class itself (MemoryBackend, HttpBackend, etc.) is also exported for consumers who want subclassing.
6.1. Tier split rationale
Four backends are universal tier (memory, http, storage, chained). One is server tier (fs-multifile) because it uses node:fs/promises. The split prevents the universal package surface from accidentally pulling Node-only deps when a browser-tier consumer imports @suaveplan/i18n-backend-storage.
7. Formatter package shape (Wave 3 template)
packages/i18n/i18n-formatter-<name>/
package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types,
@suaveplan/error, @opentelemetry/api, zod
src/
formatter.ts ← <Name>Formatter class wrapping Intl.<Name>
formatter.types.ts
formatter.schemas.ts ← Zod for options
formatter.test.ts
formatter.spec-conformance.test.ts
formatter.md
helpers.ts ← format<Name>(value, locale, options?) standalone
helpers.test.ts
helpers.md
index.ts
README.md
tsconfig.json
vite.config.ts
Each formatter caches its underlying Intl.<Name> per (locale, options-hash) pair (already proven pattern from current NumberFormatter). Each emits an OTel histogram i18n.format.<name>.duration_ms and a counter i18n.format.cache_hit{formatter="<name>"}.
7.1. Two new formatters
Collator and Segmenter are listed in the original i18n-formatters change scope but not implemented in the current monolith. The split adds them as their own packages — no monolith bloat, just two new sibling packages.
8. Wave 6 — slim @suaveplan/i18n core
After Waves 1–5 ship, the core package shrinks dramatically. Final shape:
packages/i18n/i18n/src/
catalog/
catalog.ts ← MessageCatalog, deepMerge
catalog.types.ts
catalog.schemas.ts
catalog.test.ts
catalog.md
index.ts
plugin/
instance.ts ← createI18n, I18nInstance
instance.types.ts
instance.test.ts
instance.md
index.ts
middleware/ ← unchanged from current monolith
observability/ ← unchanged from current monolith
utils/
keys.ts ← flattenRecord, buildKey
...
schemas/ ← package-level boundary schemas
conformance.test.ts ← end-to-end spec conformance (KEEPS PASSING)
index.ts ← root barrel: re-exports the entire pre-split surface
Modules that move OUT (and are deleted from packages/i18n/i18n/src/):
| Source path (deleted) | New home |
|---|---|
src/constants/ |
@suaveplan/i18n-shared |
src/errors/ |
@suaveplan/i18n-shared |
src/error-codes.ts |
@suaveplan/i18n-shared |
src/parser/ |
@suaveplan/i18n-icu-parser |
src/core/interpolation/ |
@suaveplan/i18n-icu-parser |
src/core/pluralization/ |
@suaveplan/i18n-icu-parser |
src/formatting/number/ |
@suaveplan/i18n-formatter-number |
src/formatting/relative-time/ |
@suaveplan/i18n-formatter-relative-time |
src/formatting/list/ |
@suaveplan/i18n-formatter-list |
src/formatting/display-name/ |
@suaveplan/i18n-formatter-display-name |
src/formatting/date/ |
@suaveplan/i18n-formatter-date |
src/formatting/currency/ |
@suaveplan/i18n-formatter-currency |
src/detector/ |
@suaveplan/i18n-detector |
src/core/backend/memory/ |
@suaveplan/i18n-backend-memory |
src/core/backend/http/ |
@suaveplan/i18n-backend-http |
src/core/backend/storage/ |
@suaveplan/i18n-backend-storage |
src/core/backend/multi-file-fs/ |
@suaveplan/i18n-backend-fs-multifile |
src/core/backend/chained/ |
@suaveplan/i18n-backend-chained |
src/core/backend/backend.types.ts |
@suaveplan/types/i18n (interface) + @suaveplan/i18n-shared (any runtime helpers) |
Modules that STAY in core: catalog/, plugin/, middleware/, observability/, utils/, schemas/, conformance.test.ts, root index.ts.
8.1. Back-compat — root barrel re-exports
The root packages/i18n/i18n/src/index.ts becomes a re-export shim that preserves every pre-split import. Concretely:
// Constants and errors — re-exported from i18n-shared
export {
DEFAULT_INTERPOLATION,
DEFAULT_LOCALES,
LOCALE_BCP47_MAP,
LOCALE_DESCRIPTORS,
RTL_LOCALE_LIST,
RTL_LOCALES,
I18N_CODES,
I18nError,
} from "@suaveplan/i18n-shared";
export type { DefaultLocale, I18nErrorCode } from "@suaveplan/i18n-shared";
// Catalog — stays in core
export { MessageCatalog, deepMerge } from "./catalog/catalog.js";
export type {
CatalogOptions,
ExtendedCatalogOptions,
LookupResult,
MessageLoader,
MissingKeyHandler,
NamespacedCatalogMap,
TranslationCatalogMap,
TranslationRecord,
} from "@suaveplan/types/i18n";
// Plugin — stays in core
export { createI18n, I18nInstance } from "./plugin/instance.js";
export type { I18nInstanceOptions } from "./plugin/instance.js";
// Parser/interpolation/pluralization — re-exported from i18n-icu-parser
export {
InterpolationEngine,
escapeHtmlChars,
escapeRegex,
MessageParser,
PluralizationEngine,
arabicPlural,
/* ...full plural rule set... */
} from "@suaveplan/i18n-icu-parser";
// Formatters — re-exported from each i18n-formatter-* package
export { NumberFormatter, formatNumber } from "@suaveplan/i18n-formatter-number";
export { RelativeTimeFormatter, formatRelativeTime } from "@suaveplan/i18n-formatter-relative-time";
export { ListFormatter, formatList } from "@suaveplan/i18n-formatter-list";
export { DisplayNameFormatter, formatDisplayName } from "@suaveplan/i18n-formatter-display-name";
export { CollatorFormatter, formatCollation } from "@suaveplan/i18n-formatter-collator";
export { SegmenterFormatter, formatSegments } from "@suaveplan/i18n-formatter-segmenter";
export { DateFormatter, formatDate } from "@suaveplan/i18n-formatter-date";
export { CurrencyFormatter, formatCurrency } from "@suaveplan/i18n-formatter-currency";
// Detector — re-exported from i18n-detector
export { createDetectorChain, getTextDirection } from "@suaveplan/i18n-detector";
export type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
// Backends — re-exported from i18n-backend-* packages
export { MemoryBackend, memoryBackend } from "@suaveplan/i18n-backend-memory";
export { HttpBackend, httpBackend } from "@suaveplan/i18n-backend-http";
export { StorageBackend, storageBackend } from "@suaveplan/i18n-backend-storage";
export { ChainedBackend, chainedBackend } from "@suaveplan/i18n-backend-chained";
// (fs-multifile NOT re-exported from universal root — server-tier consumers import directly)
Subpath exports also preserved — @suaveplan/i18n/detector, @suaveplan/i18n/plugin, @suaveplan/i18n/parser, @suaveplan/i18n/errors continue to resolve, internally forwarding to the relevant new package.
8.2. Verification
The existing packages/i18n/i18n/src/conformance.test.ts is the contract gate. After the slim, every assertion in that file MUST still pass — that is the regression bar for the back-compat layer.
Downstream consumers verified:
packages/react/react-i18n/— 17 distinct imports from@suaveplan/i18n(root + subpaths). Re-export bridge covers all of them.packages/react/react-ui/— uses@suaveplan/i18nfor translation key types and missing-key flow. Bridge covers.packages/react/react-forms/— uses@suaveplan/i18nfor validation message catalog. Bridge covers.
9. Wave-by-wave execution plan
Crash-safety principle: every wave commits before the next starts. Waves 1–5 are pure additions to packages/i18n/ — they never touch packages/i18n/i18n/src/. Only Wave 6 deletes from the monolith. If a session dies mid-Wave-6, the leaf packages (Waves 1–5) are already on disk and the monolith is recoverable from git.
Wave 1 — Foundation
| Step | Artifact | Gate |
|---|---|---|
| 1.1 | @suaveplan/types/i18n/i18n.types.ts (zero runtime additions) |
bunx turbo typecheck --filter=@suaveplan/types green |
| 1.2 | @suaveplan/i18n-shared package scaffold + constants + errors + error codes |
5-gate triple green; 100/100/100/100 cov |
| 1.3 | Commit: feat(i18n): foundation — types/i18n + i18n-shared (Wave 1) |
— |
Wave 2 — ICU parser
| Step | Artifact | Gate |
|---|---|---|
| 2.1 | @suaveplan/i18n-icu-parser scaffold |
— |
| 2.2 | Move parser/, core/interpolation/, core/pluralization/ source + tests + docs into the new package; rewrite imports to @suaveplan/i18n-shared |
5-gate triple green; 100/100/100/100 cov |
| 2.3 | Commit: feat(i18n): icu-parser package (Wave 2) |
— |
Note: the source is copied, not moved, in Wave 2. The monolith retains its copy until Wave 6 deletes it. This keeps the monolith green throughout Waves 2–5.
Wave 3 — Formatters (8 packages, fully parallel)
| Step | Artifact | Gate |
|---|---|---|
| 3.1 | 8 formatter package scaffolds (parallel agent dispatch) | — |
| 3.2 | Copy each formatting/<name>/ → its package; add Collator + Segmenter from scratch |
5-gate triple green per package; 100/100/100/100 cov each |
| 3.3 | Commit: feat(i18n): 8 formatter packages (Wave 3) |
— |
Wave 4 — Detector
| Step | Artifact | Gate |
|---|---|---|
| 4.1 | @suaveplan/i18n-detector scaffold |
— |
| 4.2 | Copy detector/ → new package; rewire deps to @suaveplan/i18n-shared + @suaveplan/locale-data |
5-gate triple green; 100/100/100/100 cov |
| 4.3 | Commit: feat(i18n): detector package (Wave 4) |
— |
Wave 5 — Backends (5 packages, fully parallel)
| Step | Artifact | Gate |
|---|---|---|
| 5.1 | 5 backend package scaffolds (parallel agent dispatch) | — |
| 5.2 | Copy each core/backend/<name>/ → its package; rewire to Backend from @suaveplan/types/i18n |
5-gate triple green per package; 100/100/100/100 cov each |
| 5.3 | Commit: feat(i18n): 5 backend packages (Wave 5) |
— |
Wave 6 — Slim core (RISKY)
| Step | Artifact | Gate |
|---|---|---|
| 6.1 | Add Wave 1–5 packages as workspace:^ deps in packages/i18n/i18n/package.json |
— |
| 6.2 | Rewrite packages/i18n/i18n/src/index.ts to re-export from new packages (per §8.1) |
bunx turbo typecheck --filter=@suaveplan/i18n green |
| 6.3 | Verify downstream consumers (react-i18n, react-ui, react-forms) still typecheck and test green |
bunx turbo lint typecheck test --filter='@suaveplan/react-*' green |
| 6.4 | Verify conformance.test.ts still passes against re-exported surface |
bunx turbo test --filter=@suaveplan/i18n — all 745 tests green |
| 6.5 | Delete moved-out source from monolith (per §8 table) | bunx turbo lint typecheck test --filter=@suaveplan/i18n green; coverage 100/100/100/100 on remaining slim core |
| 6.6 | Refresh MANIFEST.md (bun scripts/folder-reorg/generate-manifest.ts) |
— |
| 6.7 | Update CLAUDE.md package listing |
— |
| 6.8 | Archive all 14 change folders + this design doc to openspec/archive/2026-05-05-i18n-modular-split-*/ |
— |
| 6.9 | Commit: feat(i18n): slim core + complete modular split (Wave 6) |
— |
10. Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Re-export bridge misses an import path | Medium | Downstream consumers break | §8.1 lists every observed downstream import; conformance test + downstream typecheck in Wave 6.3 catches gaps |
| Subpath export forwarding loses tree-shaking | Low | Bundle size regression in react-i18n consumers |
Keep per-package subpath exports (@suaveplan/i18n-formatter-number/formatter) so direct importers bypass the bridge |
| Plural-rule tests in icu-parser drift from monolith copy | Low | Spec conformance fails | Copy verbatim; plural rules are pure functions; same it(...) titles |
Storage backend tests need real @suaveplan/storage MemoryStorage |
Low | Test infra | Already working in monolith; same pattern |
| Wave 6 deletion takes monolith below 100% cov | Medium | Pre-publish gate fails | Delete in surgical commits; run bunx turbo test:coverage after each delete batch; restore via git checkout if cov drops |
| Session crash during Wave 6 leaves monolith in half-deleted state | Low (but the user explicitly flagged it) | Branch needs git restore |
Waves 1–5 already committed = leaf packages survive; git restore packages/i18n/i18n/src/ brings the monolith back |
| OpenSpec change folder authoring takes longer than implementation | Medium | Pre-implementation effort sunk | Acceptable — folders are durable artifacts; even a crashed session leaves them on disk for the next session to pick up |
11. Out of scope
- No API changes. Every public symbol keeps its name and signature. The only observable change for consumers is they can now import from the per-package paths if they want narrower trees.
- No new functionality beyond
CollatorandSegmenterformatters (which were in the originali18n-formatterschange scope). - No
@suaveplan/react-i18nchanges. It stays on the slim core's re-export surface; a follow-up change can migrate it to direct per-package imports for tree-shaking. - No
@suaveplan/locale-datasplit. It is already a separate package and has no need to grow further. - No registry/distribution changes. Verdaccio publication is unchanged; each new package publishes independently.
12. Verification matrix
For every wave, the following gates are mandatory before commit:
# Per-package gates (run for each new/touched package)
bun run check # Biome 0/0/0
bunx turbo lint typecheck test --filter=@suaveplan/<pkg> # green
bunx turbo test:coverage --filter=@suaveplan/<pkg> # 100/100/100/100
bun scripts/openspec-conformance.ts @suaveplan/<pkg> # exit 0
bunx turbo build --filter=@suaveplan/<pkg> # green
# Wave 6 additional cross-package gate
bunx turbo lint typecheck test --filter='@suaveplan/react-*' # downstream stays green
bunx turbo build --filter='@suaveplan/i18n*' # entire i18n family builds
The OpenSpec conformance gate runs against each new package's spec delta; the **Verified by:** lines are checked for existence of the named test cases.
13. Authoring sequence for OpenSpec change folders
The following 14 change folders will be created in openspec/changes/:
openspec/changes/
i18n-types-extension/ ← Wave 1a (zero-runtime types in @suaveplan/types/i18n)
i18n-shared/ ← Wave 1b
i18n-icu-parser/ ← Wave 2
i18n-formatter-number/ ← Wave 3
i18n-formatter-relative-time/ ← Wave 3
i18n-formatter-list/ ← Wave 3
i18n-formatter-display-name/ ← Wave 3
i18n-formatter-collator/ ← Wave 3
i18n-formatter-segmenter/ ← Wave 3
i18n-formatter-date/ ← Wave 3
i18n-formatter-currency/ ← Wave 3
i18n-detector/ ← Wave 4
i18n-backend-memory/ ← Wave 5
i18n-backend-http/ ← Wave 5
i18n-backend-storage/ ← Wave 5
i18n-backend-fs-multifile/ ← Wave 5
i18n-backend-chained/ ← Wave 5
i18n-core-slim/ ← Wave 6
(That is actually 18 folders, not 14 — the design doc text in §1.2 lists 14 new packages plus the slim core; the change-folder count includes the types extension and the core slim as separate OpenSpec changes.)
Each folder contains:
proposal.md— Why / What changes / Out of scope / Source mapping / Verification approach / Impacttasks.md— numbered checklist following the Wave 1–5 template (spec authoring → scaffold → implementation → tests → documentation → 5-gate triple → 7-row checklist → archive)specs/<capability>/spec.md— ADDED Requirements with**Verified by:**lines per AGENTS.md §13
Each folder is a durable artifact: even if a future session crashes mid-implementation, the next session can pick up from the tasks.md checklist with full context.
14. References
openspec/AGENTS.md— gate compliance checklistopenspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md— pattern reference for cross-cutting designsopenspec/changes/archive/2026-05-04-i18n-formatters/— pattern reference for proposal/tasks/spec-delta shapepackages/i18n/i18n/CLAUDE.md(root) — current monolith inventorypackages/primitives/types/src/index.ts— current@suaveplan/typessurface (Wave 1a extends this)