Initial Commit
This commit is contained in:
198
openspec/specs/i18n-shared/spec.md
Normal file
198
openspec/specs/i18n-shared/spec.md
Normal file
@@ -0,0 +1,198 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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
|
||||
|
||||
#### Scenario: harness covers a full-featured backend
|
||||
|
||||
- **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
|
||||
Reference in New Issue
Block a user