Files
2026-05-27 23:29:53 +01:00

138 lines
7.1 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-list (i18n-formatter-list)
**Package**: `@suaveplan/i18n-formatter-list`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## Requirements
### Requirement: ListFormatter class wrapping `Intl.ListFormat`
The package SHALL export a `ListFormatter` class that wraps `Intl.ListFormat` with construction-time Zod validation of options and a `format(values: readonly string[], locale: string): string` method. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`.
#### Scenario: format with default options (long conjunction)
- **GIVEN** `const f = new ListFormatter()`
- **WHEN** `f.format(["a", "b", "c"], "en-GB")` is called
- **THEN** the result equals `"a, b, and c"`
#### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS
- **WHEN** `new ListFormatter({ type: "invalid" as never })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
**Verified by:** `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"ListFormatter formats with default options"` AND `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"ListFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"`
### Requirement: `formatList` standalone helper
The package SHALL export a `formatList(values, locale, options?)` standalone helper that delivers the same behaviour as `ListFormatter.format` without requiring class construction. Defaults: `style: "long"`, `type: "conjunction"`.
#### Scenario: standalone helper formats with disjunction type
- **WHEN** `formatList(["x", "y"], "en-GB", { type: "disjunction" })` is called
- **THEN** the result equals `"x or y"`
#### Scenario: standalone helper twins class output
- **WHEN** both `formatList(v, l, opts)` and `new ListFormatter(opts).format(v, l)` are called for the same `(v, l, opts)`
- **THEN** the results are identical
**Verified by:** `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"formatList formats disjunction"` AND `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"formatList output twins ListFormatter.format output"`
### Requirement: `Intl.ListFormat` `RangeError` maps to `UNSUPPORTED_LOCALE`
The package SHALL catch every `RangeError` thrown by the `Intl.ListFormat` constructor and re-throw it as `I18nError(I18N.UNSUPPORTED_LOCALE)` with the original error attached as `cause` and the locale string in `context`.
#### Scenario: bogus locale throws UNSUPPORTED_LOCALE
- **WHEN** `formatList(["a"], "xx-bogus-tag-!!!")` is called
- **THEN** the call throws `I18nError` with code `"I18N.UNSUPPORTED_LOCALE"` AND the error's `cause` is the original `RangeError`
**Verified by:** `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"`
### Requirement: Per-(locale, style, type) cache
The package SHALL maintain an internal `Map<string, Intl.ListFormat>` keyed by `${locale}|${style}|${type}`. Repeated calls with identical `(locale, style, type)` SHALL reuse the same `Intl.ListFormat` instance. The package SHALL export a `clearListFormatterCache()` test-only helper.
#### Scenario: identical key reuses the same Intl.ListFormat
- **GIVEN** the cache is empty
- **WHEN** `formatList(["a"], "en-GB")` is called twice in sequence
- **THEN** only one `Intl.ListFormat` constructor invocation occurs
#### Scenario: distinct type creates distinct cache entries
- **WHEN** `formatList(["a"], "en-GB", { type: "conjunction" })` and `formatList(["a"], "en-GB", { type: "disjunction" })` are called
- **THEN** two distinct `Intl.ListFormat` instances are created and cached
**Verified by:** `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"identical key reuses cached Intl.ListFormat"` AND `packages/i18n/i18n-formatter-list/src/formatter.test.ts::"distinct type creates distinct cache entries"`
### Requirement: OTel histogram `i18n.format.list.duration_ms`
The package SHALL emit an OTel histogram named exactly `i18n.format.list.duration_ms` (unit `ms`) on every successful `formatList` call, tagged with `locale`, `style`, and `type`.
#### Scenario: histogram emitted on every call
- **GIVEN** an OTel test exporter capturing histogram observations
- **WHEN** `formatList(["a", "b"], "en-GB", { style: "long", type: "conjunction" })` is called
- **THEN** exactly one observation appears under `i18n.format.list.duration_ms` with `locale`, `style`, and `type` attributes
**Verified by:** `packages/i18n/i18n-formatter-list/src/observability.test.ts::"emits i18n.format.list.duration_ms histogram on every call"`
### Requirement: OTel cache-hit counter `i18n.format.cache_hit`
The package SHALL emit an OTel counter `i18n.format.cache_hit` tagged with `formatter: "list"` on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call.
#### Scenario: cache-hit counter increments only on second call
- **GIVEN** an OTel test exporter capturing counter observations and an empty cache
- **WHEN** `formatList(["a"], "en-GB")` is called twice in sequence
- **THEN** the counter `i18n.format.cache_hit{formatter="list"}` shows exactly one increment
**Verified by:** `packages/i18n/i18n-formatter-list/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
### Requirement: Ergonomic helpers — `formatConjunction`, `formatDisjunction`
The package SHALL export `formatConjunction(values, locale, style?)` and `formatDisjunction(values, locale, style?)` ergonomic shortcuts pre-binding `formatList` with the corresponding `type`.
#### Scenario: formatConjunction renders comma + and
- **WHEN** `formatConjunction(["a", "b", "c"], "en-GB")` is called
- **THEN** the result equals `"a, b, and c"`
#### Scenario: formatDisjunction renders or
- **WHEN** `formatDisjunction(["x", "y"], "en-GB")` is called
- **THEN** the result equals `"x or y"`
**Verified by:** `packages/i18n/i18n-formatter-list/src/helpers.test.ts::"formatConjunction renders a, b, and c"` AND `packages/i18n/i18n-formatter-list/src/helpers.test.ts::"formatDisjunction renders x or y"`
### Requirement: Subpath exports
The package SHALL declare subpath exports in `package.json` so consumers can import only the surfaces they need:
- `@suaveplan/i18n-formatter-list` — root barrel
- `@suaveplan/i18n-formatter-list/formatter` — `ListFormatter` class + `formatList` + types + schema only
- `@suaveplan/i18n-formatter-list/helpers` — `formatConjunction`, `formatDisjunction` only
#### Scenario: every documented subpath resolves
- **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-formatter-list/src/index.test.ts::"every documented subpath export resolves"`
### Requirement: Side-effect-free package
The package SHALL declare `"sideEffects": false` in `package.json`.
#### Scenario: package.json declares sideEffects false
- **WHEN** `package.json` is parsed
- **THEN** the `sideEffects` field equals `false`
**Verified by:** `packages/i18n/i18n-formatter-list/src/index.test.ts::"package.json declares sideEffects false"`