Files
suaveplan/openspec/specs/i18n-shared/spec.md
2026-05-27 23:29:53 +01:00

11 KiB

Capability Spec: i18n-shared (i18n-shared)

Package: @suaveplan/i18n-shared Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

Requirements

Requirement: BCP-47 locale tag map

The package SHALL export LOCALE_BCP47_MAP: Record<string, string> mapping short ISO 639-1 codes (and common mixed-case forms) to canonical BCP-47 tags. Lookups for unknown codes SHALL pass through unchanged at the consumer level (the constant is a plain Record; consumers handle the miss).

Scenario: short code maps to canonical BCP-47

  • WHEN LOCALE_BCP47_MAP["en"] is looked up
  • THEN the value equals "en-GB"

Scenario: mixed-case code maps to canonical form

  • WHEN LOCALE_BCP47_MAP["zh-cn"] is looked up
  • THEN the value equals "zh-CN"

Verified by: packages/i18n/i18n-shared/src/constants/locale-bcp47-map.test.ts::"short code en maps to en-GB" AND packages/i18n/i18n-shared/src/constants/locale-bcp47-map.test.ts::"mixed-case zh-cn maps to zh-CN"

Requirement: RTL locale set and tuple

The package SHALL export RTL_LOCALES: ReadonlySet<string> containing the seven ISO 639-1 codes for right-to-left scripts: ar, he, fa, ur, ps, sd, yi. The package SHALL also export RTL_LOCALE_LIST as a tree-shake-friendly readonly tuple of the same codes.

Scenario: ar is RTL

  • WHEN RTL_LOCALES.has("ar") is evaluated
  • THEN the result is true

Scenario: en is not RTL

  • WHEN RTL_LOCALES.has("en") is evaluated
  • THEN the result is false

Scenario: list and set agree on size

  • WHEN RTL_LOCALE_LIST.length is compared to RTL_LOCALES.size
  • THEN the values are equal

Verified by: packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"ar is RTL" AND packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"en is not RTL" AND packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts::"RTL_LOCALE_LIST length matches RTL_LOCALES size"

Requirement: Locale descriptors with name, native name, and direction

The package SHALL export LOCALE_DESCRIPTORS as a Record<string, { name: string; nativeName: string; direction: "ltr" | "rtl" }> covering every entry in DEFAULT_LOCALES. Each descriptor's direction SHALL match the locale's membership in RTL_LOCALES.

Scenario: every default locale has a descriptor

  • WHEN every code in DEFAULT_LOCALES is looked up in LOCALE_DESCRIPTORS
  • THEN every lookup returns a defined descriptor

Scenario: Arabic descriptor is RTL

  • WHEN LOCALE_DESCRIPTORS.ar.direction is read
  • THEN the value equals "rtl"

Scenario: native names are non-empty

  • WHEN every descriptor's nativeName is checked
  • THEN every value is a non-empty string

Verified by: packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"every DEFAULT_LOCALES code has a descriptor" AND packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"ar descriptor direction is rtl" AND packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts::"every descriptor nativeName is non-empty"

Requirement: Default interpolation defaults

The package SHALL export DEFAULT_INTERPOLATION as a frozen object with prefix: "{{", suffix: "}}", and maxDepth: 10. The shape SHALL be as const so consumers can narrow at the type level.

Scenario: default prefix and suffix

  • WHEN DEFAULT_INTERPOLATION.prefix and DEFAULT_INTERPOLATION.suffix are read
  • THEN the values equal "{{" and "}}" respectively

Scenario: default max depth

  • WHEN DEFAULT_INTERPOLATION.maxDepth is read
  • THEN the value equals 10

Scenario: type is narrowed via as const

  • WHEN expectTypeOf<typeof DEFAULT_INTERPOLATION.prefix>().toEqualTypeOf<"{{">() runs
  • THEN the type-level check passes

