Initial Commit
This commit is contained in:
114
openspec/changes/diff-stringify/specs/diff/spec.md
Normal file
114
openspec/changes/diff-stringify/specs/diff/spec.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user