# JSON Deep Merge *Merge JSON by key path, not by line. Arrays get a strategy, not a coin flip.* ## Overview JSON is the lingua franca of configuration — `package.json`, `tsconfig.json`, `firebase.json`, `app.json`, Kubernetes manifests, feature-flag files, i18n bundles. It is also the single biggest source of noise conflicts in a typical repo. Two developers add two dependencies to the same `"dependencies"` block and Git sees overlapping lines where there is no semantic disagreement. Aura's JSON merger is a **key-path-aware deep merger** with configurable array strategies and lossless formatting. It understands that adding `"axios": "^1.6.0"` and adding `"zod": "^3.22.0"` to the same object are commutative operations. It understands that bumping `"version"` on both sides is a real conflict. And it preserves your file's original indent, key order, and trailing-newline discipline. > JSON conflicts are almost always false positives. The fix is not to merge harder — it is to merge at the right level. ## How It Works ### Parse into a canonical model Each of the three versions (ancestor, ours, theirs) is parsed into an order-preserving JSON DOM. Objects keep insertion order. Arrays keep index order. Numbers retain their original lexical form (`1.0`, `1.00`, `1e0` are distinct as text, identical as value — Aura treats them as equal for merge but writes back whatever style dominates). ### Walk three trees in lockstep The merger recurses into every key path present in any of the three trees. At each node it classifies the situation: | Ancestor | Ours | Theirs | Action | |----------|------|--------|--------| | present | unchanged | modified | take theirs | | present | modified | unchanged | keep ours | | present | modified | modified, same value | dedupe, no conflict | | present | modified | modified, different | conflict at this path | | absent | inserted | absent | keep ours | | absent | absent | inserted | take theirs | | absent | inserted | inserted, same value | dedupe | | absent | inserted | inserted, different | conflict at this path | | present | deleted | modified | conflict | | present | deleted | deleted | delete | The crucial bit: **object-level merges recurse into each key independently.** Adding two different keys to the same object is never a conflict. ### Arrays use a strategy Arrays are ambiguous. Is `["a", "b"]` a set, an ordered list, or a keyed table? Aura does not guess. Every array merge picks a strategy, chosen globally, per file, or per key path: | Strategy | Behavior | |------------|----------| | `union` | Union of elements by deep equality. Order: ancestor order first, then ours-new, then theirs-new. | | `concat` | Ancestor + new-from-ours + new-from-theirs. Duplicates kept. | | `replace` | If either side replaced the array wholesale, take that replacement. If both did with different values, conflict. | | `keyed:id` | Treat as a table keyed by the given field (e.g. `id`, `name`). Per-row deep merge. | | `prepend` / `append` | Non-destructive insertion on one side. | Defaults ship with sensible per-file rules: `package.json` uses `union` for `"keywords"` and `"files"`, `keyed:name` for nothing, and flags `"dependencies"` as an object (not an array). VS Code's `settings.json` uses `union` for most arrays. Kubernetes manifests use `keyed:name` for `env`, `ports`, `volumes`. ### Write back losslessly After merge, the DOM is serialized with the file's observed style: indent width, key quoting, trailing comma tolerance (JSONC), final newline. If ancestor used 2-space indent and sorted keys alphabetically, the merged file does too. ## Examples ### A: Two new dependencies Ancestor `package.json`: ```json { "name": "checkout", "version": "1.4.0", "dependencies": { "react": "^18.2.0", "express": "^4.18.0" } } ``` Ours adds `axios`: ```json { "name": "checkout", "version": "1.4.0", "dependencies": { "react": "^18.2.0", "express": "^4.18.0", "axios": "^1.6.0" } } ``` Theirs adds `zod`: ```json { "name": "checkout", "version": "1.4.0", "dependencies": { "react": "^18.2.0", "express": "^4.18.0", "zod": "^3.22.0" } } ``` Key-path diff: ```text ours = [Insert("/dependencies/axios", "^1.6.0")] theirs = [Insert("/dependencies/zod", "^3.22.0")] disjoint paths -> merge cleanly ``` Merged: ```json { "name": "checkout", "version": "1.4.0", "dependencies": { "react": "^18.2.0", "express": "^4.18.0", "axios": "^1.6.0", "zod": "^3.22.0" } } ``` ### B: Conflicting version bump Ours bumps `"version"` to `1.5.0`. Theirs bumps it to `2.0.0`. Same path, different scalar values. Aura raises: ```text CONFLICT: package.json :: /version ancestor: "1.4.0" ours: "1.5.0" theirs: "2.0.0" ``` The conflict is *localized to the path*. Every other key in the file merges cleanly. A `keep ours` resolution touches only `"version"`. ### C: Array of tags, union strategy Ancestor `tsconfig.json`: ```json { "include": ["src/**/*"] } ``` Ours: ```json { "include": ["src/**/*", "test/**/*"] } ``` Theirs: ```json { "include": ["src/**/*", "scripts/**/*"] } ``` Union strategy produces: ```json { "include": ["src/**/*", "test/**/*", "scripts/**/*"] } ``` ### D: Keyed table merge Ancestor `k8s/deploy.json`: ```json { "env": [ { "name": "PORT", "value": "3000" }, { "name": "NODE_ENV", "value": "production" } ] } ``` Ours edits `PORT`: ```json { "env": [ { "name": "PORT", "value": "8080" }, { "name": "NODE_ENV", "value": "production" } ] } ``` Theirs adds `LOG_LEVEL`: ```json { "env": [ { "name": "PORT", "value": "3000" }, { "name": "NODE_ENV", "value": "production" }, { "name": "LOG_LEVEL", "value": "info" } ] } ``` With `keyed:name`, Aura treats each entry as a row. Merged: ```json { "env": [ { "name": "PORT", "value": "8080" }, { "name": "NODE_ENV", "value": "production" }, { "name": "LOG_LEVEL", "value": "info" } ] } ``` Text merge would have produced a conflict on the `PORT` block because the surrounding array syntax shifted. ### E: Per-path strategy overrides A project can pin strategies in `.aura/merge.json`: ```json { "json": { "package.json": { "/keywords": "union", "/files": "union", "/scripts": "object-merge", "/peerDependencies": "object-merge" }, "**/k8s/*.json": { "/spec/template/spec/containers/*/env": "keyed:name", "/spec/template/spec/containers/*/ports": "keyed:containerPort" } } } ``` Glob patterns match files; JSON Pointer paths target nodes within them. Missing strategies fall back to language defaults. ## Edge Cases **JSONC and trailing commas.** `tsconfig.json` and VS Code settings allow comments and trailing commas. Aura's parser accepts both and round-trips them. Comments are attached to their nearest key and travel with it during merge. **Number precision.** `1.0` vs `1` vs `1e0` parse to the same value. Aura treats them as equal for conflict detection and writes back the ours-side formatting by default. Numeric precision loss (e.g. `0.1 + 0.2`) is never introduced — Aura does not evaluate, only transports. **Key reordering.** If ours reorders keys and theirs edits a value, Aura keeps ours's order and applies theirs's value edit. If both sides reorder differently with no value changes, the merger picks ours's order and reports the reorder in the merge summary rather than as a conflict. **Duplicate keys.** Strictly invalid JSON but occasionally seen. Aura preserves both and emits a warning; the merge treats the last occurrence as authoritative, matching parser behavior in most runtimes. **Very large files.** Lockfiles routinely exceed 1MB. The JSON merger streams these and applies a fast-path: if one side is unchanged from the ancestor, take the other side wholesale without walking the tree. **Object vs array mismatch.** If ancestor has an object at a path and one side replaces it with an array, that is treated as a wholesale replace and conflicts with any edit on the other side. Aura never silently coerces types. **Path-specific conflict messages.** Every conflict names the exact JSON Pointer path — `/dependencies/axios`, not "somewhere in package.json." For nested conflicts, the path is absolute and human-readable. Tooling that consumes Aura's conflict stream can route different paths to different reviewers (e.g. a security team owns `/scripts/**`). **Nested arrays.** A JSON document with arrays inside arrays (e.g. a 2D matrix) handles each level with the configured strategy. `union` at the outer level does not imply `union` at the inner level; strategies are per-path. For matrix-style data, `replace` is almost always the correct choice — unioning rows against each other rarely produces sensible output. **Schema-aware defaults.** Aura ships with a library of schema-aware defaults for common files: `package.json`, `tsconfig.json`, `composer.json`, `Cargo.toml` (handled through its TOML equivalent), `firebase.json`, `vercel.json`, `.vscode/settings.json`, and Kubernetes manifests. These defaults encode community consensus on which arrays are sets, which are ordered lists, and which are keyed tables. Projects can override any default via `.aura/merge.json`. **Diff-friendly output.** The serializer preserves the ancestor's key order where possible so that the post-merge diff against ours is minimal. This matters for code review: reviewers should see new keys as small additions, not as a reordered file that forces line-by-line re-reading. **Null handling.** `null` is a real value in JSON, distinct from "key absent." Setting a key to `null` on one side while theirs modifies it to a non-null value is a normal `modify/modify` conflict. Aura does not treat `null` as a deletion. **Performance.** Deep merges on files in the 10-50k-node range run in low milliseconds. Very large configuration bundles (e.g. generated i18n files with 200k keys) parse and merge in well under a second, enabled by the order-preserving DOM which uses open-addressing maps rather than balanced trees. ## See Also - [How AST merge works](/how-ast-merge-works) — the sibling algorithm for source code - [YAML merge](/yaml-merge) — the same ideas with comments and anchors - [.env merge](/env-merge) — per-key merge for dotenv files - [Merge strategies](/merge-strategies) - [Conflict resolution](/conflict-resolution)