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