Files
suaveplan/openspec/changes/utils-completion/proposal.md
2026-05-27 23:29:53 +01:00

12 KiB

Utils completion — Uint8Array helpers (countries / languages / safe-json placed elsewhere)

Package: @suaveplan/utils Status: PROPOSED Type: spec-conformance + feature

Why

The 2026-05-02 audit of packages/core/utils/src against ../suaveplan/packages/universal/utils/src flagged four legacy modules that genesis appears to lack: countries/, languages/, json/safe-json.ts, and uint/. Closer inspection shows three of the four are not gaps in @suaveplan/utils at all — they were intentionally hoisted into dedicated genesis packages (@suaveplan/locale-data, @suaveplan/json). The fourth, uint/, was dropped without a replacement and is the genuine gap.

The legacy uint/ module (340 L) is misnamed — it is a Uint8Array / encoding helper bag covering hex/base64/UTF-8 round-trips, format auto-detection, UUID interop, equality, concatenation, and slicing. Crypto-utils already covers hex / base64 / concat / xor / equality for cryptographic call sites, but those helpers are deliberately scoped to the crypto plugin (no UUID dependency, no auto-detection). Application-level callers — drag-and-drop file readers, clipboard handlers, query-string parsers — need a dependency-light Uint8Array module they can pull from @suaveplan/utils without dragging the entire crypto stack. Without it, every consumer reimplements bytesToHex / hexToBytes, drift is inevitable, and we accumulate the kind of subtle off-by-one bugs the legacy harness already pinned tests for.

Hoisting countries, languages, and safe-json to dedicated packages was the right call (each has its own catalog, schemas, and OTel surface), but the parity table for utils-completion must say so explicitly so the next agent doesn't accidentally re-introduce them under utils/ and create two copies that drift.

What changes

@suaveplan/utils (this change)

  • ADDED: uint8Array/ feature module under packages/core/utils/src/uint8-array/ — encoding-format conversion + Uint8Array helpers tier appropriate for application-level (non-cryptographic) call sites.
  • ADDED: bytesToHex(bytes) / hexToBytes(hex) — strict hex validation (rejects empty string, odd-length non-prefixed input, non-hex characters via opt-in strict mode).
  • ADDED: bytesToBase64(bytes) / base64ToBytes(base64) — uses btoa / atob in browser, Buffer fallback in Node.
  • ADDED: utf8ToBytes(str) / bytesToUtf8(bytes) — TextEncoder / TextDecoder round-trip.
  • ADDED: detectInputFormat(input) — heuristic format auto-detection for "hex" | "utf8" | "uint8array".
  • ADDED: validateInputTypeFormat(input, format) — runtime check that the JS type matches the declared format.
  • ADDED: inputToUint8Array(input, format) — universal coerce-to-bytes entry point.
  • ADDED: formatOutput(bytes, format) — universal format-from-bytes entry point.
  • ADDED: convertDataFormat(input, outputFormat, inputFormat?) — round-trip convertor with optional auto-detection.
  • ADDED: uint8ArrayToUuid(bytes) / uuidToUint8Array(uuid) — RFC 4122 16-byte ↔ canonical string interop.
  • ADDED: generateUuid() — UUID v4 emission backed by an injectable Rng from @suaveplan/types; defaults to crypto.getRandomValues. Genesis policy bans module-scope Math.random().
  • ADDED: uint8ArraysEqual(a, b) — non-constant-time equality (the constant-time variant lives in @suaveplan/crypto-utils::constant-time; documented in uint8-array.md to prevent misuse in cryptographic contexts).
  • ADDED: concatUint8Arrays(...arrays) — accepts both varargs and a single array argument; throws on non-Uint8Array elements via UtilsError (UINT8_ARRAY.INVALID_ARGUMENT).
  • ADDED: sliceUint8Array(data, splitAt) — returns [head, tail] tuple; throws on out-of-bounds via UtilsError (UINT8_ARRAY.OUT_OF_BOUNDS).
  • ADDED: DataFormat and InputType types under uint8-array.types.ts.
  • ADDED: uint8-array.schemas.ts — Zod schemas for DataFormat and convertDataFormat options.
  • ADDED: subpath export ./uint8-array in package.json::exports for tree-shaking.

