# Capability Spec Delta: diff (diff-stringify) **Package**: `@suaveplan/diff` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Multi-line patch rendering The package SHALL expose `stringifyPatch(patch: JsonPatch, options?: StringifyPatchOptions): string` that renders the input `JsonPatch` as a multi-line string, one line per operation, joined by `\n`. The grammar for each line SHALL be: - `add -> ` - `remove ` - `replace -> ` - `move -> ` - `copy -> ` - `test -> ` Path tokens SHALL be the operation's `path` (or `from`) field rendered verbatim. Value tokens SHALL be produced by `options.valueFormatter` (default: `JSON.stringify`). When the input patch is empty, the function SHALL return the empty string. #### Scenario: full RFC 6902 op set renders correctly - **WHEN** a caller invokes `stringifyPatch([{ op: "add", path: "/a", value: 1 }, { op: "remove", path: "/b" }, { op: "move", from: "/c", path: "/d" }])` - **THEN** the result equals `"add /a -> 1\nremove /b\nmove /c -> /d"` #### Scenario: empty patch produces empty string - **WHEN** a caller invokes `stringifyPatch([])` - **THEN** the result is the empty string **Verified by:** `src/utils/stringify-patch/stringify-patch.test.ts::"renders all six RFC 6902 operations"` AND `src/utils/stringify-patch/stringify-patch.test.ts::"returns empty string for empty patch"` --- ### Requirement: Value formatter injection point `StringifyPatchOptions.valueFormatter?: (value: unknown) => string` SHALL be invoked exactly once per value-bearing operation (`add`, `replace`, `test`) when supplied. When omitted, the default formatter SHALL be `JSON.stringify`. Operations without a `value` field (`remove`, `move`, `copy`) SHALL NOT invoke the formatter. #### Scenario: custom formatter applied - **WHEN** a caller invokes `stringifyPatch([{ op: "add", path: "/secret", value: "abc" }], { valueFormatter: (v) =>`***`})` - **THEN** the result equals `"add /secret -> ***"` #### Scenario: formatter not invoked for value-less ops - **WHEN** a caller invokes `stringifyPatch([{ op: "remove", path: "/a" }], { valueFormatter: spy })` - **THEN** `spy` is invoked zero times **Verified by:** `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"valueFormatter receives every value-bearing op exactly once"` AND `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"valueFormatter not invoked for remove / move / copy"` --- ### Requirement: Indent prefix and uppercase op casing `StringifyPatchOptions.indent?: string` SHALL be prepended to every rendered line (default: empty string). `StringifyPatchOptions.uppercaseOps?: boolean` SHALL render op names in uppercase when `true` (default: `false`, matching RFC 6902). #### Scenario: indent prepended per line - **WHEN** a caller invokes `stringifyPatch([{ op: "add", path: "/a", value: 1 }, { op: "remove", path: "/b" }], { indent: " " })` - **THEN** the result equals `" add /a -> 1\n remove /b"` #### Scenario: uppercase op casing - **WHEN** a caller invokes `stringifyPatch([{ op: "add", path: "/a", value: 1 }], { uppercaseOps: true })` - **THEN** the result equals `"ADD /a -> 1"` **Verified by:** `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"indent prefix prepended to every line"` AND `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"uppercaseOps flag flips op casing"` --- ### Requirement: Defensive guard for unknown ops When the input patch contains an operation whose `op` field is not one of the six RFC 6902 operations, the function SHALL throw `DiffError` with code `DIFF.INVALID_PATCH_OP`. The `DiffError` message SHALL include the unknown op name for diagnostic purposes. #### Scenario: unknown op throws structured error - **WHEN** a caller invokes `stringifyPatch([{ op: "explode" as never, path: "/a" }])` - **THEN** the call throws `DiffError` with code `DIFF.INVALID_PATCH_OP` AND the message contains `"explode"` **Verified by:** `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"throws DIFF.INVALID_PATCH_OP for unknown patch operation"` --- ### Requirement: OTel observability for stringify `stringifyPatch` SHALL emit a passive OTel span event `diff.stringify-patch` carrying a numeric `opCount` attribute equal to `patch.length`. The span event MUST be emitted regardless of whether the caller has configured a tracer. #### Scenario: span event emitted with correct opCount - **WHEN** a caller invokes `stringifyPatch(patch)` with `patch.length === 5` under an in-memory `TracerProvider` - **THEN** one span event named `diff.stringify-patch` is recorded with `opCount: 5` **Verified by:** `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"emits diff.stringify-patch span event with opCount attribute"` --- ### Requirement: Subpath export The package SHALL expose the new module at the `./utils/stringify-patch` subpath in `package.json::exports`. Importing only `stringifyPatch` from that subpath SHALL NOT pull in unrelated `@suaveplan/diff` modules (`diff/diff`, `patch/patch`, `history/history`, `sync/sync`). #### Scenario: subpath import resolves - **WHEN** a caller writes `import { stringifyPatch } from "@suaveplan/diff/utils/stringify-patch"` - **THEN** TypeScript resolves the import **Verified by:** `src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts::"subpath ./utils/stringify-patch resolves and tree-shakes"`