Files
suaveplan/openspec/archive/2026-04-29-utils-spec-conformance/specs/utils/spec.md
2026-05-27 23:29:53 +01:00

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, or info
  • 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 Error instances are passed
  • THEN dedup uses the message field, 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 / isEmpty are 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 a nor b is mutated
  • AND arrays are replaced rather than concatenated
  • AND skipUndefined: true does not overwrite with undefined; default behaviour does

Scenario: Prototype pollution is rejected

  • WHEN deepMerge encounters a __proto__ key
  • THEN UtilsError(UTILS/OBJECT.PROTOTYPE_POLLUTION) is thrown

Scenario: pick / omit / flattenObject behave as documented

  • WHEN pick, omit, keepKeys, removeKeys are called
  • THEN only the specified keys are retained / removed; keepKeys/removeKeys are aliases for pick/omit
  • WHEN flattenObject is 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 getPlatform returns "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, max64 are read
  • THEN they equal 2^-14, 65504, 2^-126, 32-bit max, Number.MIN_VALUE, Number.MAX_VALUE respectively

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 unpackFloatN is called with the wrong-length buffer
  • THEN an error is thrown

Scenario: Range predicates classify correctly

  • WHEN isInFloat16Range / isInFloat32Range are called
  • THEN in-range numbers return true, out-of-range return false, NaN/Infinity return true

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 pseudoRandom returns a value
  • THEN the value is one of the input array's elements
  • AND the function is type-safe — T[] produces T

Scenario: Injectable rng or Math.random fallback

  • WHEN no seed but a custom rng is provided
  • THEN the custom rng drives selection
  • WHEN neither seed nor rng is provided
  • THEN Math.random drives 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_2 are 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"

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
  • 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 isHex is called with an even-length hex string
  • THEN the result is true

Scenario: Odd length, non-hex chars, non-strings return false

  • WHEN isHex is 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, and DEFAULT_MAX_MESSAGE_SIZE are read
  • THEN they equal 10 KB, 1 MB, and 1 MB respectively

Scenario: getDataSize handles every supported type

  • WHEN getDataSize is 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 validateSize does not throw AND isWithinSizeLimit returns true
  • WHEN data exceeds the limit
  • THEN validateSize throws UtilsError(UTILS/VALIDATION.SIZE_EXCEEDED) AND isWithinSizeLimit returns false

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 parseSize is called with "1KB", "1.5MB", "100", "100B"
  • THEN the result is the correct byte count
  • WHEN parseSize is called with an invalid format
  • THEN UtilsError(UTILS/VALIDATION.PARSE_FAILED) is thrown

Scenario: truncateToSize is exact and idempotent on small input

  • WHEN truncateToSize is 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