6.3 KiB
OpenSpec Change: i18n-spec-conformance
- Change ID: i18n-spec-conformance
- Package:
@suaveplan/i18n - Status: PROPOSED
- Date: 2026-04-26
- Reference:
openspec/archive/2026-04-19-enterprise-i18n/specs/i18n/spec.md
Summary
Close the conformance gaps between the shipped @suaveplan/i18n package and its archived enterprise spec, and add type-level guarantees that make the package future-proof for consumers. The original change archived as "all tasks retroactively ticked" but several spec contracts are unimplemented or drifted: OTel instrumentation is absent, MessageCatalog lacks strict mode / loader dedup / namespace-aware set/load/keys / LookupResult.source, schemas don't match spec field names and aren't enforced at construction, InterpolationEngine never throws I18N.INTERPOLATION_ERROR, PluralizationEngine lacks strict mode and a CLDR-backed fallback, and LocaleResolver.normalizeLocaleCode accepts any string instead of validating BCP-47.
This change brings the package fully in line with the original spec, adds three type-safety upgrades (typed key inference via DotPaths<TMessages>, generic LookupResult<TLocale>, Intl.PluralRules fallback so new CLDR languages work without a code change), and re-establishes the 100/100/100/100 coverage gate.
Current-state audit
| Spec ID | Requirement | Status |
|---|---|---|
| I18N-01 | I18nError + 5 codes, side-effect register |
✅ codes registered; ⚠️ no side-effect import in src/index.ts |
| I18N-02 | Zod schemas match spec shape, parse at boundary | ❌ field names drifted; ❌ never called at construction |
| I18N-03 | MessageCatalog contract (source, namespace, strict, dedup, ns-aware set/load/keys) |
❌ all missing |
| I18N-04 | InterpolationEngine throws on unclosed delim / formatter error |
❌ never throws |
| I18N-05 | PluralizationEngine strict-mode throw |
❌ missing |
| I18N-06 | MessageParser AST + extractVariables + renderNodes |
✅ shipped |
| I18N-07 | Pluggable strategy chain, ResolvedLocale.direction, INVALID_LOCALE throw on garbage input |
⚠️ chain lives in middleware/server (acceptable rename); ❌ normalizeLocaleCode never throws |
| I18N-08 | Middleware attaches ResolvedLocale, sets Content-Language |
⚠️ attaches result; missing Content-Language header path |
| I18N-09 | Date formatting | ✅ shipped |
| I18N-10 | Currency formatting | ✅ shipped |
| I18N-11 | Utility functions | ✅ shipped |
| I18N-12 | OTel meter + counters + histogram | ❌ entirely missing |
| I18N-13 | Property tests | ⚠️ inline in unit tests instead of tests/properties/ |
| I18N-14 | Per-component benchmarks | ⚠️ bundled into single file |
| I18N-15 | Hygiene (sideEffects, 100% coverage, no .skip/.only) | ✅ shipped |
New additions for this change
Type-level guarantees (future-proofing)
DotPaths<TMessages>template-literal type that recursively projects a nestedTranslationRecordtree to its'a.b.c'leaf paths.MessageCatalog<TLocale, TMessages>.t<K extends DotPaths<TMessages> | (string & {})>(key: K)— autocomplete + compile-time key safety, with a string escape hatch.LookupResult<TLocale extends string = string>carries the typed locale through.PluralizationEnginefalls back toIntl.PluralRules(locale).select(n)after locale-specific rule + base-language rule miss but before strict-mode throw, so CLDR drift no longer requires code changes for newly added languages.
OTel instrumentation
src/observability/metrics.ts exporting an i18nMetrics singleton built via getMeter('suaveplan.i18n'):
- Counter
i18n.translate.total— labels:outcome('hit' | 'miss'),locale - Counter
i18n.locale.resolve.total— labels:strategy,locale - Histogram
i18n.translate.duration_ms— label:locale
Wired into MessageCatalog.get and detectLocale (locale middleware).
Strict mode + structured throws
MessageCatalogacceptsstrict?: boolean(defaultfalse); when on, missing keys throwI18nError(I18N.MISSING_KEY).MessageCatalog.load(locale, namespace?, loader)now wraps loader rejections asI18nError(I18N.LOADER_FAILED)with the original error incause.MessageCatalog.loaddeduplicates concurrent calls for the same(locale, namespace)pair via an in-flightMap<string, Promise<void>>.MessageCatalog.set(locale, namespace, record)andMessageCatalog.keys(locale, namespace?)accept a namespace argument.LookupResultbecomes{ value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }.InterpolationEnginethrowsI18nError(I18N.INTERPOLATION_ERROR)on unclosed delimiter and rethrows formatter exceptions with the same code wrapping the cause.PluralizationEngineacceptsstrict?: boolean; when on, throwsI18nError(I18N.PLURAL_RULE_NOT_FOUND)after rule + base-locale +Intl.PluralRulesfallback all miss.normalizeLocaleCode(code)validates a BCP-47-shaped tag (/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/) and throwsI18nError(I18N.INVALID_LOCALE)otherwise.
Schema realignment
InterpolationConfigSchema→{ open, close, escape, maxIterations }(renamed fromprefix/suffix/escapeHtml/maxDepth).InterpolationEngineconstructor accepts both shapes for one minor cycle, with the new shape canonical.ResolverConfigSchema→{ strategies: ResolutionStrategy[]; fallbackLocale: string; supportedLocales?: string[] }.CatalogOptionsSchemaaddsstrict?: boolean.- All three engines call
.parse(options)in their constructor; failures throwI18nErrorwith the relevant code and the Zod issue list incontext.zodIssues.
Side-effect code registration
src/index.ts adds a top-level import './error-codes.js'; so consumers that import schemas only still trigger code registration.
Available workspace deps
| Package | Available |
|---|---|
@suaveplan/error |
✅ |
@suaveplan/types |
✅ |
@opentelemetry/api (catalog: observability) |
✅ |
Impact
- Breaking:
LookupResultshape,InterpolationEngineoption keys,MessageCatalog.loadsignature,MessageCatalog.addMessagesrenamed toset(alias retained for one cycle). - Version stays at
0.1.0(genesis pre-1.0). - No external consumers on genesis yet; safe to land as-is.