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

11 KiB

Spec: @suaveplan/json

Status: shipped at 0.1.0 Package path: primitives/json Landed: 2026-04-20 Spec heading conformance: json-spec-conformance (2026-04-26)

Capability

A safe JSON parse/stringify surface for the Suaveplan monorepo. Wraps the platform JSON global with prototype-pollution defense, AbortSignal handling, structured JsonError codes, OpenTelemetry spans, and a tryParse / isValidJSON / getStringifySize ergonomic layer that the platform global does not provide.

Requirements

Requirement: Safe JSON parsing with structured errors

The package SHALL export safeParse<T>(text, options?) that returns the parsed value on success and throws JsonError with code JSON.PARSE.FAILED on invalid syntax. It MUST accept an AbortSignal; if already aborted before parsing begins, it MUST throw JsonError(JSON.PARSE.FAILED).

Scenario: Valid JSON parses successfully

  • WHEN safeParse is called with valid JSON (object, array, null, string, number, boolean, nested object)
  • THEN the value is returned with the expected shape

Scenario: Invalid JSON throws JsonError

  • WHEN safeParse is called with malformed text
  • THEN a JsonError with code JSON.PARSE.FAILED is thrown

Scenario: Already-aborted signal aborts before parsing

  • WHEN safeParse is called with an AbortSignal that is already aborted
  • THEN a JsonError is thrown without parsing the input

Verified by: src/safe-json/safe-json.test.ts::"parses valid JSON" AND src/safe-json/safe-json.test.ts::"parses a JSON array" AND src/safe-json/safe-json.test.ts::"parses null literal" AND src/safe-json/safe-json.test.ts::"parses string literal" AND src/safe-json/safe-json.test.ts::"parses number literal" AND src/safe-json/safe-json.test.ts::"parses boolean literals" AND src/safe-json/safe-json.test.ts::"parses nested objects successfully" AND src/safe-json/safe-json.test.ts::"throws on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.FAILED code on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws when AbortSignal is already aborted"

Requirement: Prototype-pollution defense via forbidden-key list

safeParse SHALL reject keys in the forbiddenKeys list (default: __proto__, constructor, prototype) by throwing JsonError with code JSON.PARSE.PROTOTYPE_POLLUTION. Callers MAY override the list (including the empty list to disable the check, or a custom set).

Scenario: Default forbidden keys are rejected

  • WHEN safeParse is called with JSON containing __proto__, constructor, or prototype
  • THEN a JsonError with code JSON.PARSE.PROTOTYPE_POLLUTION is thrown

Scenario: Forbidden-keys list is configurable

  • WHEN safeParse is called with { forbiddenKeys: [] }

  • THEN __proto__ parses without throwing

  • WHEN safeParse is called with { forbiddenKeys: ["secret"] } and JSON containing secret

  • THEN a JsonError with code JSON.PARSE.PROTOTYPE_POLLUTION is thrown

Verified by: src/safe-json/safe-json.test.ts::"rejects __proto__ key" AND src/safe-json/safe-json.test.ts::"rejects constructor key" AND src/safe-json/safe-json.test.ts::"rejects prototype key" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys" AND src/safe-json/safe-json.test.ts::"allows custom forbidden keys — empty list allows __proto__" AND src/safe-json/safe-json.test.ts::"allows custom forbidden keys — custom key blocks the key"

Requirement: Safe JSON stringification with structured errors

The package SHALL export safeStringify(value, options?) returning a JSON string. It MUST throw JsonError with code JSON.STRINGIFY.CIRCULAR on circular references, and JSON.STRINGIFY.FAILED for any non-circular exception (e.g. BigInt) thrown by the underlying JSON.stringify. DAG sharing (the same object reachable via multiple non-cyclic paths) MUST be allowed without throwing. It MUST accept an AbortSignal; if already aborted before stringification begins, it MUST throw JsonError(JSON.STRINGIFY.FAILED).

Scenario: Plain values serialise as JSON

  • WHEN safeStringify is called with a plain object, array, primitive, or null
  • THEN the output is the standard JSON representation, optionally indented when space is provided

Scenario: Circular references throw STRINGIFY.CIRCULAR

  • WHEN safeStringify is called with a value that has a cycle through a property
  • THEN a JsonError with code JSON.STRINGIFY.CIRCULAR is thrown

Scenario: DAG sharing is permitted

  • WHEN the same non-cyclic object is referenced at multiple paths
  • THEN stringification succeeds

Scenario: Non-serialisable values throw STRINGIFY.FAILED

  • WHEN safeStringify is called with a BigInt (or any value JSON.stringify rejects non-cyclically)
  • THEN a JsonError with code JSON.STRINGIFY.FAILED is thrown

Scenario: Already-aborted signal aborts before stringifying

  • WHEN safeStringify is called with an aborted AbortSignal
  • THEN a JsonError is thrown without stringifying

