Files
suaveplan/openspec/changes/archive/2026-05-04-i18n-spec-conformance/proposal.md
2026-05-27 23:29:53 +01:00

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 nested TranslationRecord tree 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.
  • PluralizationEngine falls back to Intl.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

  • MessageCatalog accepts strict?: boolean (default false); when on, missing keys throw I18nError(I18N.MISSING_KEY).
  • MessageCatalog.load(locale, namespace?, loader) now wraps loader rejections as I18nError(I18N.LOADER_FAILED) with the original error in cause.
  • MessageCatalog.load deduplicates concurrent calls for the same (locale, namespace) pair via an in-flight Map<string, Promise<void>>.
  • MessageCatalog.set(locale, namespace, record) and MessageCatalog.keys(locale, namespace?) accept a namespace argument.
  • LookupResult becomes { value: string | undefined; locale: TLocale; namespace: string; source: 'catalog' | 'fallback' | 'handler' | 'key' }.
  • InterpolationEngine throws I18nError(I18N.INTERPOLATION_ERROR) on unclosed delimiter and rethrows formatter exceptions with the same code wrapping the cause.
  • PluralizationEngine accepts strict?: boolean; when on, throws I18nError(I18N.PLURAL_RULE_NOT_FOUND) after rule + base-locale + Intl.PluralRules fallback all miss.
  • normalizeLocaleCode(code) validates a BCP-47-shaped tag (/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/) and throws I18nError(I18N.INVALID_LOCALE) otherwise.

Schema realignment

  • InterpolationConfigSchema → { open, close, escape, maxIterations } (renamed from prefix/suffix/escapeHtml/maxDepth). InterpolationEngine constructor accepts both shapes for one minor cycle, with the new shape canonical.
  • ResolverConfigSchema → { strategies: ResolutionStrategy[]; fallbackLocale: string; supportedLocales?: string[] }.
  • CatalogOptionsSchema adds strict?: boolean.
  • All three engines call .parse(options) in their constructor; failures throw I18nError with the relevant code and the Zod issue list in context.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: LookupResult shape, InterpolationEngine option keys, MessageCatalog.load signature, MessageCatalog.addMessages renamed to set (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.