60 KiB
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; updatecacheDirto"../../../node_modules/.vite/packages/bytes"; add@opentelemetry/api,@suaveplan/error,@suaveplan/typesto theexternalarray - 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); removesetupFilesif present (bytes has no polyfill needs) - Add
"universal/primitives/bytes"to the rootpackage.jsonworkspaces array - Run
bun installto 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.tsfor 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-exportingBytesError,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.tsfor the original implementation - Create
src/encoding/encoding.test.tswith 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.tsimplementing all 6 functions using nativeTextEncoder/TextDecoderandbtoa/atob; throwBytesErrorwith 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.tsandsrc/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.tsusing nativecrypto.randomUUID()forgenerateUuid; parse/serialise with hex arithmetic (no externaluuidpackage) - Create
src/uuid/uuid.schemas.ts(Zod UUID string schema) andsrc/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.tsto capture baselines; paste output intoREADME.mdbenchmarks section - Commit:
"bench(bytes): add encoding benchmarks"
Task 1.8: README + docs check
Module
.mdfiles are created alongside each TDD task (1.3–1.5). This task adds the package-level README and verifies 100% docs coverage.
- Create
README.mdatuniversal/primitives/bytes/README.mdwith: 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/; updateopenspec/specs/bytes/spec.mdwith 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 fromuniversal/primitives/bytes/and adapt (name@suaveplan/json, exports:".","./safe-json","./errors") - Register workspace in root
package.json, runbun 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/andtypes/ - 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.tsfor 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:safeParseuses a reviver to detect forbidden keys;safeStringifytracks seen objects to detect circular refs;tryParsewrapssafeParsein try/catch;isValidJSONwrapstryParse;getStringifySizeusesTextEncoder.encode(JSON.stringify(v)).byteLength; add OTel span onsafeParseandsafeStringify - Create
src/safe-json/safe-json.schemas.tsandsrc/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.tsbenchmarkingsafeParseandsafeStringifyvs nativeJSON.parse/JSON.stringifyfor 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.tsandlanguages.ts(not split into separate.data.tsfiles) 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 siblingcountries.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 ongetCountryandgetLanguagecalls (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 correctCountryItemshape) - 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.tswith all country records from the source + the 4 lookup functions (getCountry,getAllCountries,getCountriesByContinent,getCountriesByCurrency); throwLocaleDataErrorwithLOCALE_DATA_CODES.COUNTRY.NOT_FOUNDfor unknown codes - Create
src/countries/countries.schemas.ts(re-exportCountryItemSchema, defineCountryCodeSchema = 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.tswith all records + 4 functions; throwLocaleDataErrorwithLOCALE_DATA_CODES.LANGUAGE.NOT_FOUNDfor 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/typesdep 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.tsfor 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; defaultbufferSize: 10 - Export a module-level
debugsingleton (createDebug()) - No OTel — debug is an instrumentation primitive itself; adding spans would be circular
- Export
- 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.tswith all 15 functions; noany— use precise generics; throwUtilsErrorwithUTILS_CODES.OBJECT.PROTOTYPE_POLLUTIONif a deep merge would pollute__proto__; add OTel span ondeepMergeanddeepClone(these are the hot paths) - Create
src/object/object.schemas.tsandsrc/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 fromprocess.memoryUsage()in Node/Bun andperformance.memoryin browser;getPlatform,isNode,isBrowser,isBunas convenience exports using the default adapter; throwUtilsErrorwithUTILS_CODES.PLATFORM.UNAVAILABLEwhen an API is absent - Create
src/platform/platform.schemas.ts(Zod schemas forMemoryUsage,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.tsusingDataView-based bit manipulation (same approach as original); addisInFloat16Range,isInFloat32Range,isInFloat64Range, min/max constants for all three; OTel histogram onpackFloat16/32/64call 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; throwUtilsErrorwithUTILS_CODES.RANDOM.EMPTY_ARRAY/RANDOM.INDEX_OUT_OF_BOUNDS; injectablerngoption 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.tsusing 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.tsandsound.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; preserveSoundSpritestype from original; throwUtilsErrorwithUTILS_CODES.SOUND.NO_VARIATIONSwhen 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(onlygenerateIdandparseTextWithLinks—parseTemplateis 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;generateIdusescrypto.randomUUID()internally and optionally trims to requested length;parseTextWithLinksuses 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; throwUtilsErrorwithUTILS_CODES.VALIDATION.SIZE_EXCEEDED; useTextEncoderfor string byte measurement; exportSIZE_LIMITS,DEFAULT_MAX_MESSAGE_SIZE; add OTel counter onvalidateSizecalls - 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.tsbenchmarkingdeepMergeanddeepClonefor small/medium objects - Create
src/__benchmarks__/number.bench.tsbenchmarkingpackFloat16/32/64throughput - Run each and paste baselines into README
- Commit:
"bench(utils): add object and number benchmarks"
Task 5.8: README + docs check
Module
.mdfiles 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.mdatuniversal/data/utils/README.mdwith: 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/; writeopenspec/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-utilsduplicates hex/base64/utf8/bytes/concat encoding that now lives canonically in@suaveplan/bytes. Deduplication reduces maintenance surface and ensures consistent behaviour. - What: Add
@suaveplan/bytesas a dep tocrypto-utils; replace duplicate encoding utilities with re-exports or direct imports from@suaveplan/bytes.base58stays incrypto-utils(blockchain-specific). - Impact:
crypto-utilsgains a newworkspace:^dep; its exported surface is preserved (re-export or wrapping); consumers see no API change.
- Why:
- 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.