# 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(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(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%.