Files
suaveplan/openspec/changes/diff-stringify/specs/diff/spec.md
2026-05-27 23:29:53 +01:00

5.6 KiB

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"