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

60 KiB
Raw Permalink Blame History

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

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:
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:
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:
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:
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:
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:
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:
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:
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

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:
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:
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:
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:
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

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:
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:
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:
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:
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

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:
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:
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):
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.