Out-of-scope mappings (NOT changed in this proposal — documented for parity)

The 2026-05-02 audit specifically asked whether countries, languages, and safe-json belong in utils. The answer is no, they live elsewhere; the parity checklist below is the source of truth so this question is not re-litigated.

Capability Upstream location Genesis location Status
Country code/name registry packages/universal/utils/src/countries/ (3021 L) packages/i18n/locale-data/src/countries/ PRESENT-ELSEWHERE
Language code registry packages/universal/utils/src/languages/ (474 L) packages/i18n/locale-data/src/languages/ PRESENT-ELSEWHERE
Safe JSON parse / stringify (proto-pollution guard) packages/universal/utils/src/json/safe-json.ts (385 L) packages/data/json/src/safe-json/ PRESENT-ELSEWHERE
Uint8Array / encoding helpers packages/universal/utils/src/uint/uint.ts (340 L) NONE ADDED (this change)

Out of scope

  • Cross-publishing countries / languages / safe-json from @suaveplan/utils. Re-exporting them from utils would re-introduce duplicate publish surface and break tree-shaking for non-i18n consumers. Callers wanting locale data import @suaveplan/locale-data; callers wanting safe JSON import @suaveplan/json. The entries in the parity table above are PRESENT-ELSEWHERE, not delegated.
  • Constant-time uint8ArraysEqual. The cryptographic-grade comparison stays in @suaveplan/crypto-utils::constant-time to keep the dependency direction one-way (utils → no crypto). The uint8-array.md doc cross-links to it.
  • Streaming / chunked encoding. Application-level callers wanting backpressure-aware streaming use @suaveplan/streams (not yet on genesis); when it lands, a thin streamHexEncode adapter can re-export from there. No streaming work in this change.
  • InputType = ArrayBuffer overload. Legacy accepts ArrayBuffer indirectly via a private detector. Genesis narrows the public type to string | Uint8Array; callers with ArrayBuffer should new Uint8Array(buf) before passing. Documented in uint8-array.md.
  • UUID v1 / v3 / v5 / v7. Only v4 generation is in scope. Other versions belong in a future @suaveplan/uuid package or are obtainable from crypto.randomUUID + format conversion already.

Parity Checklist

Every legacy export traced, with disposition.

