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

115 lines
5.6 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 <path> -> <value>`
- `remove <path>`
- `replace <path> -> <value>`
- `move <from> -> <path>`
- `copy <from> -> <path>`
- `test <path> -> <value>`
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"`
<!-- markdownlint-enable MD024 -->