Files
suaveplan/docs/superpowers/plans/2026-04-20-utils-migration.md
2026-05-27 23:29:53 +01:00

1562 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Utils Migration Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land four new genesis packages — `@suaveplan/bytes`, `@suaveplan/json`, `@suaveplan/locale-data`, and `@suaveplan/utils` — by migrating and rewriting the relevant modules from `@suaveplan/utils` on main, then open a follow-up OpenSpec to refactor `@suaveplan/crypto-utils` to consume `@suaveplan/bytes`.
**Architecture:** Each package is an independent leaf (or near-leaf) following the genesis enterprise pattern established by `@suaveplan/pseudo-random`: `SuaveplanError` + `defineCodes` from `@suaveplan/error`, Zod schemas, OTel via `@opentelemetry/api`, fast-check property tests, mitata benchmarks, 100% Istanbul coverage, one subpath export per feature module. Build in sequence: bytes → json → locale-data → utils (all share the same dep set; none block the next, but bytes ships first to unblock the later crypto-utils refactor).
**Tech Stack:** Bun, TypeScript, Vite + vite-plugin-dts, Vitest + istanbul, fast-check, mitata, Zod, `@opentelemetry/api`, `@suaveplan/error`, `@suaveplan/types`
**Reference package:** `universal/random/pseudo-random` — copy its `package.json`, `vite.config.ts`, `vitest.config.ts`, `tsconfig.lib.json` and adapt (package name, cache dir, externals). Error class pattern: `universal/random/pseudo-random/src/errors/pseudo-random-error.ts`.
**Source material:** Original implementations at `/mnt/s/web/suaveplan/packages/universal/utils/src/` — read each file before implementing its genesis equivalent.
---
## Chunk 1: `@suaveplan/bytes`
**Path:** `universal/primitives/bytes`
### File map
```text
universal/primitives/bytes/
├── package.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── README.md
└── src/
├── index.ts # root barrel
├── types/
│ ├── index.ts
│ └── types.ts # InputFormat, OutputFormat, BytesInput
├── errors/
│ ├── index.ts
│ └── bytes-error.ts # BytesError + BYTES_CODES
├── encoding/
│ ├── index.ts
│ ├── encoding.ts # bytesToHex, hexToBytes, bytesToBase64, base64ToBytes, bytesToUtf8, utf8ToBytes
│ ├── encoding.test.ts
│ ├── encoding.schemas.ts
│ └── encoding.md
├── uint8/
│ ├── index.ts
│ ├── uint8.ts # concatUint8Arrays, sliceUint8Array, uint8ArraysEqual, inputToUint8Array, detectInputFormat, validateInputTypeFormat, formatOutput, convertDataFormat
│ ├── uint8.test.ts
│ ├── uint8.schemas.ts
│ └── uint8.md
└── uuid/
├── index.ts
├── uuid.ts # uint8ArrayToUuid, uuidToUint8Array, generateUuid
├── uuid.test.ts
├── uuid.schemas.ts
└── uuid.md
```
---
### Task 1.1: OpenSpec change + package scaffold
- [ ] Create `openspec/changes/enterprise-bytes/proposal.md` (why: canonical byte encoding package, unblocks crypto-utils refactor; what: encoding + uint8 + uuid modules; impact: new package, follow-up to update crypto-utils)
- [ ] Create `openspec/changes/enterprise-bytes/tasks.md` (link to this plan)
- [ ] Create `universal/primitives/bytes/` directory
- [ ] Copy `universal/random/pseudo-random/package.json` → `universal/primitives/bytes/package.json`, then edit:
- `name`: `"@suaveplan/bytes"`
- `description`: `"Byte encoding, format conversion, and UUID utilities"`
- Keep `@suaveplan/error workspace:^` and `@suaveplan/types workspace:^` in dependencies
- Add `@opentelemetry/api`: `"catalog:observability"` to dependencies
- Exports: `"."`, `"./encoding"`, `"./uint8"`, `"./uuid"`, `"./errors"`, `"./schemas"`
- [ ] Copy `universal/random/pseudo-random/tsconfig.lib.json` → `universal/primitives/bytes/tsconfig.lib.json` (no changes needed)
- [ ] Copy `universal/random/pseudo-random/vite.config.ts` → `universal/primitives/bytes/vite.config.ts`; update `cacheDir` to `"../../../node_modules/.vite/packages/bytes"`; add `@opentelemetry/api`, `@suaveplan/error`, `@suaveplan/types` to the `external` array
- [ ] Copy `universal/random/pseudo-random/vitest.config.ts` → `universal/primitives/bytes/vitest.config.ts`; update coverage thresholds to **100% on all four** (`statements: 100, branches: 100, functions: 100, lines: 100`); remove `setupFiles` if present (bytes has no polyfill needs)
- [ ] Add `"universal/primitives/bytes"` to the root `package.json` workspaces array
- [ ] Run `bun install` to register the new workspace
- [ ] Commit: `"chore(bytes): scaffold package"`
---
### Task 1.2: Error class + types
- [ ] Read `universal/random/pseudo-random/src/errors/pseudo-random-error.ts` for the pattern
- [ ] Create `src/errors/bytes-error.ts`:
```ts
import { defineCodes, SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
export const BYTES_CODES = defineCodes("BYTES", [
"ENCODE.INVALID_HEX",
"ENCODE.INVALID_BASE64",
"ENCODE.INVALID_UTF8",
"UINT8.INVALID_INPUT",
"UINT8.FORMAT_MISMATCH",
"UUID.INVALID_LENGTH",
"UUID.INVALID_FORMAT",
] as const);
export type BytesCode = (typeof BYTES_CODES)[keyof typeof BYTES_CODES];
export class BytesError extends SuaveplanError<BytesCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: BytesCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
```
- [ ] Create `src/errors/index.ts` (barrel re-exporting `BytesError`, `BYTES_CODES`, `BytesCode`)
- [ ] Create `src/types/types.ts`:
```ts
export type InputFormat = "hex" | "base64" | "utf8" | "uint8array";
export type OutputFormat = "hex" | "base64" | "utf8" | "uint8array";
export type BytesInput = string | Uint8Array;
```
- [ ] Create `src/types/index.ts` (barrel)
- [ ] Create `src/index.ts` (top-level barrel re-exporting from all feature modules)
- [ ] Commit: `"feat(bytes): add error class and types"`
---
### Task 1.3: `encoding` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/uint/uint.ts` for the original implementation
- [ ] Create `src/encoding/encoding.test.ts` with failing tests:
```ts
import { describe, expect, it } from "vitest";
import { base64ToBytes, bytesToBase64, bytesToHex, bytesToUtf8, hexToBytes, utf8ToBytes } from "./encoding.js";
describe("bytesToHex / hexToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
expect(hexToBytes(bytesToHex(input))).toEqual(input);
});
it("bytesToHex produces lowercase hex", { timeout: 5000 }, () => {
expect(bytesToHex(new Uint8Array([255]))).toBe("ff");
});
it("hexToBytes rejects odd-length string", { timeout: 5000 }, () => {
expect(() => hexToBytes("abc")).toThrow();
});
it("hexToBytes rejects non-hex chars", { timeout: 5000 }, () => {
expect(() => hexToBytes("zz")).toThrow();
});
});
describe("bytesToBase64 / base64ToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([1, 2, 3, 4, 5]);
expect(base64ToBytes(bytesToBase64(input))).toEqual(input);
});
it("rejects invalid base64", { timeout: 5000 }, () => {
expect(() => base64ToBytes("not!valid@base64")).toThrow();
});
});
describe("bytesToUtf8 / utf8ToBytes", () => {
it("round-trips ASCII", { timeout: 5000 }, () => {
const text = "Hello, world!";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
it("round-trips multibyte characters", { timeout: 5000 }, () => {
const text = "こんにちは";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect FAIL (no implementation)
- [ ] Create `src/encoding/encoding.ts` implementing all 6 functions using native `TextEncoder`/`TextDecoder` and `btoa`/`atob`; throw `BytesError` with appropriate codes on invalid input; add OTel span per function
- [ ] Create `src/encoding/encoding.schemas.ts` (Zod schemas for hex string, base64 string, utf8 string)
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect PASS
- [ ] Create `src/encoding/index.ts` (barrel)
- [ ] Create `src/encoding/encoding.md` (≥200 words: Purpose, Features, Basic Example, Advanced Example, API Reference, Implementation Notes) — must be created alongside the implementation, not deferred
- [ ] Commit: `"feat(bytes): add encoding module (hex/base64/utf8)"`
---
### Task 1.4: `uint8` module (TDD)
- [ ] Create `src/uint8/uint8.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { concatUint8Arrays, convertDataFormat, detectInputFormat, sliceUint8Array, uint8ArraysEqual } from "./uint8.js";
describe("concatUint8Arrays", () => {
it("concatenates multiple arrays", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4]);
const c = new Uint8Array([5]);
expect(concatUint8Arrays(a, b, c)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
});
it("handles empty arrays", { timeout: 5000 }, () => {
expect(concatUint8Arrays(new Uint8Array(), new Uint8Array([1]))).toEqual(new Uint8Array([1]));
});
});
describe("sliceUint8Array", () => {
it("slices correctly", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
expect(sliceUint8Array(arr, 1, 3)).toEqual(new Uint8Array([2, 3]));
});
});
describe("uint8ArraysEqual", () => {
it("returns true for equal arrays", { timeout: 5000 }, () => {
expect(uint8ArraysEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true);
});
it("returns false for different arrays", { timeout: 5000 }, () => {
expect(uint8ArraysEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4]))).toBe(false);
});
it("returns false for different lengths", { timeout: 5000 }, () => {
expect(uint8ArraysEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false);
});
});
describe("detectInputFormat", () => {
it("detects Uint8Array", { timeout: 5000 }, () => {
expect(detectInputFormat(new Uint8Array())).toBe("uint8array");
});
it("detects hex string", { timeout: 5000 }, () => {
expect(detectInputFormat("48656c6c6f")).toBe("hex");
});
});
describe("convertDataFormat", () => {
it("converts hex to utf8", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "utf8", "hex")).toBe("Hello");
});
it("converts utf8 to hex", { timeout: 5000 }, () => {
expect(convertDataFormat("Hello", "hex", "utf8")).toBe("48656c6c6f");
});
it("converts Uint8Array to hex", { timeout: 5000 }, () => {
expect(convertDataFormat(new Uint8Array([72, 101, 108, 108, 111]), "hex")).toBe("48656c6c6f");
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect FAIL
- [ ] Implement `src/uint8/uint8.ts` (all 8 exports + OTel spans; use encoding module internally)
- [ ] Create `src/uint8/uint8.schemas.ts` and `src/uint8/index.ts`
- [ ] Create `src/uint8/uint8.md` (≥200 words — same structure as encoding.md)
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect PASS
- [ ] Commit: `"feat(bytes): add uint8 module"`
---
### Task 1.5: `uuid` module (TDD)
- [ ] Create `src/uuid/uuid.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
describe("uuid round-trip", () => {
it("round-trips a known UUID", { timeout: 5000 }, () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
it("uuidToUint8Array produces 16 bytes", { timeout: 5000 }, () => {
expect(uuidToUint8Array("550e8400-e29b-41d4-a716-446655440000")).toHaveLength(16);
});
it("throws on invalid UUID", { timeout: 5000 }, () => {
expect(() => uuidToUint8Array("not-a-uuid")).toThrow();
});
});
describe("generateUuid", () => {
it("generates a valid UUID v4 string", { timeout: 5000 }, () => {
const uuid = generateUuid();
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
it("generates unique values", { timeout: 5000 }, () => {
expect(generateUuid()).not.toBe(generateUuid());
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect FAIL
- [ ] Implement `src/uuid/uuid.ts` using native `crypto.randomUUID()` for `generateUuid`; parse/serialise with hex arithmetic (no external `uuid` package)
- [ ] Create `src/uuid/uuid.schemas.ts` (Zod UUID string schema) and `src/uuid/index.ts`
- [ ] Create `src/uuid/uuid.md` (≥200 words — same structure as encoding.md)
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect PASS
- [ ] Commit: `"feat(bytes): add uuid module"`
---
### Task 1.6: Property tests (fast-check)
- [ ] Add fast-check property tests to `src/encoding/encoding.test.ts`:
```ts
import fc from "fast-check";
it("hex round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.uint8Array(), (bytes) => {
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes);
}));
});
it("base64 round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.uint8Array(), (bytes) => {
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
}));
});
it("utf8 round-trip for arbitrary strings", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.string(), (str) => {
expect(bytesToUtf8(utf8ToBytes(str))).toBe(str);
}));
});
```
- [ ] Add to `src/uint8/uint8.test.ts`:
```ts
it("concatUint8Arrays associativity", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.uint8Array(), fc.uint8Array(), fc.uint8Array(), (a, b, c) => {
const left = concatUint8Arrays(concatUint8Arrays(a, b), c);
const right = concatUint8Arrays(a, concatUint8Arrays(b, c));
expect(left).toEqual(right);
}));
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/bytes` — expect PASS
- [ ] Commit: `"test(bytes): add fast-check property tests"`
---
### Task 1.7: Benchmarks
- [ ] Create `src/__benchmarks__/encoding.bench.ts`:
```ts
import { bench, run } from "mitata";
import { bytesToBase64, bytesToHex, hexToBytes } from "../encoding/encoding.js";
const small = new Uint8Array(32);
const large = new Uint8Array(65536);
crypto.getRandomValues(small);
crypto.getRandomValues(large);
bench("bytesToHex (32 bytes)", () => bytesToHex(small));
bench("bytesToHex (64 KB)", () => bytesToHex(large));
bench("hexToBytes (32 bytes)", () => hexToBytes(bytesToHex(small)));
bench("bytesToBase64 (32 bytes)", () => bytesToBase64(small));
bench("bytesToBase64 (64 KB)", () => bytesToBase64(large));
await run();
```
- [ ] Run `bun src/__benchmarks__/encoding.bench.ts` to capture baselines; paste output into `README.md` benchmarks section
- [ ] Commit: `"bench(bytes): add encoding benchmarks"`
---
### Task 1.8: README + docs check
> Module `.md` files are created alongside each TDD task (1.3–1.5). This task adds the package-level README and verifies 100% docs coverage.
- [ ] Create `README.md` at `universal/primitives/bytes/README.md` with: description, install, quick-start, Modules table linking each `.md`, Benchmarks section
- [ ] Run `bun run docs:check --filter=@suaveplan/bytes` — must be 100%; fix any gaps before continuing
- [ ] Commit: `"docs(bytes): add README"`
---
### Task 1.9: Pre-publish gate + publish
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/bytes` — must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/bytes` — must be 100% all thresholds
- [ ] Fix any issues before continuing
- [ ] Run `bun scripts/publish.ts --package @suaveplan/bytes`
- [ ] Archive: move `openspec/changes/enterprise-bytes/` → `openspec/archive/2026-04-20-enterprise-bytes/`; update `openspec/specs/bytes/spec.md` with final capability description
- [ ] Commit: `"release(bytes): v0.0.0 — publish to Verdaccio"`
---
## Chunk 2: `@suaveplan/json`
**Path:** `universal/data/json`
### File map
```text
universal/data/json/
├── package.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── README.md
└── src/
├── index.ts
├── types/
│ ├── index.ts
│ └── types.ts # ParseOptions, StringifyOptions
├── errors/
│ ├── index.ts
│ └── json-error.ts # JsonError + JSON_CODES
└── safe-json/
├── index.ts
├── safe-json.ts # safeParse, safeStringify, tryParse, isValidJSON, getStringifySize
├── safe-json.test.ts
├── safe-json.schemas.ts
└── safe-json.md
```
---
### Task 2.1: OpenSpec + scaffold
- [ ] Create `openspec/changes/enterprise-json/proposal.md` + `tasks.md`
- [ ] Scaffold `universal/data/json/` — copy configs from `universal/primitives/bytes/` and adapt (name `@suaveplan/json`, exports: `"."`, `"./safe-json"`, `"./errors"`)
- [ ] Register workspace in root `package.json`, run `bun install`
- [ ] Commit: `"chore(json): scaffold package"`
---
### Task 2.2: Error class + types
- [ ] Create `src/errors/json-error.ts`:
```ts
import { defineCodes, SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
export const JSON_CODES = defineCodes("JSON", [
"PARSE.FAILED",
"PARSE.PROTOTYPE_POLLUTION",
"STRINGIFY.CIRCULAR",
"STRINGIFY.FAILED",
] as const);
export type JsonCode = (typeof JSON_CODES)[keyof typeof JSON_CODES];
export class JsonError extends SuaveplanError<JsonCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: JsonCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
```
- [ ] Create `src/types/types.ts`:
```ts
export interface ParseOptions {
/** Keys to reject (default: ["__proto__", "constructor", "prototype"]) */
forbiddenKeys?: string[];
/** Max nesting depth before throwing (default: 50) */
maxDepth?: number;
/** AbortSignal to cancel large-payload parsing */
signal?: AbortSignal;
}
export interface StringifyOptions {
space?: number;
/** AbortSignal to cancel large-payload serialisation */
signal?: AbortSignal;
/** Max circular reference depth before throwing (default: 100) */
maxDepth?: number;
}
```
- [ ] Create barrels for `errors/` and `types/`
- [ ] Commit: `"feat(json): add error class and types"`
---
### Task 2.3: `safe-json` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/json/safe-json.ts` for original implementation
- [ ] Create `src/safe-json/safe-json.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { getStringifySize, isValidJSON, JsonError, safeParse, safeStringify, tryParse } from "./safe-json.js";
describe("safeParse", () => {
it("parses valid JSON", { timeout: 5000 }, () => {
expect(safeParse('{"a":1}')).toEqual({ a: 1 });
});
it("rejects __proto__ key", { timeout: 5000 }, () => {
expect(() => safeParse('{"__proto__":{"x":1}}')).toThrow(JsonError);
});
it("rejects constructor key", { timeout: 5000 }, () => {
expect(() => safeParse('{"constructor":{"x":1}}')).toThrow(JsonError);
});
it("throws on invalid JSON", { timeout: 5000 }, () => {
expect(() => safeParse("not json")).toThrow(JsonError);
});
it("allows custom forbidden keys", { timeout: 5000 }, () => {
// With empty forbiddenKeys, __proto__ is allowed
expect(() => safeParse('{"__proto__":{}}', { forbiddenKeys: [] })).not.toThrow();
});
});
describe("safeStringify", () => {
it("serialises a plain object", { timeout: 5000 }, () => {
expect(safeStringify({ a: 1 })).toBe('{"a":1}');
});
it("throws on circular reference", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
expect(() => safeStringify(obj)).toThrow(JsonError);
});
});
describe("tryParse", () => {
it("returns undefined on invalid JSON", { timeout: 5000 }, () => {
expect(tryParse("bad")).toBeUndefined();
});
it("returns parsed value on valid JSON", { timeout: 5000 }, () => {
expect(tryParse("42")).toBe(42);
});
});
describe("isValidJSON", () => {
it("returns true for valid JSON", { timeout: 5000 }, () => {
expect(isValidJSON('{"x":1}')).toBe(true);
});
it("returns false for invalid JSON", { timeout: 5000 }, () => {
expect(isValidJSON("not json")).toBe(false);
});
});
describe("getStringifySize", () => {
it("returns byte size of serialised value", { timeout: 5000 }, () => {
const value = { hello: "world" };
const size = getStringifySize(value);
expect(size).toBe(new TextEncoder().encode(JSON.stringify(value)).byteLength);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/json` — expect FAIL
- [ ] Implement `src/safe-json/safe-json.ts`: `safeParse` uses a reviver to detect forbidden keys; `safeStringify` tracks seen objects to detect circular refs; `tryParse` wraps `safeParse` in try/catch; `isValidJSON` wraps `tryParse`; `getStringifySize` uses `TextEncoder.encode(JSON.stringify(v)).byteLength`; add OTel span on `safeParse` and `safeStringify`
- [ ] Create `src/safe-json/safe-json.schemas.ts` and `src/safe-json/index.ts`
- [ ] Run `bunx turbo test --filter=@suaveplan/json` — expect PASS
- [ ] Commit: `"feat(json): implement safe-json module"`
---
### Task 2.4: Property tests
- [ ] Add to `src/safe-json/safe-json.test.ts`:
```ts
import fc from "fast-check";
it("safeParse round-trips any JSON-serialisable value", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.jsonValue(), (value) => {
expect(safeParse(JSON.stringify(value))).toEqual(value);
}));
});
it("safeStringify + safeParse is identity for clean objects", { timeout: 30000 }, () => {
// Use only objects without forbidden keys
fc.assert(fc.property(
fc.record({ a: fc.integer(), b: fc.string() }),
(obj) => {
expect(safeParse(safeStringify(obj))).toEqual(obj);
}
));
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/json` — expect PASS
- [ ] Commit: `"test(json): add fast-check property tests"`
---
### Task 2.5: Benchmarks + docs + pre-publish
- [ ] Create `src/__benchmarks__/safe-json.bench.ts` benchmarking `safeParse` and `safeStringify` vs native `JSON.parse`/`JSON.stringify` for small, medium, and large payloads; paste baselines into README
- [ ] Create `src/safe-json/safe-json.md` (≥200 words)
- [ ] Create `README.md`
- [ ] Run `bun run docs:check --filter=@suaveplan/json` — must be 100%
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/json` — must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/json` — must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/json`
- [ ] Archive `openspec/changes/enterprise-json/` → `openspec/archive/2026-04-20-enterprise-json/`
- [ ] Commit: `"release(json): v0.0.0 — publish to Verdaccio"`
---
## Chunk 3: `@suaveplan/locale-data`
**Path:** `universal/i18n/locale-data`
### File map
```text
universal/i18n/locale-data/
├── package.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── README.md
└── src/
├── index.ts
├── types/
│ ├── index.ts
│ └── types.ts # CountryItem, LanguageItem
├── errors/
│ ├── index.ts
│ └── locale-data-error.ts # LocaleDataError + LOCALE_DATA_CODES
├── countries/
│ ├── index.ts
│ ├── countries.ts # getCountry, getAllCountries, getCountriesByContinent, getCountriesByCurrency, country records
│ ├── countries.test.ts
│ ├── countries.schemas.ts
│ └── countries.md
└── languages/
├── index.ts
├── languages.ts # getLanguages, getLanguage, getRtlLanguages, isRtlLanguage, language records
├── languages.test.ts
├── languages.schemas.ts
└── languages.md
```
> **Note:** The raw country and language data is bundled directly in `countries.ts` and `languages.ts` (not split into separate `.data.ts` files) since it's static and tree-shaken at the subpath export level. If either file exceeds the 300-line hard limit, split data into a sibling `countries.data.ts` / `languages.data.ts`.
---
### Task 3.1: OpenSpec + scaffold
- [ ] Create `openspec/changes/enterprise-locale-data/proposal.md` + `tasks.md`
- [ ] Scaffold `universal/i18n/locale-data/` — copy configs, adapt (name `@suaveplan/locale-data`, exports: `"."`, `"./countries"`, `"./languages"`, `"./errors"`)
- [ ] Keep `@opentelemetry/api: "catalog:observability"` in deps — add a counter on `getCountry` and `getLanguage` calls (not-found vs. found) so consumers can observe lookup patterns. Deps: `@suaveplan/error`, `@suaveplan/types`, `zod`, `tslib`, `@opentelemetry/api`.
- [ ] Register workspace + `bun install`
- [ ] Commit: `"chore(locale-data): scaffold package"`
---
### Task 3.2: Error class + types
- [ ] Create `src/errors/locale-data-error.ts`:
```ts
import { defineCodes, SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
export const LOCALE_DATA_CODES = defineCodes("LOCALE_DATA", [
"COUNTRY.NOT_FOUND",
"LANGUAGE.NOT_FOUND",
] as const);
export type LocaleDataCode = (typeof LOCALE_DATA_CODES)[keyof typeof LOCALE_DATA_CODES];
export class LocaleDataError extends SuaveplanError<LocaleDataCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: LocaleDataCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
```
- [ ] Create `src/types/types.ts`:
```ts
import z from "zod";
export const CountryItemSchema = z.object({
name: z.string(),
native: z.string(),
phone: z.string(),
continent: z.string(),
capital: z.string(),
currency: z.string(),
languages: z.array(z.string()),
emoji: z.string(),
emojiU: z.string(),
});
export type CountryItem = z.infer<typeof CountryItemSchema>;
export const LanguageItemSchema = z.object({
name: z.string(),
native: z.string(),
});
export type LanguageItem = z.infer<typeof LanguageItemSchema>;
```
- [ ] Create barrels, commit: `"feat(locale-data): add error class and types"`
---
### Task 3.3: `countries` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/countries/countries.ts` — copy all country records (this is static ISO data, no rewriting needed; verify each has the correct `CountryItem` shape)
- [ ] Create `src/countries/countries.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { getAllCountries, getCountriesByContinent, getCountriesByCurrency, getCountry } from "./countries.js";
describe("getCountry", () => {
it("returns a known country by ISO code", { timeout: 5000 }, () => {
const us = getCountry("US");
expect(us.name).toBe("United States");
expect(us.phone).toBe("1");
});
it("throws LocaleDataError for unknown code", { timeout: 5000 }, () => {
expect(() => getCountry("XX")).toThrow();
});
});
describe("getAllCountries", () => {
it("returns all countries as a record", { timeout: 5000 }, () => {
const all = getAllCountries();
expect(Object.keys(all).length).toBeGreaterThan(200);
expect(all["GB"].name).toBe("United Kingdom");
});
});
describe("getCountriesByContinent", () => {
it("returns only European countries for EU", { timeout: 5000 }, () => {
const eu = getCountriesByContinent("EU");
expect(eu.every((c) => c.continent === "EU")).toBe(true);
expect(eu.length).toBeGreaterThan(30);
});
});
describe("getCountriesByCurrency", () => {
it("returns countries using EUR", { timeout: 5000 }, () => {
const eur = getCountriesByCurrency("EUR");
expect(eur.some((c) => c.name === "Germany")).toBe(true);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/locale-data` — expect FAIL
- [ ] Create `src/countries/countries.ts` with all country records from the source + the 4 lookup functions (`getCountry`, `getAllCountries`, `getCountriesByContinent`, `getCountriesByCurrency`); throw `LocaleDataError` with `LOCALE_DATA_CODES.COUNTRY.NOT_FOUND` for unknown codes
- [ ] Create `src/countries/countries.schemas.ts` (re-export `CountryItemSchema`, define `CountryCodeSchema = z.string().length(2).toUpperCase()`)
- [ ] Create `src/countries/index.ts`
- [ ] Run `bunx turbo test --filter=@suaveplan/locale-data` — expect PASS
- [ ] Commit: `"feat(locale-data): add countries module"`
---
### Task 3.4: `languages` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/languages/languages.ts` — copy all language + RTL records
- [ ] Create `src/languages/languages.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { getLanguage, getLanguages, getRtlLanguages, isRtlLanguage } from "./languages.js";
describe("getLanguages", () => {
it("returns all languages", { timeout: 5000 }, () => {
const langs = getLanguages();
expect(Object.keys(langs).length).toBeGreaterThan(100);
expect(langs["en"].name).toBe("English");
});
});
describe("getLanguage", () => {
it("returns a known language", { timeout: 5000 }, () => {
expect(getLanguage("fr").name).toBe("French");
});
it("throws for unknown language code", { timeout: 5000 }, () => {
expect(() => getLanguage("xx")).toThrow();
});
});
describe("getRtlLanguages", () => {
it("includes Arabic, Hebrew, Persian, Urdu", { timeout: 5000 }, () => {
const rtl = getRtlLanguages();
for (const code of ["ar", "he", "fa", "ur"]) {
expect(rtl).toContain(code);
}
});
it("all returned codes exist in language map", { timeout: 5000 }, () => {
const all = getLanguages();
for (const code of getRtlLanguages()) {
expect(code in all).toBe(true);
}
});
});
describe("isRtlLanguage", () => {
it("returns true for Arabic", { timeout: 5000 }, () => {
expect(isRtlLanguage("ar")).toBe(true);
});
it("returns false for English", { timeout: 5000 }, () => {
expect(isRtlLanguage("en")).toBe(false);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/locale-data` — expect FAIL
- [ ] Implement `src/languages/languages.ts` with all records + 4 functions; throw `LocaleDataError` with `LOCALE_DATA_CODES.LANGUAGE.NOT_FOUND` for unknown codes
- [ ] Create `src/languages/languages.schemas.ts`, `src/languages/index.ts`
- [ ] Run `bunx turbo test --filter=@suaveplan/locale-data` — expect PASS
- [ ] Commit: `"feat(locale-data): add languages module"`
---
### Task 3.5: Docs + pre-publish
- [ ] Create `src/countries/countries.md` (≥200 words)
- [ ] Create `src/languages/languages.md` (≥200 words)
- [ ] Create `README.md`
- [ ] Run `bun run docs:check --filter=@suaveplan/locale-data` — 100%
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/locale-data` — green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/locale-data` — 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/locale-data`
- [ ] Archive `openspec/changes/enterprise-locale-data/` → `openspec/archive/2026-04-20-enterprise-locale-data/`
- [ ] Commit: `"release(locale-data): v0.0.0 — publish to Verdaccio"`
---
## Chunk 4: `@suaveplan/utils` (part 1 — scaffold + modules 1–5)
**Path:** `universal/data/utils`
### File map
```text
universal/data/utils/
├── package.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── README.md
└── src/
├── index.ts
├── types/
│ ├── index.ts
│ └── types.ts # PseudoRandomOptions, SizeLimit, etc.
├── errors/
│ ├── index.ts
│ └── utils-error.ts # UtilsError + UTILS_CODES
├── debug/
│ ├── index.ts
│ ├── debug.ts
│ ├── debug.test.ts
│ └── debug.md
├── object/
│ ├── index.ts
│ ├── object.ts
│ ├── object.test.ts
│ ├── object.schemas.ts
│ └── object.md
├── platform/
│ ├── index.ts
│ ├── platform.ts
│ ├── platform.test.ts
│ ├── platform.schemas.ts
│ └── platform.md
├── number/
│ ├── index.ts
│ ├── number.ts
│ ├── number.test.ts
│ ├── number.schemas.ts
│ └── number.md
├── random/
│ ├── index.ts
│ ├── pseudo.ts
│ ├── pseudo.test.ts
│ ├── pseudo.schemas.ts
│ └── pseudo.md
├── email/
│ ├── index.ts
│ ├── email.ts
│ ├── email.test.ts
│ ├── email.schemas.ts
│ └── email.md
├── sound/
│ ├── index.ts
│ ├── sound.ts
│ ├── sound.test.ts
│ ├── sound.schemas.ts
│ └── sound.md
├── string/
│ ├── index.ts
│ ├── string.ts
│ ├── string.test.ts
│ ├── string.schemas.ts
│ └── string.md
├── validate/
│ ├── index.ts
│ ├── validate.ts
│ ├── validate.test.ts
│ └── validate.md
└── validation/
├── index.ts
├── size.ts
├── size.test.ts
├── size.schemas.ts
└── size.md
```
Exports map in `package.json`: `"."`, `"./debug"`, `"./object"`, `"./platform"`, `"./number"`, `"./random"`, `"./email"`, `"./sound"`, `"./string"`, `"./validate"`, `"./validation"`, `"./errors"`
---
### Task 4.1: OpenSpec + scaffold
- [ ] Create `openspec/changes/enterprise-utils/proposal.md` + `tasks.md`
- [ ] Scaffold `universal/data/utils/` — copy configs from bytes, adapt (name `@suaveplan/utils`, no `@suaveplan/types` dep unless needed by types.ts, add `@opentelemetry/api`)
- [ ] Register workspace + `bun install`
- [ ] Commit: `"chore(utils): scaffold package"`
---
### Task 4.2: Error class
- [ ] Create `src/errors/utils-error.ts`:
```ts
import { defineCodes, SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
export const UTILS_CODES = defineCodes("UTILS", [
"OBJECT.PROTOTYPE_POLLUTION",
"OBJECT.CIRCULAR",
"NUMBER.OUT_OF_RANGE",
"RANDOM.EMPTY_ARRAY",
"RANDOM.INDEX_OUT_OF_BOUNDS",
"VALIDATION.SIZE_EXCEEDED",
"VALIDATION.PARSE_FAILED",
"PLATFORM.UNAVAILABLE",
"SOUND.NO_VARIATIONS",
] as const);
export type UtilsCode = (typeof UTILS_CODES)[keyof typeof UTILS_CODES];
export class UtilsError extends SuaveplanError<UtilsCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: UtilsCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
```
- [ ] Create `src/errors/index.ts`
- [ ] Commit: `"feat(utils): add error class"`
---
### Task 4.3: `debug` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/debug/debug.ts` for original implementation
- [ ] Create `src/debug/debug.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createDebug } from "./debug.js";
describe("createDebug", () => {
it("logs unique messages only", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({ log: (...args) => logs.push(args) });
dbg.log("hello");
dbg.log("hello"); // duplicate — suppressed
dbg.log("world");
expect(logs).toHaveLength(2);
expect(logs[0]).toEqual(["hello"]);
expect(logs[1]).toEqual(["world"]);
});
it("evicts oldest entry when buffer is full", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({ log: (...args) => logs.push(args), bufferSize: 3 });
dbg.log("a"); dbg.log("b"); dbg.log("c");
dbg.log("d"); // evicts "a"
dbg.log("a"); // "a" is no longer in buffer — should log
expect(logs).toHaveLength(5);
});
it("exports a default debug instance", { timeout: 5000 }, async () => {
const { debug } = await import("./debug.js");
expect(typeof debug.log).toBe("function");
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/debug/debug.ts`:
- Export `createDebug({ log?, warn?, error?, bufferSize? })` that wraps a rolling circular buffer of message signatures; delegate to console if no override provided; default `bufferSize: 10`
- Export a module-level `debug` singleton (`createDebug()`)
- **No OTel** — debug is an instrumentation primitive itself; adding spans would be circular
- [ ] Create `src/debug/index.ts`
- [ ] Create `src/debug/debug.md` (≥200 words: Purpose, Features, Basic Example, Advanced Example, API Reference, Implementation Notes)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add debug module"`
---
### Task 4.4: `object` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/object/object.ts`
- [ ] Create `src/object/object.test.ts` (cover all 15 exports: `isObject`, `isPlainObject`, `isObjectOrArray`, `isEmpty`, `deepClone`, `deepMerge`, `deepMergeAll`, `pick`, `omit`, `keepKeys`, `removeKeys`, `flattenObject`, `getNestedValue`, `setNestedValue`, `hasPath`):
```ts
import { describe, expect, it } from "vitest";
import { deepClone, deepMerge, deepMergeAll, flattenObject, getNestedValue, hasPath, isEmpty, isObject, isObjectOrArray, isPlainObject, keepKeys, omit, pick, removeKeys, setNestedValue } from "./object.js";
describe("isObject", () => {
it("returns true for plain objects", { timeout: 5000 }, () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it("returns false for null, arrays, primitives", { timeout: 5000 }, () => {
expect(isObject(null)).toBe(false);
expect(isObject([])).toBe(false);
expect(isObject(42)).toBe(false);
});
});
describe("deepMerge", () => {
it("merges nested objects", { timeout: 5000 }, () => {
const a = { x: { y: 1, z: 2 } };
const b = { x: { y: 99 } };
expect(deepMerge(a, b)).toEqual({ x: { y: 99, z: 2 } });
});
it("does not mutate inputs", { timeout: 5000 }, () => {
const a = { x: 1 };
const b = { y: 2 };
deepMerge(a, b);
expect(a).toEqual({ x: 1 });
});
});
describe("pick / omit", () => {
it("pick returns only specified keys", { timeout: 5000 }, () => {
expect(pick({ a: 1, b: 2, c: 3 }, ["a", "c"])).toEqual({ a: 1, c: 3 });
});
it("omit removes specified keys", { timeout: 5000 }, () => {
expect(omit({ a: 1, b: 2, c: 3 }, ["b"])).toEqual({ a: 1, c: 3 });
});
});
describe("flattenObject", () => {
it("flattens nested object with dot notation", { timeout: 5000 }, () => {
expect(flattenObject({ a: { b: { c: 1 } } })).toEqual({ "a.b.c": 1 });
});
});
describe("getNestedValue / setNestedValue / hasPath", () => {
it("gets a deeply nested value", { timeout: 5000 }, () => {
expect(getNestedValue({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42);
});
it("returns undefined for missing path", { timeout: 5000 }, () => {
expect(getNestedValue({ a: 1 }, "a.b.c")).toBeUndefined();
});
it("sets a deeply nested value", { timeout: 5000 }, () => {
const obj = { a: { b: 1 } };
setNestedValue(obj, "a.b", 99);
expect(obj.a.b).toBe(99);
});
it("hasPath returns true when path exists", { timeout: 5000 }, () => {
expect(hasPath({ a: { b: 1 } }, "a.b")).toBe(true);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/object/object.ts` with all 15 functions; no `any` — use precise generics; throw `UtilsError` with `UTILS_CODES.OBJECT.PROTOTYPE_POLLUTION` if a deep merge would pollute `__proto__`; add OTel span on `deepMerge` and `deepClone` (these are the hot paths)
- [ ] Create `src/object/object.schemas.ts` and `src/object/index.ts`
- [ ] Create `src/object/object.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add object module"`
---
### Task 4.5: `platform` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/platform/platform.ts`
- [ ] Create `src/platform/platform.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { createPlatform, getPlatform, isBun, isBrowser, isNode } from "./platform.js";
describe("platform detection (Node/Bun environment)", () => {
it("isNode returns true in this test runner", { timeout: 5000 }, () => {
expect(isNode()).toBe(true);
});
it("isBrowser returns false in this test runner", { timeout: 5000 }, () => {
expect(isBrowser()).toBe(false);
});
it("getPlatform returns 'bun' or 'node'", { timeout: 5000 }, () => {
expect(["bun", "node"]).toContain(getPlatform());
});
});
describe("createPlatform (injectable adapter)", () => {
it("accepts a mock adapter", { timeout: 5000 }, () => {
const p = createPlatform({
getMemoryUsage: () => ({ heapUsed: 100, heapTotal: 200 }),
getCpuUsage: () => ({ user: 10, system: 5 }),
getPlatform: () => "browser",
});
expect(p.getMemoryUsage().heapUsed).toBe(100);
expect(p.getPlatform()).toBe("browser");
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/platform/platform.ts`: injectable adapter pattern (`createPlatform`); default adapter reads from `process.memoryUsage()` in Node/Bun and `performance.memory` in browser; `getPlatform`, `isNode`, `isBrowser`, `isBun` as convenience exports using the default adapter; throw `UtilsError` with `UTILS_CODES.PLATFORM.UNAVAILABLE` when an API is absent
- [ ] Create `src/platform/platform.schemas.ts` (Zod schemas for `MemoryUsage`, `CpuUsage`), `src/platform/index.ts`
- [ ] Create `src/platform/platform.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add platform module"`
---
### Task 4.6: `number` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/number/number.ts`
- [ ] Create `src/number/number.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { isInFloat16Range, isInFloat32Range, max16, max32, min16, min32, packFloat16, packFloat32, packFloat64, unpackFloat16, unpackFloat32, unpackFloat64 } from "./number.js";
describe("Float16 round-trip", () => {
it("packs and unpacks 0, 1, -1, 0.5, min16, max16", { timeout: 5000 }, () => {
for (const v of [0, 1, -1, 0.5, min16, max16]) {
expect(unpackFloat16(packFloat16(v))).toBeCloseTo(v, 3);
}
});
it("handles NaN, Infinity, -Infinity", { timeout: 5000 }, () => {
expect(unpackFloat16(packFloat16(Number.NaN))).toBeNaN();
expect(unpackFloat16(packFloat16(Infinity))).toBe(Infinity);
expect(unpackFloat16(packFloat16(-Infinity))).toBe(-Infinity);
});
});
describe("Float32/64 round-trip", () => {
it("packFloat32/unpackFloat32 is lossless within float32 range", { timeout: 5000 }, () => {
const v = 3.14;
expect(unpackFloat32(packFloat32(v))).toBeCloseTo(v, 5);
});
it("packFloat64/unpackFloat64 is lossless", { timeout: 5000 }, () => {
const v = Math.PI;
expect(unpackFloat64(packFloat64(v))).toBe(v);
});
});
describe("range guards", () => {
it("isInFloat16Range returns false for out-of-range values", { timeout: 5000 }, () => {
expect(isInFloat16Range(max32)).toBe(false);
});
it("isInFloat32Range returns false for out-of-range values", { timeout: 5000 }, () => {
expect(isInFloat32Range(Number.MAX_VALUE)).toBe(false);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/number/number.ts` using `DataView`-based bit manipulation (same approach as original); add `isInFloat16Range`, `isInFloat32Range`, `isInFloat64Range`, min/max constants for all three; OTel histogram on `packFloat16/32/64` call count
- [ ] Create `src/number/number.schemas.ts`, `src/number/index.ts`
- [ ] Create `src/number/number.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add number (float encoding) module"`
---
### Task 4.7: `random` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/random/pseudo.ts`
- [ ] Create `src/random/pseudo.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { pseudoRandom } from "./pseudo.js";
describe("pseudoRandom", () => {
it("returns a deterministic result for the same seed", { timeout: 5000 }, () => {
const arr = ["a", "b", "c"];
expect(pseudoRandom(arr, 0, 0.42)).toBe(pseudoRandom(arr, 0, 0.42));
});
it("throws for empty array", { timeout: 5000 }, () => {
expect(() => pseudoRandom([], 0)).toThrow();
});
it("throws for out-of-bounds index", { timeout: 5000 }, () => {
expect(() => pseudoRandom(["a"], 1)).toThrow();
expect(() => pseudoRandom(["a"], -1)).toThrow();
});
it("produces different results for different seeds", { timeout: 5000 }, () => {
const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const results = new Set(Array.from({ length: 10 }, (_, i) => pseudoRandom(arr, 0, i / 10)));
expect(results.size).toBeGreaterThan(1);
});
it("is type-safe — returns T for T[]", { timeout: 5000 }, () => {
const result: number = pseudoRandom([1, 2, 3], 0);
expect(typeof result).toBe("number");
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/random/pseudo.ts`: `pseudoRandom<T>(arr: T[], index: number, seed = Math.random()): T`; throw `UtilsError` with `UTILS_CODES.RANDOM.EMPTY_ARRAY` / `RANDOM.INDEX_OUT_OF_BOUNDS`; injectable `rng` option for determinism in tests
- [ ] Create `src/random/pseudo.schemas.ts`, `src/random/index.ts`
- [ ] Create `src/random/pseudo.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add random/pseudo module"`
---
## Chunk 5: `@suaveplan/utils` (part 2 — modules 6–10, property tests, benchmarks, publish)
### Task 5.1: `email` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/email/email.ts`
- [ ] Create `src/email/email.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { normalizeEmail } from "./email.js";
describe("normalizeEmail", () => {
it("strips dots and plus alias from Gmail", { timeout: 5000 }, () => {
expect(normalizeEmail("test.user+alias@gmail.com")).toBe("testuser@gmail.com");
});
it("strips dash alias from Yahoo", { timeout: 5000 }, () => {
expect(normalizeEmail("test.user-alias@yahoo.com")).toBe("test.user@yahoo.com");
});
it("strips plus alias from Outlook", { timeout: 5000 }, () => {
expect(normalizeEmail("user+alias@outlook.com")).toBe("user@outlook.com");
});
it("strips dots and plus for generic domains", { timeout: 5000 }, () => {
expect(normalizeEmail("t.e.s.t+x@example.com")).toBe("test@example.com");
});
it("returns input unchanged for malformed email (no @)", { timeout: 5000 }, () => {
expect(normalizeEmail("notanemail")).toBe("notanemail");
});
it("handles empty local part after stripping gracefully", { timeout: 5000 }, () => {
expect(normalizeEmail("+alias@gmail.com")).toBe("+alias@gmail.com");
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/email/email.ts` using the provider constants from the original (GOOGLE_DOMAINS, YAHOO_DOMAINS, MICROSOFT_DOMAINS); no external deps
- [ ] Create `src/email/email.schemas.ts` (Zod email string schema), `src/email/index.ts`
- [ ] Create `src/email/email.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add email normalizer module"`
---
### Task 5.2: `sound` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/sound/sound.ts` and `sound.types.ts`
- [ ] Create `src/sound/sound.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { convertSprites, getSoundKey, parseSoundVariations } from "./sound.js";
describe("parseSoundVariations", () => {
it("groups sprite keys by base name", { timeout: 5000 }, () => {
const sprites = {
footstep_1: [0, 100] as [number, number],
footstep_2: [100, 100] as [number, number],
jump_1: [200, 150] as [number, number],
};
expect(parseSoundVariations(sprites)).toEqual({ footstep: [1, 2], jump: [1] });
});
it("ignores keys without underscore+number suffix", { timeout: 5000 }, () => {
expect(parseSoundVariations({ ambient: [0, 1000] as [number, number] })).toEqual({});
});
});
describe("getSoundKey", () => {
it("returns a key that exists in the variations map", { timeout: 5000 }, () => {
const variations = { footstep: [1, 2, 3] };
const key = getSoundKey("footstep", variations);
expect(key).toMatch(/^footstep_[123]$/);
});
it("throws UtilsError for unknown sound name", { timeout: 5000 }, () => {
expect(() => getSoundKey("unknown", { footstep: [1] })).toThrow();
});
});
describe("convertSprites", () => {
it("converts howler sprite format to variation map", { timeout: 5000 }, () => {
const sprites = { step_1: [0, 100] as [number, number], step_2: [100, 100] as [number, number] };
expect(convertSprites(sprites)).toEqual({ step: [1, 2] });
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/sound/sound.ts`; preserve `SoundSprites` type from original; throw `UtilsError` with `UTILS_CODES.SOUND.NO_VARIATIONS` when a sound name has no entries
- [ ] Create `src/sound/sound.schemas.ts`, `src/sound/index.ts`
- [ ] Create `src/sound/sound.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add sound sprites module"`
---
### Task 5.3: `string` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/string/string.ts` (only `generateId` and `parseTextWithLinks` — `parseTemplate` is scrapped)
- [ ] Create `src/string/string.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { generateId, parseTextWithLinks } from "./string.js";
describe("generateId", () => {
it("returns a non-empty string", { timeout: 5000 }, () => {
expect(typeof generateId()).toBe("string");
expect(generateId().length).toBeGreaterThan(0);
});
it("generates unique values", { timeout: 5000 }, () => {
const ids = new Set(Array.from({ length: 100 }, generateId));
expect(ids.size).toBe(100);
});
it("accepts a custom length", { timeout: 5000 }, () => {
expect(generateId({ length: 8 })).toHaveLength(8);
});
});
describe("parseTextWithLinks", () => {
it("extracts URLs from plain text", { timeout: 5000 }, () => {
const result = parseTextWithLinks("Visit https://example.com for info");
expect(result).toContainEqual(expect.objectContaining({ url: "https://example.com" }));
});
it("returns empty array for text with no links", { timeout: 5000 }, () => {
expect(parseTextWithLinks("no links here")).toEqual([]);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/string/string.ts`; `generateId` uses `crypto.randomUUID()` internally and optionally trims to requested length; `parseTextWithLinks` uses a URL regex to find and annotate links in text
- [ ] Create `src/string/string.schemas.ts`, `src/string/index.ts`
- [ ] Create `src/string/string.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add string module (generateId, parseTextWithLinks)"`
---
### Task 5.4: `validate` module (TDD)
- [ ] Create `src/validate/validate.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { isHex } from "./validate.js";
describe("isHex", () => {
it("returns true for even-length hex strings", { timeout: 5000 }, () => {
expect(isHex("a1b2c3")).toBe(true);
expect(isHex("ff")).toBe(true);
expect(isHex("")).toBe(false); // empty string is not valid hex
});
it("returns false for odd-length strings", { timeout: 5000 }, () => {
expect(isHex("abc")).toBe(false);
});
it("returns false for non-hex characters", { timeout: 5000 }, () => {
expect(isHex("gg")).toBe(false);
});
it("returns false for non-strings", { timeout: 5000 }, () => {
expect(isHex(123)).toBe(false);
expect(isHex(null)).toBe(false);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/validate/validate.ts`: `isHex = (i: unknown): i is string => typeof i === "string" && /^([A-Fa-f0-9]{2})+$/.test(i)`
- [ ] Create `src/validate/index.ts`
- [ ] Create `src/validate/validate.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add validate/isHex module"`
---
### Task 5.5: `validation/size` module (TDD)
- [ ] Read `/mnt/s/web/suaveplan/packages/universal/utils/src/validation/size.ts`
- [ ] Create `src/validation/size.test.ts`:
```ts
import { describe, expect, it } from "vitest";
import { DEFAULT_MAX_MESSAGE_SIZE, formatSize, getDataSize, isWithinSizeLimit, parseSize, SIZE_LIMITS, truncateToSize, validateSize } from "./size.js";
describe("SIZE_LIMITS", () => {
it("SMALL is 10 KB", { timeout: 5000 }, () => expect(SIZE_LIMITS.SMALL).toBe(10 * 1024));
it("LARGE is 1 MB", { timeout: 5000 }, () => expect(SIZE_LIMITS.LARGE).toBe(1024 * 1024));
});
describe("getDataSize", () => {
it("returns byte length of ASCII string", { timeout: 5000 }, () => expect(getDataSize("hello")).toBe(5));
it("returns byte length of ArrayBuffer", { timeout: 5000 }, () => {
expect(getDataSize(new ArrayBuffer(100))).toBe(100);
});
it("handles multibyte characters", { timeout: 5000 }, () => {
expect(getDataSize("こ")).toBeGreaterThan(1);
});
});
describe("validateSize", () => {
it("does not throw when within limit", { timeout: 5000 }, () => {
expect(() => validateSize("hello", 100)).not.toThrow();
});
it("throws UtilsError when exceeding limit", { timeout: 5000 }, () => {
expect(() => validateSize("hello", 3)).toThrow();
});
});
describe("formatSize", () => {
it("formats bytes as human-readable", { timeout: 5000 }, () => {
expect(formatSize(1024)).toBe("1.00 KB");
expect(formatSize(1024 * 1024)).toBe("1.00 MB");
});
});
describe("parseSize", () => {
it("parses '1KB' to 1024", { timeout: 5000 }, () => expect(parseSize("1KB")).toBe(1024));
it("parses '1.5MB'", { timeout: 5000 }, () => expect(parseSize("1.5MB")).toBe(1.5 * 1024 * 1024));
});
describe("truncateToSize", () => {
it("truncates string to byte limit", { timeout: 5000 }, () => {
const result = truncateToSize("Hello, world!", 5);
expect(getDataSize(result)).toBeLessThanOrEqual(5);
});
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect FAIL
- [ ] Implement `src/validation/size.ts`; throw `UtilsError` with `UTILS_CODES.VALIDATION.SIZE_EXCEEDED`; use `TextEncoder` for string byte measurement; export `SIZE_LIMITS`, `DEFAULT_MAX_MESSAGE_SIZE`; add OTel counter on `validateSize` calls
- [ ] Create `src/validation/size.schemas.ts` (Zod schema for size limit options), `src/validation/index.ts`
- [ ] Create `src/validation/size.md` (≥200 words)
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"feat(utils): add validation/size module"`
---
### Task 5.6: Property tests (fast-check)
Add property tests to the relevant modules:
- [ ] `src/object/object.test.ts` — deepMerge idempotency, deepClone produces structurally equal but distinct object, pick/omit are complementary:
```ts
import fc from "fast-check";
it("deepMerge(a, {}) is structurally equal to a", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.object(), (a) => {
expect(deepMerge(a, {})).toEqual(a);
}));
});
it("pick(obj, keys) + omit(obj, keys) together cover all keys", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.object(), fc.array(fc.string()), (obj, keys) => {
const picked = pick(obj, keys as Array<keyof typeof obj>);
const omitted = omit(obj, keys as Array<keyof typeof obj>);
const merged = { ...picked, ...omitted };
expect(Object.keys(merged).sort()).toEqual(Object.keys(obj).sort());
}));
});
```
- [ ] `src/number/number.test.ts` — float32/64 round-trip for arbitrary in-range values:
```ts
it("packFloat32 round-trips in-range values", { timeout: 30000 }, () => {
fc.assert(fc.property(fc.float({ noNaN: true, noDefaultInfinity: true }), (v) => {
expect(unpackFloat32(packFloat32(v))).toBeCloseTo(v, 4);
}));
});
```
- [ ] `src/random/pseudo.test.ts` — always returns an element of the array:
```ts
it("always returns an element that exists in the array", { timeout: 30000 }, () => {
fc.assert(fc.property(
fc.array(fc.integer(), { minLength: 1 }),
fc.nat(),
fc.double({ min: 0, max: 1, noNaN: true }),
(arr, rawIndex, seed) => {
const index = rawIndex % arr.length;
expect(arr).toContain(pseudoRandom(arr, index, seed));
}
));
});
```
- [ ] Run `bunx turbo test --filter=@suaveplan/utils` — expect PASS
- [ ] Commit: `"test(utils): add fast-check property tests"`
---
### Task 5.7: Benchmarks
- [ ] Create `src/__benchmarks__/object.bench.ts` benchmarking `deepMerge` and `deepClone` for small/medium objects
- [ ] Create `src/__benchmarks__/number.bench.ts` benchmarking `packFloat16/32/64` throughput
- [ ] Run each and paste baselines into README
- [ ] Commit: `"bench(utils): add object and number benchmarks"`
---
### Task 5.8: README + docs check
> Module `.md` files are created inline alongside each TDD task (4.3–5.5). This task adds the package-level README and verifies 100% docs coverage.
- [ ] Create `README.md` at `universal/data/utils/README.md` with: description, install, quick-start, Modules table (linking each `.md`), Benchmarks section
- [ ] Run `bun run docs:check --filter=@suaveplan/utils` — must be 100%; fix any gaps before continuing
- [ ] Commit: `"docs(utils): add README"`
---
### Task 5.9: Pre-publish gate + publish
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/utils` — must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/utils` — must be 100% all thresholds
- [ ] Fix any lint, type, or coverage issues before continuing
- [ ] Run `bun scripts/publish.ts --package @suaveplan/utils`
- [ ] Archive `openspec/changes/enterprise-utils/` → `openspec/archive/2026-04-20-enterprise-utils/`; write `openspec/specs/utils/spec.md`
- [ ] Commit: `"release(utils): v0.0.0 — publish to Verdaccio"`
---
### Task 5.10: Open follow-up OpenSpec for `crypto-utils` refactor
- [ ] Create `openspec/changes/crypto-utils-bytes-refactor/proposal.md`:
- **Why:** `@suaveplan/crypto-utils` duplicates hex/base64/utf8/bytes/concat encoding that now lives canonically in `@suaveplan/bytes`. Deduplication reduces maintenance surface and ensures consistent behaviour.
- **What:** Add `@suaveplan/bytes` as a dep to `crypto-utils`; replace duplicate encoding utilities with re-exports or direct imports from `@suaveplan/bytes`. `base58` stays in `crypto-utils` (blockchain-specific).
- **Impact:** `crypto-utils` gains a new `workspace:^` dep; its exported surface is preserved (re-export or wrapping); consumers see no API change.
- [ ] Create `openspec/changes/crypto-utils-bytes-refactor/tasks.md` (stub — implementation is a separate session)
- [ ] Commit: `"chore: open crypto-utils-bytes-refactor OpenSpec change"`
---
## Summary
| Package | Domain path | Chunks |
|---------|-------------|--------|
| `@suaveplan/bytes` | `universal/primitives/bytes` | 1 |
| `@suaveplan/json` | `universal/data/json` | 2 |
| `@suaveplan/locale-data` | `universal/i18n/locale-data` | 3 |
| `@suaveplan/utils` | `universal/data/utils` | 4–5 |
| crypto-utils follow-up | OpenSpec change only | 5.10 |
**All dependencies already exist in genesis — no blocking work required before starting.**