Verified by: packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default prefix is {{" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default suffix is }}" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"default maxDepth is 10" AND packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts::"DEFAULT_INTERPOLATION is narrowed via as const"

Requirement: I18N error code registry

The package SHALL export I18N_CODES constructed via defineCodes("I18N", [...] as const) from @suaveplan/error, containing the following 18 codes:

  • Parser / interpolation / pluralization: MISSING_KEY, INVALID_LOCALE, INVALID_OPTIONS, LOADER_FAILED, INTERPOLATION_ERROR, PLURAL_RULE_NOT_FOUND, NESTING_DEPTH_EXCEEDED
  • Backends: BACKEND_READ_FAILED, BACKEND_WRITE_FAILED, BACKEND_NOT_REGISTERED, BACKEND_LOCALE_NOT_AVAILABLE, BACKEND_TIMEOUT, CHAINED_BACKEND_ALL_FAILED
  • Formatters: INVALID_FORMATTER_OPTIONS, UNSUPPORTED_LOCALE, FORMATTER_CACHE_OVERFLOW
  • Detector: DETECTOR_CHAIN_EXHAUSTED
  • Plugin / core: PLUGIN_ALREADY_INSTALLED

Every code SHALL be addressable as I18N_CODES.<NAME> and SHALL produce a string literal of the form "I18N.<NAME>". The package SHALL also export I18nErrorCode as the discriminant union of all 18 string literals.

Scenario: every documented code is in the registry

  • WHEN every name listed in this requirement is read from I18N_CODES
  • THEN every read returns a defined value

Scenario: code values follow the I18N. prefix

  • WHEN I18N_CODES.MISSING_KEY is read
  • THEN the value equals "I18N.MISSING_KEY"

Scenario: I18nErrorCode union has cardinality 18

  • WHEN the type-level cardinality of I18nErrorCode is asserted
  • THEN the union covers exactly 18 string literals

Verified by: packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"every documented code is in I18N_CODES" AND packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"code values are namespaced under I18N." AND packages/i18n/i18n-shared/src/errors/error-codes.test.ts::"I18nErrorCode union has cardinality 18"

Requirement: I18nError class

The package SHALL export I18nError extends SuaveplanError<I18nErrorCode> from @suaveplan/error. The class SHALL be constructible from any code in I18N_CODES and SHALL satisfy instanceof I18nError and instanceof SuaveplanError. The class SHALL preserve the cause chain when constructed with { cause }. The class SHALL NOT carry any biome / linter suppression comments per CLAUDE.md §9.

Scenario: construction with each code

  • WHEN new I18nError(code, "test") is called for every code in I18N_CODES
  • THEN every construction succeeds

Scenario: instanceof discrimination

  • WHEN an I18nError is caught
  • THEN instanceof I18nError returns true AND instanceof SuaveplanError returns true

Scenario: cause preservation

  • WHEN new I18nError(I18N_CODES.LOADER_FAILED, "k", { cause: new Error("network") }) is constructed
  • THEN the resulting error's .cause.message equals "network"

Scenario: no biome suppressions in source

  • WHEN the file src/errors/i18n-error.ts is grepped for biome-ignore
  • THEN zero matches are found

Verified by: packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"constructs with every code in I18N_CODES" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"instanceof I18nError and SuaveplanError" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"preserves cause chain" AND packages/i18n/i18n-shared/src/errors/i18n-error.test.ts::"source contains no biome-ignore suppressions"

Requirement: Subpath exports for tree-shaking

The package SHALL declare subpath exports in package.json so consumers can import individual constants without pulling the entire bundle. The following subpaths SHALL resolve:

  • @suaveplan/i18n-shared — root barrel
  • @suaveplan/i18n-shared/constants — all constants
  • @suaveplan/i18n-shared/constants/locale-bcp47-map — LOCALE_BCP47_MAP only
  • @suaveplan/i18n-shared/constants/rtl-locales — RTL_LOCALES and RTL_LOCALE_LIST only
  • @suaveplan/i18n-shared/constants/locale-descriptors — LOCALE_DESCRIPTORS, DEFAULT_LOCALES, DefaultLocale only
  • @suaveplan/i18n-shared/constants/interpolation-defaults — DEFAULT_INTERPOLATION only
  • @suaveplan/i18n-shared/errors — I18N_CODES, I18nError, I18nErrorCode
  • @suaveplan/i18n-shared/testing — runBackendConformance, ConformanceContext, ConformanceFactory

Scenario: every subpath resolves at type-check time

  • WHEN every documented subpath is imported in a consumer test fixture
  • THEN every import resolves with no Module not found errors

Verified by: packages/i18n/i18n-shared/src/index.test.ts::"every documented subpath export resolves"

Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json so bundlers can tree-shake unused exports.

Scenario: package.json declares sideEffects false

  • WHEN package.json is parsed
  • THEN the sideEffects field equals false

Verified by: packages/i18n/i18n-shared/src/byte-size.test.ts::"package.json declares sideEffects false"

Requirement: Backend conformance harness

The package SHALL expose a runBackendConformance(label, makeFactory) function under the @suaveplan/i18n-shared/testing subpath. The harness SHALL exercise the universal Backend interface from @suaveplan/types (name + capabilities surface, read-of-absent → I18nError("I18N.BACKEND_LOCALE_NOT_AVAILABLE"), optional readMulti merge, optional save round-trip, optional lastModified monotonicity) and SHALL gate each capability-bearing assertion on both the structural presence of the optional method AND the matching capabilities.canX flag, so backends with partial implementations and read-only backends both pass without modification. The harness SHALL import describe / it / expect exclusively from @suaveplan/testing/runner so it executes identically under Vitest and Bun's test runner.

Scenario: harness exports a callable runner

  • WHEN runBackendConformance is imported from @suaveplan/i18n-shared/testing
  • THEN the value is a function with arity 2
  • GIVEN an in-memory backend implementing every capability
  • WHEN runBackendConformance("MockFullBackend", factory) is called
  • THEN every assertion in the suite passes

Scenario: harness gates capabilities for read-only backends

  • GIVEN a backend with canReadMulti/canSave/canTrackModification all false
  • WHEN the harness runs
  • THEN the read-multi / save / last-modified assertions early-return without failing

Verified by: packages/i18n/i18n-shared/src/testing/conformance.test.ts::"is a callable function" AND the full Backend conformance: MockFullBackend block AND the full Backend conformance: MockReadOnlyBackend block AND the full Backend conformance: MockCapabilityMismatchBackend block