@haneinfo/diff
v0.1.1
Published
Pure JSON structural diff: stable change lists with keyed array matching, display truncation and hard input limits. No database, Nest module or capability.
Readme
@haneinfo/diff
Pure JSON structural diff for Haneinfo packages and Apps (approved decision 202610-d9a5). No
database, no Nest module, no capability: two JSON values and options in, a change list out.
@haneinfo/definition-registry (definition version diffs) and @haneinfo/state-machine
(instance revision diffs) use it, and Apps may call it directly.
import { diffJson } from '@haneinfo/diff';
const result = diffJson(before, after, { keys: { 'spec.**.fields': ['id', 'key'] } });
// { summary: { added, removed, changed }, changes: [...], truncated }Change list
Each change is { path: string[], op: 'ADDED' | 'REMOVED' | 'CHANGED', before?, after?,
beforeTruncated?, afterTruncated? }.
- Object keys appear as-is in
path; array elements as[index]or[keyName=keyValue]. ADDEDhas onlyafter,REMOVEDonlybefore. A value whose type changes (object to array, scalar to object) is oneCHANGEDat that path.- There is no "moved" operation. A reordered positional array shows as changes at the positions.
nulland an absent key are different.undefinedas a whole side means "absent", sodiffJson(undefined, x)is a singleADDEDat the root path[].- Changes are sorted by path, segment by segment in code-unit order, a parent before its children;
two positional segments compare by number (
[2]before[10]). The order is stable. summarycounts every change, including those cut off bymaxChanges.
Both sides are normalized through JSON first (undefined properties vanish, dates become strings),
so the result describes exactly what a JSON column or HTTP body carries.
Array matching
For each array, the first keys pattern matching the array's path decides:
| Rule | Pairing |
| --- | --- |
| 'id' | by that property |
| ['id', 'key'] | each element by the first listed property it has (labelled with the first name) |
| false | by position |
Patterns are path segments joined by .; * matches one segment and ** any number (including
none), e.g. spec.**.columns. Without a matching pattern, id, key, code and name are tried
in that order: the first property that every element on both sides has as a string or finite
number, unique per side, is used. When the chosen key is missing on any element or duplicated,
the array falls back to position. Scalar arrays are always positional. 1 and "1" are different
keys.
Limits
| Limit | Default | Behaviour |
| --- | --- | --- |
| Input per side | 1 MB of UTF-8 JSON text | DiffError DIFF_INPUT_TOO_LARGE (details.side) |
| Nesting depth | 32 ({} is depth 1) | DiffError DIFF_TOO_DEEP (cycles also fail here) |
| Changes returned | 5,000 (maxChanges) | cut in path order, truncated: true |
| One displayed value | 2,000 characters (maxValueChars) | strings keep their first characters (code points); objects and arrays over the limit are omitted; marked beforeTruncated / afterTruncated |
Values JSON cannot represent (e.g. bigint) fail with DIFF_INVALID_INPUT; bad options with
DIFF_INVALID_OPTIONS. Input and depth limits are fixed; the two output limits are options so
callers can expose them as configuration.
Security
Every string in a change list — values and path segments, which can carry key values — is user
content. Render it as plain text; MCP tools wrap it as $userContent.
Helpers and test vectors
compareDiffPaths, matchesDiffPathPattern, limitDisplayValue, limitChangeValues,
summarizeChanges, finalizeChanges (sort, count, cut and bound caller-built change lists) and
utf8ByteLength are exported. DIFF_TEST_VECTORS and DIFF_ERROR_TEST_VECTORS are the shared
corpus every wrapper can replay to prove it applies the same rules.