Capability Upstream Migrated Notes
bytesToHex ✅ ✅ Verbatim port; default permissive (matches legacy), strict mode opt-in.
hexToBytes ✅ ✅ Throws on empty input + non-hex; matches legacy error message.
bytesToBase64 ✅ ✅ btoa browser / Buffer Node fallback.
base64ToBytes ✅ ✅ atob browser / Buffer Node fallback.
utf8ToBytes / bytesToUtf8 ✅ ✅ Native TextEncoder / TextDecoder.
_detectFormat (private) ✅ 🚫 Replaced by detectInputFormat (the public version). Internal helper not needed once ArrayBuffer overload is dropped.
detectInputFormat ✅ ✅ Public API stays.
validateInputTypeFormat ✅ ✅ Throws via UtilsError instead of plain Error.
inputToUint8Array ✅ ✅ Verbatim.
formatOutput ✅ ✅ Verbatim.
convertDataFormat ✅ ✅ Verbatim.
uint8ArrayToUuid ✅ ✅ Backed by uuid catalog.
uuidToUint8Array ✅ ✅ Backed by uuid catalog.
generateUuid ✅ ⬆️ Enhanced: injectable Rng, defaults to crypto.getRandomValues. Genesis bans module-scope Math.random().
uint8ArraysEqual ✅ ✅ Non-constant-time (matches legacy). Crypto-grade comparison delegated to @suaveplan/crypto-utils.
concatUint8Arrays ✅ ✅ Same dual signature (varargs OR single array). UtilsError instead of plain Error.
sliceUint8Array ✅ ✅ UtilsError instead of plain Error.
DataFormat / InputType types ✅ ✅ Co-located in uint8-array.types.ts.
countries/* (3021 L) ✅ 🚫 PRESENT-ELSEWHERE: @suaveplan/locale-data::countries. Cross-publishing rejected (see Out of scope).
languages/* (474 L) ✅ 🚫 PRESENT-ELSEWHERE: @suaveplan/locale-data::languages. Cross-publishing rejected.
json/safe-json.ts (385 L) ✅ 🚫 PRESENT-ELSEWHERE: @suaveplan/json::safe-json. Cross-publishing rejected.

Source mapping

This change derives from ../suaveplan/packages/universal/utils/src/uint/. There is no enterprise/utils.md for the encoding sub-feature — source mapping cites upstream source directly.

Requirement Source file Source lines Notes
bytesToHex / hexToBytes packages/universal/utils/src/uint/uint.ts L20-L58 Verbatim; rewire Error → UtilsError with stable code.
bytesToBase64 / base64ToBytes packages/universal/utils/src/uint/uint.ts L62-L91 Verbatim.
utf8ToBytes / bytesToUtf8 packages/universal/utils/src/uint/uint.ts L95-L105 Verbatim.
detectInputFormat packages/universal/utils/src/uint/uint.ts L134-L149 Verbatim.
validateInputTypeFormat packages/universal/utils/src/uint/uint.ts L154-L170 Verbatim; UtilsError instead of Error.
inputToUint8Array packages/universal/utils/src/uint/uint.ts L175-L194 Verbatim.
formatOutput packages/universal/utils/src/uint/uint.ts L199-L216 Verbatim.
convertDataFormat packages/universal/utils/src/uint/uint.ts L225-L252 Verbatim.
uint8ArrayToUuid / uuidToUint8Array packages/universal/utils/src/uint/uint.ts L257-L271 Verbatim; backed by uuid catalog dep.
generateUuid packages/universal/utils/src/uint/uint.ts L275-L278 Enhanced: injectable Rng.
uint8ArraysEqual packages/universal/utils/src/uint/uint.ts L283-L289 Verbatim.
concatUint8Arrays packages/universal/utils/src/uint/uint.ts L294-L322 Verbatim.
sliceUint8Array packages/universal/utils/src/uint/uint.ts L330-L340 Verbatim.
DataFormat / InputType types packages/universal/utils/src/uint/uint.types.ts L1-L2 Verbatim.

Requirements with no source line

Requirement Justification
UtilsError codes for UINT8_ARRAY.INVALID_HEX, UINT8_ARRAY.INVALID_FORMAT, UINT8_ARRAY.INVALID_ARGUMENT, UINT8_ARRAY.OUT_OF_BOUNDS Genesis-wide policy that every thrown error route through @suaveplan/error (CLAUDE.md §Workflow rule 2). Legacy throws plain Error.
Injectable Rng for generateUuid Genesis-wide determinism rule; module-scope Math.random() is banned.
Zod schemas for DataFormat and convertDataFormat options Genesis-wide policy (CLAUDE.md "Zod schemas wired at every declared boundary").
Module renamed from uint/ to uint8-array/ The legacy name suggested typed-integer constructors (uint8(n)/uint16(n)/...) which it never delivered. The genesis name describes what the module actually does. The 2026-05-02 audit explicitly flagged this confusion.

Verification approach

  • src/uint8-array/uint8-array.test.ts — every helper round-trip; error scenarios for invalid hex, invalid format, out-of-bounds slice; equality false on length mismatch; concat with mixed input shapes; UUID 16-byte validation.
  • src/uint8-array/uint8-array.spec-conformance.test.ts — UtilsError codes thrown from production paths; injected Rng used by generateUuid; Zod schemas reject invalid formats; subpath ./uint8-array resolves and tree-shakes.

Impact

Affected dependents

@suaveplan/utils currently has zero downstream packages on genesis (verified by bunx turbo ls --filter=...@suaveplan/utils). This is purely additive surface area — the change cannot break anything that doesn't yet exist.

  • No dependents (verified by grep -rln "@suaveplan/utils" /home/ubuntu/web/genesis/packages/ --include=package.json returning only packages/core/utils/package.json itself).

When new consumers land, they import @suaveplan/utils/uint8-array or @suaveplan/utils (root barrel). Existing imports of @suaveplan/utils/debug, ./object, etc. continue to work — this change adds a subpath, never removes one.

Migration impact

None — additive only. No public surface is removed or renamed.