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,93 @@
# `@suaveplan/i18n-formatter-collator` — `Intl.Collator` wrapper (NEW formatter)
**Package**: `@suaveplan/i18n-formatter-collator` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package — brand-new addition; no source in current monolith)
**Date**: 2026-05-05
**Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §7.1 — closes the "two new formatters" gap from the original `i18n-formatters` change scope)
**Path**: `packages/i18n/i18n-formatter-collator/`
**Tier**: universal
## Why
The original `i18n-formatters` change folder (now archived under `openspec/archive/2026-05-04-i18n-formatters/`) listed `Collator` and `Segmenter` in its scope but the implementation never landed — only the four headline formatters (number, relative-time, list, date) shipped in the monolith. This change closes that gap as part of Wave 3 of the modular split.
`Intl.Collator` is the canonical cross-runtime API for locale-aware string comparison and sorting. Concrete uses across the monorepo:
- **`@suaveplan/dedup`** today uses Jaro-Winkler / Levenshtein for similarity, but locale-aware exact-prefix sorting (e.g. customer names sorted in the user's locale) belongs on top of `Intl.Collator`, not raw string compare.
- **`@suaveplan/react-data-table`** (Wave 3c-bound, downstream) needs locale-aware column sorting — `Intl.Collator` is the right primitive.
- **`@suaveplan/i18n` slim core** (Wave 6) wires every formatter into the default `FormatBridge`. Collator slots in for the future `{key, collator}` ICU placeholder and for sort-key generation in catalog-key indexes.
Building this formatter from scratch (rather than waiting for a downstream consumer to inline `new Intl.Collator(...)`) means the same five hard rules every other Wave-3 formatter ships under (Zod-validated options, per-(locale, options-hash) cache, OTel histogram + cache-hit counter, structured error mapping, side-effect-free) apply uniformly across the family.
## What changes
### New package: `@suaveplan/i18n-formatter-collator`
- ADDED: `packages/i18n/i18n-formatter-collator/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
- ADDED: `src/formatter.ts` — `CollatorFormatter` class wrapping `Intl.Collator` with construction-time Zod option validation; `compareStrings(a, b, locale, options?)` standalone (analogue of `formatNumber`); `formatCollation(values, locale, options?)` standalone returning the array sorted via the collator (analogue of `formatList` — the "format" verb in the family); `clearCollatorCache` test-only helper. Per-(locale, options-hash) cache key `${locale}|${JSON.stringify(options)}`.
- ADDED: `src/formatter.types.ts` — `CollatorUsage` (`"sort" | "search"`), `CollatorSensitivity` (`"base" | "accent" | "case" | "variant"`), `CollatorCaseFirst` (`"upper" | "lower" | "false"`), `CollatorNumeric` (boolean), `CollatorFormatterOptions`, `CollatorFormatterCacheKey`. Sourced from the ECMA-402 `Intl.CollatorOptions` surface.
- ADDED: `src/formatter.schemas.ts` — `CollatorFormatterOptionsSchema` Zod schema validating every option per ECMA-402 (`usage`, `sensitivity`, `ignorePunctuation`, `numeric`, `caseFirst`, `collation`).
- ADDED: `src/helpers.ts` — `compareCaseInsensitive(a, b, locale)` pre-binds `sensitivity: "accent"`; `compareNumericAware(a, b, locale)` pre-binds `numeric: true` (for natural sort `"file2" < "file10"`); `sortStrings(values, locale, options?)` returns the sorted array (immutable copy).
- ADDED: `src/observability.ts` — local OTel histogram `i18n.format.collator.duration_ms` + counter `i18n.format.cache_hit{formatter="collator"}`.
- ADDED: `src/formatter.test.ts` — covers default sort; case-insensitive; numeric-aware natural sort; usage `"search"`; class twins helper; Zod failure → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`.
- ADDED: `src/formatter.spec-conformance.test.ts` — every requirement scenario in this delta runs as an executable test.
- ADDED: `src/helpers.test.ts` — covers all three ergonomic helpers.
- ADDED: `src/index.ts` — explicit named re-exports.
- ADDED: `src/formatter.md`, `src/helpers.md`, `src/observability.md` — sibling docs.
### Subpath exports
- `.` — root barrel
- `./formatter` — `CollatorFormatter` class + `compareStrings` + `formatCollation` + types + schema only
- `./helpers` — `compareCaseInsensitive`, `compareNumericAware`, `sortStrings` 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
- **Collation index generation** (e.g. precomputed sort keys for database indexing) — out of scope; `Intl.Collator` does not expose `getSortKey()`.
- **Custom collation tables** (e.g. CLDR `tailoring` rules beyond what runtimes ship) — out of scope; matches `Intl.Collator` capabilities only.
- **`Intl.Collator.supportedLocalesOf` exposure** — out of scope for Wave 3; can be added as a follow-up if downstream consumers need negotiation.
- **Removing this from the monolith** — N/A (the monolith never shipped a collator).
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| All requirements | (no source in monolith) | — | Brand-new package; designed from scratch using `@suaveplan/i18n-formatter-number` (`packages/i18n/i18n/src/formatting/number/number.ts`) as the shape template |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `CollatorFormatter` class wrapping `Intl.Collator` | Brand-new — closes the original `i18n-formatters` change scope; required by Wave 6 default `FormatBridge` for future `{key, collator}` ICU placeholder support |
| `compareStrings`, `formatCollation` standalones | Brand-new — symmetric with `formatNumber` / `formatList`; `compareStrings` returns `-1`, `0`, or `1`; `formatCollation` returns the sorted array |
| Per-(locale, options-hash) cache | Mandatory per Wave-3 formatter shape — every other formatter in the family ships this; same JSON-key pattern as `NumberFormatter` |
| Zod schema, `INVALID_FORMATTER_OPTIONS`, `UNSUPPORTED_LOCALE` mappings | Mandatory per Wave-3 formatter shape |
| OTel histogram `i18n.format.collator.duration_ms` + cache-hit counter `i18n.format.cache_hit{formatter="collator"}` | Mandatory per Wave-3 formatter shape |
| Three ergonomic helpers (`compareCaseInsensitive`, `compareNumericAware`, `sortStrings`) | Symmetric with `formatPercent` / `formatCompact` / `formatUnit` for the number formatter; covers the highest-frequency real-world collation use cases |
## Verification approach
- `packages/i18n/i18n-formatter-collator/src/formatter.test.ts` — `compareStrings("a", "b", "en-GB")` returns `-1`; case-sensitive vs case-insensitive divergence; numeric-aware sort orders `"file2"` before `"file10"`; class wrapper twins helper output; Zod failure → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`; cache reuse on identical key
- `packages/i18n/i18n-formatter-collator/src/formatter.spec-conformance.test.ts` — every requirement scenario runs as an executable test
- `packages/i18n/i18n-formatter-collator/src/helpers.test.ts` — `compareCaseInsensitive("a", "A", "en-GB")` returns `0`; `compareNumericAware("file2", "file10", "en-GB")` returns `-1`; `sortStrings(["c", "a", "b"], "en-GB")` returns `["a", "b", "c"]` (immutable copy verified)
- `packages/i18n/i18n-formatter-collator/src/observability.test.ts` — histogram named `i18n.format.collator.duration_ms`; counter `i18n.format.cache_hit{formatter="collator"}` increments only on second call with same `(locale, options)`
- `packages/i18n/i18n-formatter-collator/src/index.test.ts` — every documented public export resolves; subpaths resolve
## Impact
- New universal-tier package — joins the rest of the i18n family.
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core re-exports `CollatorFormatter` and `formatCollation` under the back-compat root barrel (per design doc §8.1 — `formatCollation` is the listed re-export).
- Closes a gap from the original `i18n-formatters` change scope (now archived under `openspec/archive/2026-05-04-i18n-formatters/`).
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.

View File

@@ -0,0 +1,184 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-collator (i18n-formatter-collator)
**Package**: `@suaveplan/i18n-formatter-collator`
**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.)
>
> NEW formatter — no source in current monolith. Closes the gap from the original `i18n-formatters` change scope.
## ADDED Requirements
### Requirement: CollatorFormatter class wrapping `Intl.Collator`
The package SHALL export a `CollatorFormatter` class that wraps `Intl.Collator` with construction-time Zod validation of options and `compare(a: string, b: string, locale: string): -1 | 0 | 1` and `sort(values: readonly string[], locale: string): string[]` methods. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`.
#### Scenario: compare with default options
- **GIVEN** `const f = new CollatorFormatter()`
- **WHEN** `f.compare("a", "b", "en-GB")` is called
- **THEN** the result equals `-1`
#### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS
- **WHEN** `new CollatorFormatter({ usage: "invalid" as never })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
**Verified by:** `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"CollatorFormatter compares with default options"` AND `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"CollatorFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"`
---
### Requirement: `compareStrings` standalone helper
The package SHALL export a `compareStrings(a, b, locale, options?)` standalone helper that returns `-1`, `0`, or `1` per `Intl.Collator.compare` semantics.
#### Scenario: standalone compares with default options
- **WHEN** `compareStrings("apple", "banana", "en-GB")` is called
- **THEN** the result equals `-1`
#### Scenario: standalone twins class output
- **WHEN** both `compareStrings(a, b, l, opts)` and `new CollatorFormatter(opts).compare(a, b, l)` are called for the same `(a, b, l, opts)`
- **THEN** the results are identical
**Verified by:** `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"compareStrings compares apple and banana"` AND `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"compareStrings output twins CollatorFormatter.compare output"`
---
### Requirement: `formatCollation` standalone helper returns sorted array
The package SHALL export a `formatCollation(values, locale, options?)` standalone helper that returns a sorted (immutable copy) array per the configured collator. The input array SHALL NOT be mutated.
#### Scenario: formatCollation sorts ascending
- **WHEN** `formatCollation(["c", "a", "b"], "en-GB")` is called
- **THEN** the result equals `["a", "b", "c"]`
#### Scenario: formatCollation does not mutate the input
- **GIVEN** `const input = ["c", "a", "b"]`
- **WHEN** `formatCollation(input, "en-GB")` is called
- **THEN** `input` still equals `["c", "a", "b"]`
**Verified by:** `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"formatCollation sorts ascending"` AND `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"formatCollation does not mutate input array"`
---
### Requirement: `Intl.Collator` `RangeError` maps to `UNSUPPORTED_LOCALE`
The package SHALL catch every `RangeError` thrown by the `Intl.Collator` 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** `compareStrings("a", "b", "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-collator/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.Collator>` keyed by `${locale}|${JSON.stringify(options)}`. Repeated calls with identical `(locale, options)` SHALL reuse the same `Intl.Collator` instance. The package SHALL export a `clearCollatorCache()` test-only helper.
#### Scenario: identical options reuse the same Intl.Collator
- **GIVEN** the cache is empty
- **WHEN** `compareStrings("a", "b", "en-GB")` is called twice in sequence
- **THEN** only one `Intl.Collator` constructor invocation occurs
#### Scenario: distinct options create distinct cache entries
- **WHEN** `compareStrings("a", "b", "en-GB", { sensitivity: "base" })` and `compareStrings("a", "b", "en-GB", { sensitivity: "accent" })` are called
- **THEN** two distinct `Intl.Collator` instances are created and cached
**Verified by:** `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"identical options reuse cached Intl.Collator"` AND `packages/i18n/i18n-formatter-collator/src/formatter.test.ts::"distinct options create distinct cache entries"`
---
### Requirement: OTel histogram `i18n.format.collator.duration_ms`
The package SHALL emit an OTel histogram named exactly `i18n.format.collator.duration_ms` (unit `ms`) on every successful `compareStrings` or `formatCollation` call, tagged with `locale`, `usage`, and `sensitivity`.
#### Scenario: histogram emitted on every call
- **GIVEN** an OTel test exporter capturing histogram observations
- **WHEN** `compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" })` is called
- **THEN** exactly one observation appears under `i18n.format.collator.duration_ms` with `locale`, `usage`, and `sensitivity` attributes
**Verified by:** `packages/i18n/i18n-formatter-collator/src/observability.test.ts::"emits i18n.format.collator.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: "collator"` 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** `compareStrings("a", "b", "en-GB")` is called twice in sequence
- **THEN** the counter `i18n.format.cache_hit{formatter="collator"}` shows exactly one increment
**Verified by:** `packages/i18n/i18n-formatter-collator/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
---
### Requirement: Ergonomic helpers — case-insensitive, numeric-aware, sort
The package SHALL export three ergonomic helpers:
- `compareCaseInsensitive(a, b, locale)` — pre-binds `sensitivity: "accent"`
- `compareNumericAware(a, b, locale)` — pre-binds `numeric: true` for natural sort (`"file2" < "file10"`)
- `sortStrings(values, locale, options?)` — returns sorted immutable copy
#### Scenario: compareCaseInsensitive treats a and A as equal
- **WHEN** `compareCaseInsensitive("a", "A", "en-GB")` is called
- **THEN** the result equals `0`
#### Scenario: compareNumericAware orders file2 before file10
- **WHEN** `compareNumericAware("file2", "file10", "en-GB")` is called
- **THEN** the result equals `-1`
#### Scenario: sortStrings sorts and returns immutable copy
- **GIVEN** `const input = ["c", "a", "b"]`
- **WHEN** `const out = sortStrings(input, "en-GB")` is called
- **THEN** `out` equals `["a", "b", "c"]` AND `input` still equals `["c", "a", "b"]`
**Verified by:** `packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"compareCaseInsensitive treats a and A as equal"` AND `packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"compareNumericAware orders file2 before file10"` AND `packages/i18n/i18n-formatter-collator/src/helpers.test.ts::"sortStrings sorts and returns immutable copy"`
---
### Requirement: Subpath exports
The package SHALL declare subpath exports in `package.json`:
- `@suaveplan/i18n-formatter-collator` — root barrel
- `@suaveplan/i18n-formatter-collator/formatter` — `CollatorFormatter` class + `compareStrings` + `formatCollation` + types + schema only
- `@suaveplan/i18n-formatter-collator/helpers` — three ergonomic helpers 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-collator/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-collator/src/index.test.ts::"package.json declares sideEffects false"`

View File

@@ -0,0 +1,59 @@
# Tasks — `@suaveplan/i18n-formatter-collator`
> 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-collator/spec.md` (10 requirements) and archives the change folder. All `**Verified by:**` paths in the spec have been confirmed to exist in `packages/i18n/i18n-formatter-collator/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-collator/spec.md` delta drafted (10 requirements)
- [x] 1.5 Every requirement carries a `**Verified by:**` line
- [x] 1.6 `openspec change validate i18n-formatter-collator` clean
## 2. Package scaffold
- [x] 2.1 `packages/i18n/i18n-formatter-collator/src/` structure
- [x] 2.2 `package.json` — `@suaveplan/i18n-formatter-collator@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-collator` green at commit time
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-formatter-collator` 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-collator/spec.md` (canonical, status ACTIVE)
- [x] 7.2 Folder moved to `openspec/archive/2026-05-09-i18n-formatter-collator/`
- [x] 7.3 `MANIFEST.md` already lists the package