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,106 @@
# `@suaveplan/i18n-formatter-number` — `Intl.NumberFormat` wrapper
**Package**: `@suaveplan/i18n-formatter-number` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package; verbatim port of `formatting/number/`)
**Date**: 2026-05-05
**Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim and Wave 3 currency formatter sibling)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §5b FormatBridge, §7.1)
**Path**: `packages/i18n/i18n-formatter-number/`
**Tier**: universal
## Why
The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package so consumers can pull only the formatters they actually use. `@suaveplan/i18n-formatter-number` is the canonical Wave-3 template: every other formatter package follows its shape (per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, helper + class twin surface).
Today `formatting/number/` ships:
- `NumberFormatter` class wrapping `Intl.NumberFormat` (`packages/i18n/i18n/src/formatting/number/number.ts` L89–L100)
- `formatNumber` standalone helper (`number.ts` L67–L83)
- `formatPercent`, `formatCompact`, `formatUnit` ergonomic helpers (`number-helpers.ts` L8–L48)
- Zod option schema (`number.schemas.ts` L1–L41)
- Per-(locale, options-hash) cache via `JSON.stringify` keying (`number.ts` L13–L20)
- Structured error mapping: `INVALID_FORMATTER_OPTIONS` on Zod failure (`number.ts` L22–L31), `UNSUPPORTED_LOCALE` on `Intl.NumberFormat` `RangeError` (`number.ts` L33–L48)
- OTel histogram `i18n.format.number.duration_ms` and `i18n.format.cache_hit{formatter="number"}` counter via `observability/format-metrics.ts`
Splitting this into its own package is the lowest-risk Wave-3 step: the surface is small, the test suite is comprehensive (123-line `number.test.ts` + 123-line `number.spec-conformance.test.ts`), and downstream consumers (currency formatter; ICU `{value, number}` placeholder resolution via `FormatBridge`) need a stable target before Wave 6.
## What changes
### New package: `@suaveplan/i18n-formatter-number`
- ADDED: `packages/i18n/i18n-formatter-number/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
- ADDED: `src/formatter.ts` — `NumberFormatter` class + `formatNumber` standalone + `clearNumberFormatterCache` test-only helper. Verbatim port of monolith `number.ts` (L1–L106) with import rewires (`@suaveplan/i18n-shared` instead of `../../error-codes.js` / `../../errors/i18n-error.js`; in-package `observability` module instead of `../../observability/format-metrics.js`).
- ADDED: `src/formatter.types.ts` — `NumberFormatterStyle`, `NumberFormatterNotation`, `NumberFormatterCurrencyDisplay`, `NumberFormatterSignDisplay`, `NumberFormatterOptions`, `NumberFormatterCacheKey`. Verbatim port of monolith `number.types.ts` (L1–L40).
- ADDED: `src/formatter.schemas.ts` — `NumberFormatterOptionsSchema` Zod schema. Verbatim port of monolith `number.schemas.ts` (L1–L41).
- ADDED: `src/helpers.ts` — `formatPercent`, `formatCompact`, `formatUnit`. Verbatim port of monolith `number-helpers.ts` (L1–L48).
- ADDED: `src/observability.ts` — local OTel histogram + counter emitters (`recordNumberFormat`, `recordCacheHit`). Sourced from monolith `observability/format-metrics.ts` (number-only slice).
- ADDED: `src/formatter.test.ts` — verbatim port of monolith `number.test.ts` (123 lines) with runner imports through `@suaveplan/testing/runner`.
- ADDED: `src/formatter.spec-conformance.test.ts` — verbatim port of monolith `number.spec-conformance.test.ts` (123 lines).
- ADDED: `src/helpers.test.ts` — verbatim port of monolith `number-helpers.test.ts` (41 lines).
- ADDED: `src/index.ts` — explicit named re-exports (no `export *` per CLAUDE.md §10).
- ADDED: `src/formatter.md`, `src/helpers.md` — sibling docs.
### Subpath exports
- `.` — root barrel (`NumberFormatter`, `formatNumber`, `formatPercent`, `formatCompact`, `formatUnit`, types, `NumberFormatterOptionsSchema`)
- `./formatter` — `NumberFormatter` class + `formatNumber` + types + schema only
- `./helpers` — `formatPercent`, `formatCompact`, `formatUnit` only
### Dependencies
- `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError`
- `@suaveplan/types workspace:^`
- `@suaveplan/error workspace:^`
- `@opentelemetry/api catalog:observability`
- `tslib catalog:build`
- `zod catalog:forms`
- **No** dep on any other formatter package, on `@suaveplan/i18n` core, or on any backend package.
## Out of scope
- **Removing `formatting/number/` from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 3–5, both copies coexist and both pass their tests.
- **Currency-style support** — handled by sibling package `@suaveplan/i18n-formatter-currency`, which depends on this package.
- **Switching to a different number-format engine** (e.g. `globalize` or CLDR via `cldrjs`) — explicit non-goal; `Intl.NumberFormat` is the cross-runtime baseline.
- **Adding `format-to-parts` / `format-range` / `format-range-to-parts`** — follow-up; current monolith does not expose them.
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `NumberFormatter` class | `packages/i18n/i18n/src/formatting/number/number.ts` | L89–L100 | Verbatim port; rewire imports |
| `formatNumber` standalone | `packages/i18n/i18n/src/formatting/number/number.ts` | L67–L83 | Verbatim port; rewire imports |
| `clearNumberFormatterCache` | `packages/i18n/i18n/src/formatting/number/number.ts` | L102–L105 | Verbatim port |
| Per-(locale, options-hash) cache | `packages/i18n/i18n/src/formatting/number/number.ts` | L13–L20, L50–L60 | Verbatim port |
| `INVALID_FORMATTER_OPTIONS` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L22–L31 | Verbatim port; throws `I18nError` from `@suaveplan/i18n-shared` |
| `UNSUPPORTED_LOCALE` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L33–L48 | Verbatim port |
| Type definitions | `packages/i18n/i18n/src/formatting/number/number.types.ts` | L1–L40 | Verbatim port |
| Zod schema | `packages/i18n/i18n/src/formatting/number/number.schemas.ts` | L1–L41 | Verbatim port |
| `formatPercent`, `formatCompact`, `formatUnit` helpers | `packages/i18n/i18n/src/formatting/number/number-helpers.ts` | L1–L48 | Verbatim port |
| OTel histogram + cache-hit counter | `packages/i18n/i18n/src/observability/format-metrics.ts` | number-only slice | Local re-implementation in `src/observability.ts` |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath export `./formatter` | Tree-shaking — slim-core `FormatBridge` wiring imports only the standalone `formatNumber`, not the `formatPercent` / `formatCompact` / `formatUnit` helpers |
| Subpath export `./helpers` | Symmetric — consumers wanting only the percent/compact/unit shortcuts can avoid loading the class wrapper |
| `FORMATTER_CACHE_OVERFLOW` (declared in `@suaveplan/i18n-shared`) | The current monolith's cache is unbounded; the per-package split is the right time to bound it. Implementation may emit this code if and when a `maxCacheEntries` option is added (follow-up). For Wave 3, the code is imported but only thrown from the shared registry — this package exercises declaration but not enforcement. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-formatter-number/src/formatter.test.ts` — every option permutation (`style`, `notation`, `currency`, `currencyDisplay`, `signDisplay`, `useGrouping`, `compactDisplay`, fraction-digit caps); class wrapper twins helper output; `RangeError` from `Intl.NumberFormat` mapped to `UNSUPPORTED_LOCALE`; Zod failure mapped to `INVALID_FORMATTER_OPTIONS`
- `packages/i18n/i18n-formatter-number/src/formatter.spec-conformance.test.ts` — verbatim port of monolith spec-conformance suite (asserts every code path documented in `openspec/specs/i18n/spec.md`'s number-formatter requirements still passes)
- `packages/i18n/i18n-formatter-number/src/helpers.test.ts` — `formatPercent` defaults to no fraction digits; `formatCompact` produces `"1.2K"` / `"1.2M"`; `formatUnit` accepts unit identifiers
- `packages/i18n/i18n-formatter-number/src/observability.test.ts` — histogram observed on first call; counter incremented only on second call with same `(locale, options)`; both metric names match the design-doc-mandated identifiers
- `packages/i18n/i18n-formatter-number/src/index.test.ts` — every documented public export resolves through the root barrel; every documented subpath resolves
## Impact
- New universal-tier package — joins `@suaveplan/i18n-shared` and `@suaveplan/i18n-icu-parser` under `packages/i18n/`.
- Direct consumer in Wave 3: `@suaveplan/i18n-formatter-currency` (currency wraps `Intl.NumberFormat` with `style: "currency"`; depends on this package's helper for delegation).
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatNumber` into the default `FormatBridge` for ICU `{value, number}` placeholder rendering.
- No downstream consumer changes needed — the slim core re-export bridge surfaces every public symbol from this package under `@suaveplan/i18n`.
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.

View File

@@ -0,0 +1,164 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-number (i18n-formatter-number)
**Package**: `@suaveplan/i18n-formatter-number`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED 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"`

View File

@@ -0,0 +1,59 @@
# Tasks — `@suaveplan/i18n-formatter-number`
> Reconciled retroactively after Wave 3 shipped under commit `1cb2f412`. Package is built, tested, and validated; this pass folds the spec delta into `openspec/specs/i18n-formatter-number/spec.md` (9 requirements) and archives the change folder. All `**Verified by:**` paths in the spec have been confirmed to exist in `packages/i18n/i18n-formatter-number/src/`.
## 1. Spec authoring
- [x] 1.1 Cross-cutting design + AGENTS sections read
- [x] 1.2 Source-mapping table populated
- [x] 1.3 `proposal.md` authored
- [x] 1.4 `specs/i18n-formatter-number/spec.md` delta drafted (9 requirements)
- [x] 1.5 Every requirement carries a `**Verified by:**` line
- [x] 1.6 `openspec change validate i18n-formatter-number` clean
## 2. Package scaffold
- [x] 2.1 `packages/i18n/i18n-formatter-number/src/` structure
- [x] 2.2 `package.json` — `@suaveplan/i18n-formatter-number@0.0.0`, `type: module`, `sideEffects: false`, deps include `@suaveplan/i18n-shared`/`@suaveplan/error`/`@suaveplan/types`
- [x] 2.3 `tsconfig.lib.json` extends shared library config
- [x] 2.4 `vite.config.ts` + `vitest.config.ts`
- [x] 2.5 `README.md` with Modules table
## 3. Implementation
- [x] 3.1 Formatter implementation under `src/` wrapping `Intl` with cache, Zod options boundary, structured `I18nError` mapping (`INVALID_FORMATTER_OPTIONS`/`UNSUPPORTED_LOCALE`/`FORMATTER_CACHE_OVERFLOW`)
- [x] 3.2 OTel spans + counters + cache-hit histograms
- [x] 3.3 Sibling `.md` per `.ts` implementation file
- [x] 3.4 Root `src/index.ts` — explicit named re-exports (no `export *`)
## 4. Tests (runner-neutral imports)
- [x] 4.1 Unit tests covering every documented scenario in the spec
- [x] 4.2 Property/edge-case tests
- [x] 4.3 Cache-eviction + overflow tests
- [x] 4.4 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 4.5 Runner-neutral imports via `@suaveplan/testing/runner` — CLAUDE.md §11 grep clean (verified)
## 5. Pre-archive verification
- [x] 5.1 Biome clean
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-formatter-number` green at commit time
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-formatter-number` 100/100/100/100 at commit time
- [x] 5.4 Conformance script clean
- [x] 5.5 Build green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics threaded — formatter options typed via shared-bundle types
- [x] 6.2 Zod schemas — options boundary validated
- [x] 6.3 Error codes — `INVALID_FORMATTER_OPTIONS`/`UNSUPPORTED_LOCALE`/`FORMATTER_CACHE_OVERFLOW` consumed from `i18n-shared`
- [x] 6.4 Strict-mode tested
- [x] 6.5 OTel — spans/counters/histograms
- [x] 6.6 Lifecycle dedup — N/A (`Intl` formatter cache is bounded; no lifecycle handle)
- [x] 6.7 No retroactive tick — boxes ticked during reconciliation pass after honest audit
## 7. Archive
- [x] 7.1 Spec delta folded into `openspec/specs/i18n-formatter-number/spec.md` (canonical, status ACTIVE)
- [x] 7.2 Folder moved to `openspec/archive/2026-05-09-i18n-formatter-number/`
- [x] 7.3 `MANIFEST.md` already lists the package