Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-number (i18n-formatter-number)
**Package**: `@suaveplan/i18n-formatter-number`
**Status**: ACTIVE
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## Requirements
### Requirement: NumberFormatter class wrapping `Intl.NumberFormat`
The package SHALL export a `NumberFormatter` class that wraps `Intl.NumberFormat` with construction-time Zod validation of options and a `format(value: number, locale: string): string` method. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`.
#### Scenario: format with default options
- **GIVEN** `const f = new NumberFormatter()`
- **WHEN** `f.format(1234.5, "en-GB")` is called
- **THEN** the result equals `"1,234.5"`
#### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS
- **WHEN** `new NumberFormatter({ style: "invalid" as never })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
**Verified by:** `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"NumberFormatter formats with default options"` AND `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"NumberFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"`
### Requirement: `formatNumber` standalone helper
The package SHALL export a `formatNumber(value, locale, options?)` standalone helper that delivers the same behaviour as `NumberFormatter.format` without requiring class construction. The helper SHALL validate options with the same Zod schema and SHALL throw the same error codes.
#### Scenario: standalone helper formats with options
- **WHEN** `formatNumber(0.5, "en-GB", { style: "percent" })` is called
- **THEN** the result equals `"50%"`
#### Scenario: standalone helper twins class output
- **WHEN** both `formatNumber(v, l, opts)` and `new NumberFormatter(opts).format(v, l)` are called for the same `(v, l, opts)`
- **THEN** the results are identical
**Verified by:** `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"formatNumber formats percent style"` AND `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"formatNumber output twins NumberFormatter.format output"`
### Requirement: `Intl.NumberFormat` `RangeError` maps to `UNSUPPORTED_LOCALE`
The package SHALL catch every `RangeError` thrown by the `Intl.NumberFormat` constructor (caused by an unsupported locale or unsupported option value) 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** `formatNumber(1, "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-number/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"`
### Requirement: Per-(locale, options-hash) cache
The package SHALL maintain an internal `Map<string, Intl.NumberFormat>` keyed by `${locale}|${JSON.stringify(options)}`. Repeated calls with identical `(locale, options)` SHALL reuse the same `Intl.NumberFormat` instance. The package SHALL export a `clearNumberFormatterCache()` test-only helper that empties the cache.
#### Scenario: identical options reuse the same Intl.NumberFormat
- **GIVEN** the cache is empty
- **WHEN** `formatNumber(1, "en-GB", { style: "decimal" })` is called twice in sequence
- **THEN** only one `Intl.NumberFormat` constructor invocation occurs (verified via spy on the global)
#### Scenario: distinct options create distinct cache entries
- **WHEN** `formatNumber(1, "en-GB", { style: "decimal" })` and `formatNumber(1, "en-GB", { style: "percent" })` are called
- **THEN** two distinct `Intl.NumberFormat` instances are created and cached
#### Scenario: clearNumberFormatterCache resets state
- **GIVEN** the cache holds at least one entry
- **WHEN** `clearNumberFormatterCache()` is called
- **THEN** the next `formatNumber` call constructs a fresh `Intl.NumberFormat`
**Verified by:** `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"identical options reuse cached Intl.NumberFormat"` AND `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"distinct options create distinct cache entries"` AND `packages/i18n/i18n-formatter-number/src/formatter.test.ts::"clearNumberFormatterCache resets cache state"`
### Requirement: OTel histogram `i18n.format.number.duration_ms`
The package SHALL emit an OTel histogram named exactly `i18n.format.number.duration_ms` (unit `ms`) on every successful `formatNumber` call. The histogram SHALL be tagged with `locale` and `style`.
#### Scenario: histogram emitted on every call
- **GIVEN** an OTel test exporter capturing histogram observations
- **WHEN** `formatNumber(1, "en-GB", { style: "decimal" })` is called
- **THEN** exactly one observation appears under `i18n.format.number.duration_ms` with `locale: "en-GB"` and `style: "decimal"` attributes
**Verified by:** `packages/i18n/i18n-formatter-number/src/observability.test.ts::"emits i18n.format.number.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: "number"` on every cache hit (i.e. on the second and subsequent calls with identical `(locale, options)`). 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** `formatNumber(1, "en-GB", { style: "decimal" })` is called twice in sequence
- **THEN** the counter `i18n.format.cache_hit{formatter="number"}` shows exactly one increment
**Verified by:** `packages/i18n/i18n-formatter-number/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
### Requirement: Ergonomic helpers — percent, compact, unit
The package SHALL export `formatPercent`, `formatCompact`, and `formatUnit` ergonomic shortcuts for the corresponding `Intl.NumberFormat` configurations. Each helper SHALL be a thin pre-binding of `formatNumber` (or, for `formatUnit`, a direct `Intl.NumberFormat` call because `style: "unit"` falls outside the typed `NumberFormatterStyle`).
#### Scenario: formatPercent multiplies by 100 per Intl semantics
- **WHEN** `formatPercent(0.5, "en-GB")` is called
- **THEN** the result equals `"50%"`
#### Scenario: formatCompact produces compact-notation output
- **WHEN** `formatCompact(1500, "en-US")` is called
- **THEN** the result equals `"1.5K"`
#### Scenario: formatUnit accepts unit identifiers
- **WHEN** `formatUnit(50, "kilometer-per-hour", "en-GB")` is called
- **THEN** the result contains the substring `"50"` AND the substring `"km/h"` (or locale-equivalent rendering)
**Verified by:** `packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatPercent renders percent"` AND `packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatCompact renders 1.5K for 1500"` AND `packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatUnit renders unit identifier"`
### Requirement: Subpath exports
The package SHALL declare subpath exports in `package.json` so consumers can import only the surfaces they need:
- `@suaveplan/i18n-formatter-number` — root barrel
- `@suaveplan/i18n-formatter-number/formatter` — `NumberFormatter` class + `formatNumber` + types + schema only
- `@suaveplan/i18n-formatter-number/helpers` — `formatPercent`, `formatCompact`, `formatUnit` 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-number/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-formatter-number/src/index.test.ts::"package.json declares sideEffects false"`