Verified by: src/safe-json/safe-json.test.ts::"serialises a plain object" AND src/safe-json/safe-json.test.ts::"serialises with space option" AND src/safe-json/safe-json.test.ts::"serialises arrays" AND src/safe-json/safe-json.test.ts::"serialises null" AND src/safe-json/safe-json.test.ts::"serialises primitives" AND src/safe-json/safe-json.test.ts::"throws on circular reference" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.CIRCULAR code on circular reference" AND src/safe-json/safe-json.test.ts::"throws when AbortSignal is already aborted" AND src/safe-json/safe-json.test.ts::"handles nested objects without circular refs" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)" AND src/safe-json/safe-json.test.ts::"allows same object reference at different paths (not a cycle)"

Requirement: Non-throwing parse helpers

The package SHALL export tryParse<T>(text, options?) that returns undefined instead of throwing on any failure (including prototype-pollution rejections), and isValidJSON(text) returning true for any text that parses (including the literal "null", strings, numbers, booleans, arrays — i.e. NOT a naïve tryParse(text) !== undefined check).

Scenario: tryParse swallows errors

  • WHEN tryParse is called with invalid JSON or with prototype-polluting JSON under default forbidden keys
  • THEN the return value is undefined

Scenario: tryParse passes options through to safeParse

  • WHEN tryParse(text, options) is called
  • THEN the call delegates to safeParse(text, options) so the same options apply

Scenario: isValidJSON treats "null" as valid

  • WHEN isValidJSON("null") is called
  • THEN the result is true

Scenario: isValidJSON covers every JSON shape

  • WHEN isValidJSON is called with valid JSON of any shape (object, array, string, number, boolean, null)
  • THEN the result is true

Scenario: isValidJSON rejects malformed and prototype-polluting input

  • WHEN isValidJSON is called with invalid text, the empty string, or a JSON document containing forbidden keys
  • THEN the result is false

Verified by: src/safe-json/safe-json.test.ts::"returns undefined on invalid JSON" AND src/safe-json/safe-json.test.ts::"returns parsed value on valid JSON" AND src/safe-json/safe-json.test.ts::"returns null for JSON null" AND src/safe-json/safe-json.test.ts::"returns undefined when safeParse throws for prototype pollution" AND src/safe-json/safe-json.test.ts::"returns parsed object" AND src/safe-json/safe-json.test.ts::"passes options through to safeParse" AND src/safe-json/safe-json.test.ts::"returns true for valid JSON object" AND src/safe-json/safe-json.test.ts::"returns false for invalid JSON" AND src/safe-json/safe-json.test.ts::"returns true for JSON null literal" AND src/safe-json/safe-json.test.ts::"returns true for JSON string" AND src/safe-json/safe-json.test.ts::"returns true for JSON number" AND src/safe-json/safe-json.test.ts::"returns true for JSON boolean" AND src/safe-json/safe-json.test.ts::"returns true for JSON array" AND src/safe-json/safe-json.test.ts::"returns false for empty string" AND src/safe-json/safe-json.test.ts::"returns false for prototype-polluting JSON (forbidden key)"

Requirement: Stringify byte-size estimator

The package SHALL export getStringifySize(value) returning the UTF-8 byte length of JSON.stringify(value).

Scenario: Returns byte length of serialised value

  • WHEN getStringifySize is called with any JSON-serialisable value
  • THEN the result equals utf8ByteLength(JSON.stringify(value))

Scenario: Multi-byte characters count correctly

  • WHEN getStringifySize is called with a string containing non-ASCII characters
  • THEN the result counts each character's UTF-8 byte width

Verified by: src/safe-json/safe-json.test.ts::"returns byte size of serialised value" AND src/safe-json/safe-json.test.ts::"returns correct size for string with multi-byte chars" AND src/safe-json/safe-json.test.ts::"returns correct size for simple number"

Requirement: Round-trip identity

safeStringify followed by safeParse SHALL be the identity on any JSON-serialisable value (modulo Date/undefined JSON limitations baked into the platform global).

Scenario: Round-trip preserves arbitrary JSON-serialisable values

  • WHEN any JSON-serialisable value is passed through safeStringify then safeParse
  • THEN the output is structurally equal to the input

Verified by: src/safe-json/safe-json.test.ts::"safeParse round-trips any JSON-serialisable value" AND src/safe-json/safe-json.test.ts::"safeStringify + safeParse is identity for clean objects"

Requirement: OTel spans and stable error-code namespace

safeParse and safeStringify SHALL emit OpenTelemetry spans via @opentelemetry/api. All error codes MUST be registered under the JSON.* namespace via defineCodes from @suaveplan/error.

Scenario: Errors carry the JSON.* code namespace

  • WHEN any JsonError is thrown
  • THEN the code field begins with JSON. (e.g. JSON.PARSE.FAILED, JSON.PARSE.PROTOTYPE_POLLUTION, JSON.STRINGIFY.CIRCULAR, JSON.STRINGIFY.FAILED)

Verified by: src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.FAILED code on invalid JSON" AND src/safe-json/safe-json.test.ts::"throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.CIRCULAR code on circular reference" AND src/safe-json/safe-json.test.ts::"throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)"

Error codes

JSON.PARSE.FAILED, JSON.PARSE.PROTOTYPE_POLLUTION, JSON.STRINGIFY.CIRCULAR, JSON.STRINGIFY.FAILED

Quality gates

  • Istanbul coverage on all 4 thresholds: 100%.
  • Docs coverage: 100%.