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,175 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: i18n-formatter-segmenter (i18n-formatter-segmenter)
**Package**: `@suaveplan/i18n-formatter-segmenter`
**Status**: ACTIVE
> 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.
## Requirements
### Requirement: SegmenterFormatter class wrapping `Intl.Segmenter`
The package SHALL export a `SegmenterFormatter` class that wraps `Intl.Segmenter` with construction-time Zod validation of options and `segment(input: string, locale: string): string[]` and `segmentWithMetadata(input: string, locale: string): SegmentData[]` methods. Construction with malformed options SHALL throw `I18nError(I18N.INVALID_FORMATTER_OPTIONS)`.
#### Scenario: segment with default options (grapheme)
- **GIVEN** `const f = new SegmenterFormatter()`
- **WHEN** `f.segment("ab", "en-GB")` is called
- **THEN** the result equals `["a", "b"]`
#### Scenario: invalid options throw INVALID_FORMATTER_OPTIONS
- **WHEN** `new SegmenterFormatter({ granularity: "invalid" as never })` is called
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_FORMATTER_OPTIONS"`
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"SegmenterFormatter segments with default options"` AND `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"SegmenterFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"`
### Requirement: `segmentText` standalone helper returns `string[]`
The package SHALL export a `segmentText(input, locale, options?)` standalone helper that returns the segments as a plain `string[]`.
#### Scenario: word granularity returns word segments
- **WHEN** `segmentText("hello world", "en-GB", { granularity: "word" })` is called
- **THEN** the result includes both `"hello"` and `"world"` as segments
#### Scenario: standalone twins class output
- **WHEN** both `segmentText(input, l, opts)` and `new SegmenterFormatter(opts).segment(input, l)` are called for the same `(input, l, opts)`
- **THEN** the results are identical
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"segmentText word granularity returns word segments"` AND `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"segmentText output twins SegmenterFormatter.segment output"`
### Requirement: `formatSegments` standalone helper returns `SegmentData[]`
The package SHALL export a `formatSegments(input, locale, options?)` standalone helper that returns `SegmentData[]` with each entry preserving `{ segment, index, isWordLike }` metadata per `Intl.SegmentData`.
#### Scenario: formatSegments preserves index and isWordLike
- **WHEN** `formatSegments("hi!", "en-GB", { granularity: "word" })` is called
- **THEN** the first entry has `segment: "hi"`, `index: 0`, `isWordLike: true` AND a subsequent entry has `segment: "!"`, `isWordLike: false`
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"formatSegments preserves index and isWordLike metadata"`
### Requirement: Grapheme granularity treats compound emoji as a single segment
The package SHALL respect ECMA-402 `Intl.Segmenter` semantics: a compound emoji (e.g. `"👨‍👩‍👧"`, the family-of-three ZWJ sequence) SHALL be returned as a single segment under `granularity: "grapheme"`.
#### Scenario: family emoji is a single grapheme
- **WHEN** `segmentText("👨‍👩‍👧", "en-GB", { granularity: "grapheme" })` is called
- **THEN** the result has length `1`
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"compound emoji is a single grapheme segment"`
### Requirement: `Intl.Segmenter` `RangeError` maps to `UNSUPPORTED_LOCALE`
The package SHALL catch every `RangeError` thrown by the `Intl.Segmenter` 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** `segmentText("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-segmenter/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"`
### Requirement: Per-(locale, granularity) cache
The package SHALL maintain an internal `Map<string, Intl.Segmenter>` keyed by `${locale}|${granularity}`. Repeated calls with identical `(locale, granularity)` SHALL reuse the same `Intl.Segmenter` instance. The package SHALL export a `clearSegmenterCache()` test-only helper.
#### Scenario: identical key reuses the same Intl.Segmenter
- **GIVEN** the cache is empty
- **WHEN** `segmentText("a", "en-GB")` is called twice in sequence
- **THEN** only one `Intl.Segmenter` constructor invocation occurs
#### Scenario: distinct granularity creates distinct cache entries
- **WHEN** `segmentText("a", "en-GB", { granularity: "grapheme" })` and `segmentText("a", "en-GB", { granularity: "word" })` are called
- **THEN** two distinct `Intl.Segmenter` instances are created and cached
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"identical key reuses cached Intl.Segmenter"` AND `packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"distinct granularity creates distinct cache entries"`
### Requirement: OTel histogram `i18n.format.segmenter.duration_ms`
The package SHALL emit an OTel histogram named exactly `i18n.format.segmenter.duration_ms` (unit `ms`) on every successful `segmentText` or `formatSegments` call, tagged with `locale` and `granularity`.
#### Scenario: histogram emitted on every call
- **GIVEN** an OTel test exporter capturing histogram observations
- **WHEN** `segmentText("hello", "en-GB", { granularity: "word" })` is called
- **THEN** exactly one observation appears under `i18n.format.segmenter.duration_ms` with `locale` and `granularity` attributes
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/observability.test.ts::"emits i18n.format.segmenter.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: "segmenter"` 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** `segmentText("a", "en-GB")` is called twice in sequence
- **THEN** the counter `i18n.format.cache_hit{formatter="segmenter"}` shows exactly one increment
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/observability.test.ts::"cache-hit counter increments only on second call with same options"`
### Requirement: Ergonomic helpers — `countGraphemes`, `splitWords`, `splitSentences`
The package SHALL export three ergonomic helpers:
- `countGraphemes(input, locale)` — returns the grapheme count (emoji-aware)
- `splitWords(input, locale)` — returns word-segments only (filters `isWordLike: true`)
- `splitSentences(input, locale)` — returns sentence-granularity segments
#### Scenario: countGraphemes treats compound emoji as one
- **WHEN** `countGraphemes("👨‍👩‍👧", "en-GB")` is called
- **THEN** the result equals `1`
#### Scenario: countGraphemes counts ASCII characters
- **WHEN** `countGraphemes("hello", "en-GB")` is called
- **THEN** the result equals `5`
#### Scenario: splitWords filters non-word-like segments
- **WHEN** `splitWords("hello, world!", "en-GB")` is called
- **THEN** the result equals `["hello", "world"]`
#### Scenario: splitSentences returns sentence segments
- **WHEN** `splitSentences("Hi. How are you?", "en-GB")` is called
- **THEN** the result has length `2`
**Verified by:** `packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"countGraphemes treats compound emoji as one"` AND `packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"countGraphemes counts ASCII characters"` AND `packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"splitWords filters non-word-like segments"` AND `packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"splitSentences returns sentence segments"`
### Requirement: Subpath exports
The package SHALL declare subpath exports in `package.json`:
- `@suaveplan/i18n-formatter-segmenter` — root barrel
- `@suaveplan/i18n-formatter-segmenter/formatter` — `SegmenterFormatter` class + `segmentText` + `formatSegments` + types + schema only
- `@suaveplan/i18n-formatter-segmenter/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-segmenter/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-segmenter/src/index.test.ts::"package.json declares sideEffects false"`