Initial Commit
This commit is contained in:
96
openspec/archive/2026-05-09-i18n-formatter-list/proposal.md
Normal file
96
openspec/archive/2026-05-09-i18n-formatter-list/proposal.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `@suaveplan/i18n-formatter-list` — `Intl.ListFormat` wrapper
|
||||
|
||||
**Package**: `@suaveplan/i18n-formatter-list` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature (new universal-tier package; verbatim port of `formatting/list/` plus class wrapper + Zod schema + observability uplift)
|
||||
**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)
|
||||
**Path**: `packages/i18n/i18n-formatter-list/`
|
||||
**Tier**: universal
|
||||
|
||||
## Why
|
||||
|
||||
The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package. `@suaveplan/i18n-formatter-list` ports the existing `formatting/list/formatList` standalone helper, adds the `ListFormatter` class wrapper that the rest of the formatter family ships, and brings the package up to the canonical Wave-3 shape (Zod schema, OTel histogram + cache-hit counter, sibling test files).
|
||||
|
||||
Today `formatting/list/` ships a thin slice:
|
||||
|
||||
- `formatList(values, locale, options?)` standalone helper (`packages/i18n/i18n/src/formatting/list/list.ts` L41–L49)
|
||||
- `clearListFormatterCache` test-only helper (`list.ts` L52–L54)
|
||||
- Per-(locale, style, type) cache (`list.ts` L12, L14–L33)
|
||||
- `UNSUPPORTED_LOCALE` mapping (`list.ts` L26–L31)
|
||||
- `ListFormatStyle` and `ListFormatType` type aliases (`list.ts` L4–L5)
|
||||
|
||||
The split pulls these into `i18n-formatter-list`, adds the class wrapper for symmetry with `NumberFormatter` / `RelativeTimeFormatter` / `DateFormatter`, adds `INVALID_FORMATTER_OPTIONS` Zod-validation on options, and wires the OTel histogram + cache-hit counter so the family is consistent.
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/i18n-formatter-list`
|
||||
|
||||
- ADDED: `packages/i18n/i18n-formatter-list/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`).
|
||||
- ADDED: `src/formatter.ts` — `ListFormatter` class (NEW, symmetric with sibling formatters) wrapping `Intl.ListFormat` with construction-time Zod option validation; `formatList` standalone (port of monolith `list.ts` L41–L49); `clearListFormatterCache` test-only helper (port of L52–L54). Per-(locale, style, type) cache key `${locale}|${style}|${type}` (port of L19).
|
||||
- ADDED: `src/formatter.types.ts` — `ListFormatStyle` (`"long" | "short" | "narrow"`), `ListFormatType` (`"conjunction" | "disjunction" | "unit"`), `ListFormatterOptions`, `ListFormatterCacheKey`. Sourced from monolith `list.ts` L4–L10 plus new types for the class surface.
|
||||
- ADDED: `src/formatter.schemas.ts` — `ListFormatterOptionsSchema` Zod schema. NEW (the monolith does not currently Zod-validate list options).
|
||||
- ADDED: `src/helpers.ts` — `formatConjunction(values, locale, style?)` and `formatDisjunction(values, locale, style?)` ergonomic shortcuts that pre-bind `formatList` with `type: "conjunction"` / `type: "disjunction"`.
|
||||
- ADDED: `src/observability.ts` — local OTel histogram `i18n.format.list.duration_ms` + counter `i18n.format.cache_hit{formatter="list"}`.
|
||||
- ADDED: `src/formatter.test.ts` — port of monolith `list.test.ts` (56 lines) plus new tests covering `ListFormatter` class wrapper, Zod-validation, and cache-hit behaviour.
|
||||
- ADDED: `src/formatter.spec-conformance.test.ts` — NEW spec-conformance suite asserting every requirement in this delta.
|
||||
- ADDED: `src/helpers.test.ts` — covers `formatConjunction` and `formatDisjunction`.
|
||||
- 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` — `ListFormatter` class + `formatList` + types + schema only
|
||||
- `./helpers` — `formatConjunction`, `formatDisjunction` 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/list/` from `packages/i18n/i18n/src/`** — happens in Wave 6.
|
||||
- **`formatToParts` for list output** — follow-up; current monolith does not expose it.
|
||||
- **Custom list separators** — out of scope; `Intl.ListFormat` already handles locale-correct separators.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `formatList` standalone | `packages/i18n/i18n/src/formatting/list/list.ts` | L41–L49 | Verbatim port |
|
||||
| `clearListFormatterCache` | `packages/i18n/i18n/src/formatting/list/list.ts` | L52–L54 | Verbatim port |
|
||||
| Per-(locale, style, type) cache | `packages/i18n/i18n/src/formatting/list/list.ts` | L12, L14–L33 | Verbatim port |
|
||||
| `UNSUPPORTED_LOCALE` mapping | `packages/i18n/i18n/src/formatting/list/list.ts` | L22–L31 | Verbatim port; throws `I18nError` from `@suaveplan/i18n-shared` |
|
||||
| `ListFormatStyle`, `ListFormatType`, `ListFormatOptions` types | `packages/i18n/i18n/src/formatting/list/list.ts` | L4–L10 | Verbatim port; rename `ListFormatOptions` → `ListFormatterOptions` for symmetry |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `ListFormatter` class wrapper | New — symmetric with `NumberFormatter`, `RelativeTimeFormatter`, `DateFormatter` so consumers can construct once with stable options and reuse |
|
||||
| `ListFormatterOptionsSchema` Zod schema | New — current monolith does not validate list options; the canonical Wave-3 formatter shape mandates Zod-validated boundaries |
|
||||
| `INVALID_FORMATTER_OPTIONS` mapping | Bound to the new Zod schema |
|
||||
| OTel histogram `i18n.format.list.duration_ms` + cache-hit counter | New — the canonical Wave-3 formatter shape mandates these (the monolith currently does not emit them for the list formatter) |
|
||||
| `formatConjunction`, `formatDisjunction` helpers | New ergonomic surface symmetric with `formatPercent` / `formatCompact` for the number formatter |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `packages/i18n/i18n-formatter-list/src/formatter.test.ts` — port of monolith `list.test.ts`; covers conjunction / disjunction / unit types; long / short / narrow styles; default `(long, conjunction)`; class wrapper twins helper output; Zod failure → `INVALID_FORMATTER_OPTIONS`; bogus locale → `UNSUPPORTED_LOCALE`; cache reuse on identical key
|
||||
- `packages/i18n/i18n-formatter-list/src/formatter.spec-conformance.test.ts` — every requirement scenario in this delta runs as an executable test
|
||||
- `packages/i18n/i18n-formatter-list/src/helpers.test.ts` — `formatConjunction(["a", "b", "c"], "en-GB")` returns `"a, b, and c"`; `formatDisjunction(["x", "y"], "en-GB")` returns `"x or y"`
|
||||
- `packages/i18n/i18n-formatter-list/src/observability.test.ts` — histogram named `i18n.format.list.duration_ms`; counter `i18n.format.cache_hit{formatter="list"}` increments only on cache hit
|
||||
- `packages/i18n/i18n-formatter-list/src/index.test.ts` — every documented public export resolves; subpaths resolve
|
||||
|
||||
## Impact
|
||||
|
||||
- New universal-tier package — joins the rest of the i18n family under `packages/i18n/`.
|
||||
- Indirect consumer in Wave 6: `@suaveplan/i18n` slim core re-exports `ListFormatter` and `formatList` under the back-compat root barrel.
|
||||
- `MANIFEST.md` and `CLAUDE.md` updated post-merge.
|
||||
@@ -0,0 +1,153 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: i18n-formatter-list (i18n-formatter-list)
|
||||
|
||||
**Package**: `@suaveplan/i18n-formatter-list`
|
||||
**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: 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"`
|
||||
59
openspec/archive/2026-05-09-i18n-formatter-list/tasks.md
Normal file
59
openspec/archive/2026-05-09-i18n-formatter-list/tasks.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Tasks — `@suaveplan/i18n-formatter-list`
|
||||
|
||||
> 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-list/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-list/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-list/spec.md` delta drafted (9 requirements)
|
||||
- [x] 1.5 Every requirement carries a `**Verified by:**` line
|
||||
- [x] 1.6 `openspec change validate i18n-formatter-list` clean
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/i18n/i18n-formatter-list/src/` structure
|
||||
- [x] 2.2 `package.json` — `@suaveplan/i18n-formatter-list@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-list` green at commit time
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-formatter-list` 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-list/spec.md` (canonical, status ACTIVE)
|
||||
- [x] 7.2 Folder moved to `openspec/archive/2026-05-09-i18n-formatter-list/`
|
||||
- [x] 7.3 `MANIFEST.md` already lists the package
|
||||
Reference in New Issue
Block a user