Files
2026-05-27 23:29:53 +01:00

162 lines
11 KiB
Markdown

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