25 KiB
@suaveplan/utils — Specification
Package: @suaveplan/utils
Version at graduation: 0.1.0
Layer: Universal — zero browser/React/Node-specific APIs
Published: 2026-04-20. Spec heading conformance: utils-spec-conformance (2026-04-26).
Purpose
Zero-external-dependency utility library covering debugging, object manipulation, platform detection, IEEE 754 float packing, pseudo-random selection, email normalization, audio sprite management, string utilities, hex validation, and byte-size enforcement.
Requirements
Requirement: Debug logger with bounded deduplicating buffer
The package SHALL export a debug factory and a default debug instance whose log / warn / error / info methods deduplicate identical message keys per-channel within a bounded ring buffer. When the buffer fills, the oldest entry MUST be evicted. Error objects MUST be keyed by message. The default sink is console.*. bufferSize: 1 MUST keep only the most recent message.
Scenario: Identical messages are logged once per channel
- WHEN the same message is logged repeatedly via
log,warn,error, orinfo - THEN only one underlying console call happens per channel until the entry is evicted
Scenario: Buffer eviction is FIFO
- WHEN the buffer is full and a new unique message arrives
- THEN the oldest entry is evicted
Scenario: Console fallbacks do not crash
- WHEN the default console-backed debug instance is used for log / warn / error / info / multi-arg
- THEN no exception is thrown
Scenario: Error objects key on message
- WHEN
Errorinstances are passed - THEN dedup uses the
messagefield, not object identity
Verified by: src/debug/debug.test.ts::"logs unique messages only" AND src/debug/debug.test.ts::"evicts oldest entry when buffer is full" AND src/debug/debug.test.ts::"exports a default debug instance" AND src/debug/debug.test.ts::"uses console.log by default (no crash)" AND src/debug/debug.test.ts::"uses console.warn by default (no crash)" AND src/debug/debug.test.ts::"uses console.error by default (no crash)" AND src/debug/debug.test.ts::"uses console.info by default (no crash)" AND src/debug/debug.test.ts::"warn deduplicates independently in same buffer" AND src/debug/debug.test.ts::"error deduplicates independently in same buffer" AND src/debug/debug.test.ts::"info deduplicates independently in same buffer" AND src/debug/debug.test.ts::"handles Error objects by keying on message" AND src/debug/debug.test.ts::"handles multi-arg log calls" AND src/debug/debug.test.ts::"bufferSize=1 only keeps the most recent message"
Requirement: Object utilities — type guards, immutable merge/pick/omit, nested access, deep clone
The package SHALL export isObject, isPlainObject, isObjectOrArray, isEmpty, deepMerge (with optional skipUndefined), deepMergeAll, pick / omit / keepKeys / removeKeys, flattenObject (configurable separator), getNestedValue / setNestedValue / hasPath, and deepClone. deepMerge/deepMergeAll MUST throw UtilsError(UTILS/OBJECT.PROTOTYPE_POLLUTION) on __proto__/constructor/prototype keys, MUST NOT mutate inputs, and MUST replace arrays rather than merge them.
Scenario: Type guards classify objects correctly
- WHEN
isObject/isPlainObject/isObjectOrArray/isEmptyare called with various values - THEN they classify plain objects, arrays, null, primitives, Date and RegExp per their documented contract
Scenario: deepMerge composes nested objects without mutation
- WHEN
deepMerge(a, b)is called with nested objects - THEN the result merges nested keys AND neither
anorbis mutated - AND arrays are replaced rather than concatenated
- AND
skipUndefined: truedoes not overwrite withundefined; default behaviour does
Scenario: Prototype pollution is rejected
- WHEN
deepMergeencounters a__proto__key - THEN
UtilsError(UTILS/OBJECT.PROTOTYPE_POLLUTION)is thrown
Scenario: pick / omit / flattenObject behave as documented
- WHEN
pick,omit,keepKeys,removeKeysare called - THEN only the specified keys are retained / removed;
keepKeys/removeKeysare aliases forpick/omit - WHEN
flattenObjectis called with or without a custom separator - THEN dot-notation (or the custom separator) flattens nested keys; non-object leaves are preserved
Verified by: src/object/object.test.ts::"returns true for plain objects" AND src/object/object.test.ts::"returns false for null, arrays, primitives" AND src/object/object.test.ts::"returns true for Date and RegExp (non-array objects)" AND src/object/object.test.ts::"returns false for non-plain objects" AND src/object/object.test.ts::"returns true for objects and arrays" AND src/object/object.test.ts::"returns false for null and primitives" AND src/object/object.test.ts::"returns true for empty objects" AND src/object/object.test.ts::"returns false for non-empty objects" AND src/object/object.test.ts::"merges nested objects" AND src/object/object.test.ts::"does not mutate inputs" AND src/object/object.test.ts::"replaces arrays (does not merge them)" AND src/object/object.test.ts::"skipUndefined does not overwrite with undefined" AND src/object/object.test.ts::"overwrites with undefined when skipUndefined=false (default)" AND src/object/object.test.ts::"throws on __proto__ key" AND src/object/object.test.ts::"merges multiple objects in priority order" AND src/object/object.test.ts::"handles empty call" AND src/object/object.test.ts::"pick returns only specified keys" AND src/object/object.test.ts::"omit removes specified keys" AND src/object/object.test.ts::"pick handles missing keys gracefully" AND src/object/object.test.ts::"keepKeys is alias for pick" AND src/object/object.test.ts::"removeKeys is alias for omit" AND src/object/object.test.ts::"flattens nested object with dot notation" AND src/object/object.test.ts::"preserves non-object leaf values" AND src/object/object.test.ts::"uses custom separator"
Requirement: Platform detection with injectable adapter
The package SHALL export isBrowser, isNode, isBun, isEdge, isSSR, supportsWebWorkers, supportsServiceWorkers, and getPlatform() returning "browser" | "node" | "bun" | "edge" | "unknown". Memory and CPU usage MUST be exposed via an injectable adapter; missing adapter methods MUST throw UtilsError(UTILS/PLATFORM.UNAVAILABLE). Partial adapters MUST fall back to defaults. getPlatform MUST classify bun, node, and browser (window+document present) correctly and return "unknown" otherwise.
Scenario: Detection works under the test runner
- WHEN
isNode(),isBrowser(),isBun(),getPlatform()are called inside Vitest - THEN node-side helpers return appropriate booleans;
getPlatform()returns"bun"or"node"
Scenario: Platform adapter is injectable and partial-tolerant
- WHEN a mock adapter is supplied
- THEN memory / CPU calls delegate to it
- WHEN the adapter omits a method
- THEN the default fallback is used
- WHEN memory or CPU is unavailable in the underlying runtime
- THEN
UtilsError(UTILS/PLATFORM.UNAVAILABLE)is thrown
Scenario: getPlatform classifies all five buckets
- WHEN environment globals indicate Bun, Node, or Browser
- THEN
getPlatformreturns"bun","node", or"browser"accordingly - WHEN none of the markers is present
- THEN the result is
"unknown"
Verified by: src/platform/platform.test.ts::"isNode returns true in this test runner" AND src/platform/platform.test.ts::"isBrowser returns false in this test runner" AND src/platform/platform.test.ts::"getPlatform returns 'bun' or 'node'" AND src/platform/platform.test.ts::"isBun returns boolean" AND src/platform/platform.test.ts::"accepts a mock adapter" AND src/platform/platform.test.ts::"default adapter returns real memory usage" AND src/platform/platform.test.ts::"default adapter returns real CPU usage" AND src/platform/platform.test.ts::"partial adapter falls back to defaults for missing methods" AND src/platform/platform.test.ts::"returns memory stats when memoryUsage is available" AND src/platform/platform.test.ts::"throws PLATFORM.UNAVAILABLE when memoryUsage is missing" AND src/platform/platform.test.ts::"returns bun when hasBun=true" AND src/platform/platform.test.ts::"returns node when hasNode=true" AND src/platform/platform.test.ts::"calls fallback when neither bun nor node" AND src/platform/platform.test.ts::"returns 'browser' when window and document are defined" AND src/platform/platform.test.ts::"returns 'unknown' when window is missing" AND src/platform/platform.test.ts::"returns 'unknown' when document is missing" AND src/platform/platform.test.ts::"uses globalThis by default (returns unknown in Node)" AND src/platform/platform.test.ts::"returns cpu stats when cpuUsage is available" AND src/platform/platform.test.ts::"throws PLATFORM.UNAVAILABLE when cpuUsage is missing"
Requirement: IEEE 754 float packing and range constants
The package SHALL export precision constants (min16, max16, min32, max32, min64, max64) and lossless pack / unpack helpers for IEEE 754 half / single / double precision. NaN, ±Infinity, negative zero, and subnormals MUST round-trip correctly per IEEE 754 rules. unpackFloatN MUST throw on invalid buffer length. isInFloat16Range / isInFloat32Range / isInFloat64Range MUST classify NaN/Infinity as in-range and out-of-range floats correctly.
Scenario: Range constants match IEEE 754 definitions
- WHEN
min16,max16,min32,max32,min64,max64are read - THEN they equal
2^-14, 65504,2^-126, 32-bit max,Number.MIN_VALUE,Number.MAX_VALUErespectively
Scenario: pack/unpack round-trips for half / single / double
- WHEN any in-range float is packed and unpacked at the same precision
- THEN the value is recovered (subnormal positive/negative both round-trip)
- AND NaN, ±Infinity, ±0 are preserved at every precision
- AND values beyond the half-precision range saturate to ±Infinity
Scenario: Invalid buffer length throws
- WHEN
unpackFloatNis called with the wrong-length buffer - THEN an error is thrown
Scenario: Range predicates classify correctly
- WHEN
isInFloat16Range/isInFloat32Rangeare called - THEN in-range numbers return
true, out-of-range returnfalse, NaN/Infinity returntrue
Verified by: src/number/number.test.ts::"min16 is 2^-14" AND src/number/number.test.ts::"max16 is 65504" AND src/number/number.test.ts::"min32 is 2^-126" AND src/number/number.test.ts::"max32 is the max 32-bit float" AND src/number/number.test.ts::"min64 is Number.MIN_VALUE" AND src/number/number.test.ts::"max64 is Number.MAX_VALUE" AND src/number/number.test.ts::"packs and unpacks 0, 1, -1, 0.5, min16, max16" AND src/number/number.test.ts::"handles NaN, Infinity, -Infinity" AND src/number/number.test.ts::"handles negative zero" AND src/number/number.test.ts::"handles subnormal values (< min16)" AND src/number/number.test.ts::"unpackFloat16 returns subnormal positive when exp=0 and mant>0" AND src/number/number.test.ts::"unpackFloat16 returns subnormal negative when sign=1 exp=0 mant>0" AND src/number/number.test.ts::"values beyond max16 become Infinity" AND src/number/number.test.ts::"negative large values become -Infinity" AND src/number/number.test.ts::"throws for invalid buffer length" AND src/number/number.test.ts::"packFloat32/unpackFloat32 is lossless within float32 range" AND src/number/number.test.ts::"packFloat64/unpackFloat64 is lossless" AND src/number/number.test.ts::"packFloat32 handles NaN and Infinities" AND src/number/number.test.ts::"packFloat64 handles NaN and Infinities" AND src/number/number.test.ts::"unpackFloat32 works on non-zero offset Uint8Array" AND src/number/number.test.ts::"isInFloat16Range returns false for out-of-range values" AND src/number/number.test.ts::"isInFloat16Range returns true for values in range" AND src/number/number.test.ts::"isInFloat16Range returns true for Infinity and NaN" AND src/number/number.test.ts::"isInFloat32Range returns false for out-of-range values" AND src/number/number.test.ts::"isInFloat32Range returns true for values in range"
Requirement: Pseudorandom array selection
The package SHALL export pseudoRandom<T>(arr, index, seed?, opts?) returning a deterministic element when seeded and a random element when only rng is supplied (default Math.random). Empty arrays MUST throw UtilsError(UTILS/RANDOM.EMPTY_ARRAY); out-of-bounds indices (negative or >= arr.length) MUST throw UtilsError(UTILS/RANDOM.INDEX_OUT_OF_BOUNDS). Output MUST always be an element of the input array.
Scenario: Same seed yields the same result
- WHEN
pseudoRandom(arr, index, seed)is called twice with the same arguments - THEN the result is identical
- WHEN different seeds are used
- THEN results may differ
Scenario: Empty / out-of-bounds inputs throw
- WHEN the array is empty
- THEN
UtilsError(UTILS/RANDOM.EMPTY_ARRAY)is thrown - WHEN the index is negative or
>= arr.length - THEN
UtilsError(UTILS/RANDOM.INDEX_OUT_OF_BOUNDS)is thrown
Scenario: Output is always an element of the array
- WHEN
pseudoRandomreturns a value - THEN the value is one of the input array's elements
- AND the function is type-safe —
T[]producesT
Scenario: Injectable rng or Math.random fallback
- WHEN no seed but a custom
rngis provided - THEN the custom rng drives selection
- WHEN neither seed nor rng is provided
- THEN
Math.randomdrives selection
Verified by: src/random/pseudo.test.ts::"returns a deterministic result for the same seed" AND src/random/pseudo.test.ts::"throws for empty array" AND src/random/pseudo.test.ts::"throws for out-of-bounds index (too high)" AND src/random/pseudo.test.ts::"throws for out-of-bounds index (negative)" AND src/random/pseudo.test.ts::"produces different results for different seeds" AND src/random/pseudo.test.ts::"is type-safe — returns T for T[]" AND src/random/pseudo.test.ts::"uses injectable rng when no seed provided" AND src/random/pseudo.test.ts::"uses Math.random when neither seed nor rng provided" AND src/random/pseudo.test.ts::"works with a single-element array" AND src/random/pseudo.test.ts::"works with object arrays" AND src/random/pseudo.test.ts::"throws with correct error code for empty array" AND src/random/pseudo.test.ts::"throws with correct error code for out-of-bounds" AND src/random/pseudo.test.ts::"always returns an element that exists in the array"
Requirement: Provider-aware email normalization
The package SHALL export normalizeEmail(email) that strips Gmail dots and + aliases, Yahoo - aliases, Outlook + aliases, and ./+ aliases for generic domains. Malformed emails (missing @) MUST be returned unchanged. Empty local-part or empty domain after stripping MUST be handled gracefully without throwing.
Scenario: Provider-specific stripping
- WHEN a Gmail address with dots and
+-alias is normalized - THEN dots and the
+-alias suffix are removed - WHEN a Yahoo
--alias is normalized - THEN the
--alias suffix is removed - WHEN an Outlook
+-alias is normalized - THEN the
+-alias suffix is removed - WHEN a generic-provider address with dots and
+-alias is normalized - THEN dots and the
+-alias suffix are removed
Scenario: Malformed input is preserved
- WHEN the email lacks
@ - THEN the input is returned unchanged
- WHEN the input is
@-only or yields empty local/domain - THEN no exception is thrown
Verified by: src/email/email.test.ts::"strips dots and plus alias from Gmail" AND src/email/email.test.ts::"strips dash alias from Yahoo" AND src/email/email.test.ts::"strips plus alias from Outlook" AND src/email/email.test.ts::"strips dots and plus for generic domains" AND src/email/email.test.ts::"returns input unchanged for malformed email (no @)" AND src/email/email.test.ts::"handles at-sign only (empty local and domain)" AND src/email/email.test.ts::"handles empty local part after stripping gracefully"
Requirement: Sound-sprite parsing and variation selection
The package SHALL export parseSoundVariations(sprites) (and its alias convertSprites) which groups <name>_<n> keys by base name, ignoring keys without a numeric suffix, with empty bases (leading _), with whitespace-only suffixes, or with non-integer suffixes. getSoundKey(name, variations) MUST return a key that exists in the variations map and MUST throw UtilsError(UTILS/SOUND.NO_VARIATIONS) for unknown sound names.
Scenario: parseSoundVariations groups by numeric suffix
- WHEN sprite keys like
bell_1,bell_2are parsed - THEN they are grouped under
bell - WHEN a key lacks
_<n>, has an empty base, has a whitespace-only suffix, or has a non-integer suffix - THEN that key is ignored
Scenario: getSoundKey returns a valid variation or throws
- WHEN
getSoundKey(name, variations)is called for a known name - THEN the result is one of the variation keys
- WHEN the name is unknown
- THEN
UtilsError(UTILS/SOUND.NO_VARIATIONS)is thrown
Scenario: convertSprites is an alias for parseSoundVariations
- WHEN
convertSprites(sprites)is called - THEN it returns the same shape as
parseSoundVariations
Verified by: src/sound/sound.test.ts::"groups sprite keys by base name" AND src/sound/sound.test.ts::"ignores keys without underscore+number suffix" AND src/sound/sound.test.ts::"ignores keys where base is empty (leading underscore)" AND src/sound/sound.test.ts::"ignores keys with whitespace-only suffix" AND src/sound/sound.test.ts::"ignores keys with non-integer suffix" AND src/sound/sound.test.ts::"returns a key that exists in the variations map" AND src/sound/sound.test.ts::"throws UtilsError for unknown sound name" AND src/sound/sound.test.ts::"converts howler sprite format to variation map"
Requirement: String utilities — id generation and link extraction
The package SHALL export generateId(opts?) returning a non-empty random hex identifier (length defaults to 32; configurable shorter or longer than the underlying UUID), and parseTextWithLinks(text) returning an array of HTTP/HTTPS URL records (TextLink[]).
Scenario: generateId returns unique non-empty strings
- WHEN
generateId()is called many times - THEN each call returns a non-empty string AND values are unique
Scenario: generateId honours custom length
- WHEN
generateId({ length })is called with a length shorter or longer than 32 - THEN the output length matches the requested length
Scenario: parseTextWithLinks finds HTTP/HTTPS URLs
- WHEN
parseTextWithLinks(text)is called on text containing URLs - THEN each URL is returned as a
TextLink - WHEN the text has no links
- THEN an empty array is returned
Verified by: src/string/string.test.ts::"returns a non-empty string" AND src/string/string.test.ts::"generates unique values" AND src/string/string.test.ts::"accepts a custom length shorter than base" AND src/string/string.test.ts::"accepts a custom length longer than 32 chars" AND src/string/string.test.ts::"extracts URLs from plain text" AND src/string/string.test.ts::"returns empty array for text with no links"
Requirement: Hex string validation
The package SHALL export isHex(i) as a type-guard returning true only for non-empty, even-length, hex-only strings; non-strings, odd-length strings, and strings with non-hex characters MUST return false.
Scenario: Even-length hex returns true
- WHEN
isHexis called with an even-length hex string - THEN the result is
true
Scenario: Odd length, non-hex chars, non-strings return false
- WHEN
isHexis called with an odd-length string, a string with non-hex characters, or a non-string value - THEN the result is
false
Verified by: src/validate/validate.test.ts::"returns true for even-length hex strings" AND src/validate/validate.test.ts::"returns false for odd-length strings" AND src/validate/validate.test.ts::"returns false for non-hex characters" AND src/validate/validate.test.ts::"returns false for non-strings"
Requirement: Byte-size enforcement and parsing
The package SHALL export SIZE_LIMITS constants (including SMALL=10 KB, LARGE=1 MB), DEFAULT_MAX_MESSAGE_SIZE (1 MB), getDataSize (UTF-8 bytes for strings, byteLength for binary, 8 for numbers), validateSize (throws UtilsError(UTILS/VALIDATION.SIZE_EXCEEDED) over limit), isWithinSizeLimit (non-throwing boolean), formatSize (human-readable; "0 Bytes" for zero; configurable decimals), parseSize (throws UtilsError(UTILS/VALIDATION.PARSE_FAILED) on invalid format), and truncateToSize (binary-search string truncation).
Scenario: Constants match their documented values
- WHEN
SIZE_LIMITS.SMALL,SIZE_LIMITS.LARGE, andDEFAULT_MAX_MESSAGE_SIZEare read - THEN they equal 10 KB, 1 MB, and 1 MB respectively
Scenario: getDataSize handles every supported type
- WHEN
getDataSizeis called with an ASCII string, multibyte string, ArrayBuffer, Uint8Array, or number - THEN the result is the correct byte length (8 for numbers — float64)
Scenario: validateSize and isWithinSizeLimit honour the default
- WHEN data is within the explicit or default limit
- THEN
validateSizedoes not throw ANDisWithinSizeLimitreturnstrue - WHEN data exceeds the limit
- THEN
validateSizethrowsUtilsError(UTILS/VALIDATION.SIZE_EXCEEDED)ANDisWithinSizeLimitreturnsfalse
Scenario: formatSize / parseSize are inverse on canonical inputs
- WHEN
formatSize(bytes, decimals?)is called - THEN the output is human-readable (
"0 Bytes"for zero,"1.50 KB", etc.) with configurable decimals - WHEN
parseSizeis called with"1KB","1.5MB","100","100B" - THEN the result is the correct byte count
- WHEN
parseSizeis called with an invalid format - THEN
UtilsError(UTILS/VALIDATION.PARSE_FAILED)is thrown
Scenario: truncateToSize is exact and idempotent on small input
- WHEN
truncateToSizeis called with a string longer than the limit - THEN the result fits within the limit
- WHEN the input is already within the limit
- THEN the input is returned unchanged
Verified by: src/validation/size.test.ts::"SMALL is 10 KB" AND src/validation/size.test.ts::"LARGE is 1 MB" AND src/validation/size.test.ts::"is 1 MB" AND src/validation/size.test.ts::"returns byte length of ASCII string" AND src/validation/size.test.ts::"returns byte length of ArrayBuffer" AND src/validation/size.test.ts::"handles multibyte characters" AND src/validation/size.test.ts::"returns byte length of Uint8Array" AND src/validation/size.test.ts::"returns 8 for a number (float64)" AND src/validation/size.test.ts::"does not throw when within limit" AND src/validation/size.test.ts::"throws UtilsError when exceeding limit" AND src/validation/size.test.ts::"uses default maxSize (1 MB) when not specified" AND src/validation/size.test.ts::"returns true when data is within limit" AND src/validation/size.test.ts::"returns false when data exceeds limit" AND src/validation/size.test.ts::"uses default maxSize when not specified" AND src/validation/size.test.ts::"formats bytes as human-readable" AND src/validation/size.test.ts::"returns '0 Bytes' for zero" AND src/validation/size.test.ts::"uses custom decimal places" AND src/validation/size.test.ts::"parses '1KB' to 1024" AND src/validation/size.test.ts::"parses '1.5MB'" AND src/validation/size.test.ts::"parses plain bytes '100'" AND src/validation/size.test.ts::"parses '100B'" AND src/validation/size.test.ts::"throws for invalid format" AND src/validation/size.test.ts::"truncates string to byte limit" AND src/validation/size.test.ts::"returns string unchanged if already within limit"
Error Codes
| Code | Description |
|---|---|
UTILS/OBJECT.PROTOTYPE_POLLUTION |
__proto__ / constructor key detected |
UTILS/OBJECT.CIRCULAR |
Circular reference in deepClone |
UTILS/NUMBER.OUT_OF_RANGE |
Float value outside target precision range |
UTILS/RANDOM.EMPTY_ARRAY |
pseudoRandom called with empty array |
UTILS/RANDOM.INDEX_OUT_OF_BOUNDS |
index >= array.length |
UTILS/VALIDATION.SIZE_EXCEEDED |
Data exceeds maxSize bytes |
UTILS/VALIDATION.PARSE_FAILED |
Invalid size string format |
UTILS/PLATFORM.UNAVAILABLE |
Requested platform feature unavailable |
UTILS/SOUND.NO_VARIATIONS |
getSoundKey called for unknown sound name |
Quality Gates (at ship)
- Istanbul coverage: 100% statements / branches / functions / lines
- Docs coverage: 100% (12/12 files)
- Lint: clean (biome, 0 errors)
- TypeScript: clean (noEmit)
- Tests: 169 passing