89 lines
6.3 KiB
Markdown
89 lines
6.3 KiB
Markdown
# 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.
|