@contentrain/mcp 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/{annotations-C-tQVmot.mjs → annotations-CXHPnS-M.mjs} +8 -1
- package/dist/annotations-CXHPnS-M.mjs.map +1 -0
- package/dist/{apply-manager-BybvnUVC.mjs → apply-manager-DsNq7Zhm.mjs} +7 -7
- package/dist/{apply-manager-BybvnUVC.mjs.map → apply-manager-DsNq7Zhm.mjs.map} +1 -1
- package/dist/{astro-parser-Cb9uuBpW.mjs → astro-parser-dhFgc2wA.mjs} +3 -3
- package/dist/{astro-parser-Cb9uuBpW.mjs.map → astro-parser-dhFgc2wA.mjs.map} +1 -1
- package/dist/{availability-Dgcmvv99.mjs → availability-DYHNoP0_.mjs} +5 -1
- package/dist/availability-DYHNoP0_.mjs.map +1 -0
- package/dist/{branch-lifecycle-DhzHfzs3.d.mts → branch-lifecycle-DGlgjnC7.d.mts} +32 -2
- package/dist/branch-lifecycle-DGlgjnC7.d.mts.map +1 -0
- package/dist/{branch-lifecycle-B6PXZ2N7.mjs → branch-lifecycle-DUOoyNWn.mjs} +32 -4
- package/dist/branch-lifecycle-DUOoyNWn.mjs.map +1 -0
- package/dist/{compiler-B6hNQeCC.mjs → compiler-CR9F0wZZ.mjs} +2 -2
- package/dist/{compiler-B6hNQeCC.mjs.map → compiler-CR9F0wZZ.mjs.map} +1 -1
- package/dist/{compiler-sfc.cjs-8yY7b5bx.mjs → compiler-sfc.cjs-Cpexs92i.mjs} +2 -2
- package/dist/{compiler-sfc.cjs-8yY7b5bx.mjs.map → compiler-sfc.cjs-Cpexs92i.mjs.map} +1 -1
- package/dist/{config-VlRKRwAS.mjs → config-B8a4JBT1.mjs} +2 -2
- package/dist/{config-VlRKRwAS.mjs.map → config-B8a4JBT1.mjs.map} +1 -1
- package/dist/{content-manager-Dn4tqDw1.d.mts → content-manager-Bx94DXSF.d.mts} +2 -2
- package/dist/{content-manager-Dn4tqDw1.d.mts.map → content-manager-Bx94DXSF.d.mts.map} +1 -1
- package/dist/{context-C3x6I3cE.mjs → context-CxpTATh4.mjs} +7 -7
- package/dist/context-CxpTATh4.mjs.map +1 -0
- package/dist/core/apply-manager.mjs +10 -11
- package/dist/core/config.d.mts +1 -1
- package/dist/core/config.mjs +2 -2
- package/dist/core/content-manager.d.mts +1 -1
- package/dist/core/content-manager.mjs +3 -3
- package/dist/core/context.d.mts +1 -1
- package/dist/core/context.mjs +5 -6
- package/dist/core/contracts/index.d.mts +1 -1
- package/dist/core/doctor.mjs +8 -8
- package/dist/core/graph-builder.mjs +3 -3
- package/dist/core/meta-manager.mjs +2 -2
- package/dist/core/model-manager.d.mts +1 -1
- package/dist/core/model-manager.mjs +3 -3
- package/dist/core/ops/index.d.mts +17 -29
- package/dist/core/ops/index.d.mts.map +1 -1
- package/dist/core/ops/index.mjs +9 -6
- package/dist/core/overlay-reader.d.mts +1 -1
- package/dist/core/overlay-reader.mjs +1 -1
- package/dist/core/scan-config.mjs +2 -2
- package/dist/core/scanner.mjs +4 -4
- package/dist/core/validator/index.d.mts +1 -1
- package/dist/core/validator/index.mjs +5 -5
- package/dist/{detect-B0oXbQaP.mjs → detect-CI32ynpq.mjs} +2 -2
- package/dist/{detect-B0oXbQaP.mjs.map → detect-CI32ynpq.mjs.map} +1 -1
- package/dist/{doctor-DNPuGgcg.mjs → doctor-B7G0oFxO.mjs} +7 -7
- package/dist/{doctor-DNPuGgcg.mjs.map → doctor-B7G0oFxO.mjs.map} +1 -1
- package/dist/errors-BmLE-JSC.mjs +55 -0
- package/dist/errors-BmLE-JSC.mjs.map +1 -0
- package/dist/{errors-CvBS12e7.mjs → errors-DLkAWnzQ.mjs} +1 -1
- package/dist/{errors-CvBS12e7.mjs.map → errors-DLkAWnzQ.mjs.map} +1 -1
- package/dist/{fs-DLbVB-Ek.mjs → fs-DJv5G8uL.mjs} +1 -1
- package/dist/{fs-DLbVB-Ek.mjs.map → fs-DJv5G8uL.mjs.map} +1 -1
- package/dist/git/branch-lifecycle.d.mts +2 -2
- package/dist/git/branch-lifecycle.mjs +5 -5
- package/dist/git/errors.d.mts +30 -0
- package/dist/git/errors.d.mts.map +1 -0
- package/dist/git/errors.mjs +2 -0
- package/dist/git/identity.mjs +1 -1
- package/dist/git/reconcile.d.mts +49 -0
- package/dist/git/reconcile.d.mts.map +1 -0
- package/dist/git/reconcile.mjs +13 -0
- package/dist/git/transaction.d.mts +60 -15
- package/dist/git/transaction.d.mts.map +1 -1
- package/dist/git/transaction.mjs +10 -11
- package/dist/{graph-builder-BEocwW13.mjs → graph-builder-C9NS7y-a.mjs} +3 -3
- package/dist/{graph-builder-BEocwW13.mjs.map → graph-builder-C9NS7y-a.mjs.map} +1 -1
- package/dist/{id-BjhaIxTL.mjs → id-CjGMPP1k.mjs} +2 -2
- package/dist/{id-BjhaIxTL.mjs.map → id-CjGMPP1k.mjs.map} +1 -1
- package/dist/{identity-D8QoGxhT.mjs → identity-CxAB9Qb8.mjs} +1 -1
- package/dist/{identity-D8QoGxhT.mjs.map → identity-CxAB9Qb8.mjs.map} +1 -1
- package/dist/index-_HJVkDJa.d.mts +85 -0
- package/dist/index-_HJVkDJa.d.mts.map +1 -0
- package/dist/index.mjs +24 -22
- package/dist/index.mjs.map +1 -1
- package/dist/{local-Db5X9mjZ.mjs → local-bnn6yrG7.mjs} +83 -8
- package/dist/local-bnn6yrG7.mjs.map +1 -0
- package/dist/{meta-manager-B_Se183X.mjs → meta-manager-g3shoZES.mjs} +2 -2
- package/dist/{meta-manager-B_Se183X.mjs.map → meta-manager-g3shoZES.mjs.map} +1 -1
- package/dist/{model-manager-DgyKMjLN.mjs → model-manager-DqwL8ptm.mjs} +66 -5
- package/dist/model-manager-DqwL8ptm.mjs.map +1 -0
- package/dist/{node-DUGiIkv1.mjs → node-CpLm3qr6.mjs} +1 -1
- package/dist/{node-DUGiIkv1.mjs.map → node-CpLm3qr6.mjs.map} +1 -1
- package/dist/{ops-BzbThweu.mjs → ops-BfrdZ6GK.mjs} +24 -34
- package/dist/ops-BfrdZ6GK.mjs.map +1 -0
- package/dist/{overlay-reader-CkU_pGXZ.mjs → overlay-reader-Bx1YxGXq.mjs} +1 -1
- package/dist/{overlay-reader-CkU_pGXZ.mjs.map → overlay-reader-Bx1YxGXq.mjs.map} +1 -1
- package/dist/{paths-CdPPWpbG.mjs → paths-C6_C1gLp.mjs} +1 -1
- package/dist/{paths-CdPPWpbG.mjs.map → paths-C6_C1gLp.mjs.map} +1 -1
- package/dist/providers/github/index.d.mts +10 -1
- package/dist/providers/github/index.d.mts.map +1 -1
- package/dist/providers/github/index.mjs +104 -4
- package/dist/providers/github/index.mjs.map +1 -1
- package/dist/providers/gitlab/index.d.mts +8 -1
- package/dist/providers/gitlab/index.d.mts.map +1 -1
- package/dist/providers/gitlab/index.mjs +15 -2
- package/dist/providers/gitlab/index.mjs.map +1 -1
- package/dist/providers/local/index.d.mts +26 -2
- package/dist/providers/local/index.d.mts.map +1 -1
- package/dist/providers/local/index.mjs +13 -12
- package/dist/reconcile-D5FTVW5z.mjs +1704 -0
- package/dist/reconcile-D5FTVW5z.mjs.map +1 -0
- package/dist/reconcile-M7KBZtdn.mjs +249 -0
- package/dist/reconcile-M7KBZtdn.mjs.map +1 -0
- package/dist/{scan-config-Dx49nCqk.mjs → scan-config-C5zzygeH.mjs} +2 -2
- package/dist/{scan-config-Dx49nCqk.mjs.map → scan-config-C5zzygeH.mjs.map} +1 -1
- package/dist/{scanner-BSvXjfkp.mjs → scanner-CEiT8Tgx.mjs} +7 -7
- package/dist/{scanner-BSvXjfkp.mjs.map → scanner-CEiT8Tgx.mjs.map} +1 -1
- package/dist/server/http/index.d.mts +1 -1
- package/dist/server/http/index.mjs +24 -22
- package/dist/server/http/index.mjs.map +1 -1
- package/dist/{server-B7QA0Q1M.mjs → server-B6abaEU2.mjs} +343 -227
- package/dist/server-B6abaEU2.mjs.map +1 -0
- package/dist/{server-CLPNnhYL.d.mts → server-SDsD0XGM.d.mts} +2 -2
- package/dist/{server-CLPNnhYL.d.mts.map → server-SDsD0XGM.d.mts.map} +1 -1
- package/dist/server.d.mts +1 -1
- package/dist/server.mjs +24 -22
- package/dist/{source-map-generator-DhjiQdV3.mjs → source-map-generator-Clb2Eqse.mjs} +1 -1
- package/dist/{source-map-generator-DhjiQdV3.mjs.map → source-map-generator-Clb2Eqse.mjs.map} +1 -1
- package/dist/{svelte-parser-CwySu9yn.mjs → svelte-parser-CBN7Jxb7.mjs} +3 -3
- package/dist/{svelte-parser-CwySu9yn.mjs.map → svelte-parser-CBN7Jxb7.mjs.map} +1 -1
- package/dist/templates/index.mjs +1 -1
- package/dist/{templates-CAkTIRkE.mjs → templates-BZbohJc8.mjs} +1 -1
- package/dist/{templates-CAkTIRkE.mjs.map → templates-BZbohJc8.mjs.map} +1 -1
- package/dist/testing/memory-provider.d.mts +26 -1
- package/dist/testing/memory-provider.d.mts.map +1 -1
- package/dist/testing/memory-provider.mjs +62 -2
- package/dist/testing/memory-provider.mjs.map +1 -1
- package/dist/tools/annotations.d.mts.map +1 -1
- package/dist/tools/annotations.mjs +1 -1
- package/dist/tools/availability.d.mts.map +1 -1
- package/dist/tools/availability.mjs +1 -1
- package/dist/{transaction-BW9ektWu.mjs → transaction-BxDiiakv.mjs} +128 -64
- package/dist/transaction-BxDiiakv.mjs.map +1 -0
- package/dist/tsx-parser-DOQpJVgK.mjs +2 -0
- package/dist/{tsx-parser-BvsK6lyG.mjs → tsx-parser-go5V9zoc.mjs} +1 -1
- package/dist/{tsx-parser-BvsK6lyG.mjs.map → tsx-parser-go5V9zoc.mjs.map} +1 -1
- package/dist/util/detect.mjs +2 -2
- package/dist/util/fs.mjs +1 -1
- package/dist/util/id.mjs +1 -1
- package/dist/{validator-0sJ9vh1j.mjs → validator-B2GhPM3L.mjs} +5 -6
- package/dist/{validator-0sJ9vh1j.mjs.map → validator-B2GhPM3L.mjs.map} +1 -1
- package/dist/{vue-parser-auedZiJG.mjs → vue-parser-5Mp8w-mb.mjs} +3 -3
- package/dist/{vue-parser-auedZiJG.mjs.map → vue-parser-5Mp8w-mb.mjs.map} +1 -1
- package/package.json +13 -5
- package/dist/annotations-C-tQVmot.mjs.map +0 -1
- package/dist/availability-Dgcmvv99.mjs.map +0 -1
- package/dist/branch-lifecycle-B6PXZ2N7.mjs.map +0 -1
- package/dist/branch-lifecycle-DhzHfzs3.d.mts.map +0 -1
- package/dist/context-C3x6I3cE.mjs.map +0 -1
- package/dist/local-Db5X9mjZ.mjs.map +0 -1
- package/dist/model-manager-DgyKMjLN.mjs.map +0 -1
- package/dist/ops-BzbThweu.mjs.map +0 -1
- package/dist/paths-CmVw5Cw2.mjs +0 -65
- package/dist/paths-CmVw5Cw2.mjs.map +0 -1
- package/dist/serialization-tmgfFBOh.mjs +0 -2
- package/dist/server-B7QA0Q1M.mjs.map +0 -1
- package/dist/transaction-BW9ektWu.mjs.map +0 -1
- package/dist/tsx-parser-Byc7HlJ8.mjs +0 -2
- /package/dist/{index-BWbTJlXm.d.mts → index-ByrVsRJE.d.mts} +0 -0
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
import { t as buildContextChange } from "./context-CxpTATh4.mjs";
|
|
2
|
+
import { D as contentDirPath, O as contentFilePath, k as documentFilePath } from "./model-manager-DqwL8ptm.mjs";
|
|
3
|
+
import { t as OverlayReader } from "./overlay-reader-Bx1YxGXq.mjs";
|
|
4
|
+
import { t as validateProject } from "./validator-B2GhPM3L.mjs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { MODEL_FIELD_ORDER, canonicalStringify, conflictId, parseMarkdownFrontmatter, serializeMarkdownFrontmatter, sortKeys, stableHash } from "@contentrain/types";
|
|
7
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
//#region src/core/ops/apply-to-worktree.ts
|
|
9
|
+
/**
|
|
10
|
+
* Phase 2 shim — applies a `FileChange[]` to a local worktree directory.
|
|
11
|
+
*
|
|
12
|
+
* Phase 3 replaces this with `LocalProvider.applyPlan` (same logic, owned by
|
|
13
|
+
* the provider) while a `GitHubProvider.applyPlan` produces the equivalent
|
|
14
|
+
* commit through the Git Data API. Until then, tool handlers call this shim
|
|
15
|
+
* inside the existing `createTransaction` flow so Phase 2 gets plan/apply
|
|
16
|
+
* semantics without rewriting the transaction machinery.
|
|
17
|
+
*
|
|
18
|
+
* - `content: string` → `writeFile` (creates parent dirs).
|
|
19
|
+
* - `content: null` → `rm`; missing files are tolerated.
|
|
20
|
+
* - Changes are applied in their given order; callers should sort for
|
|
21
|
+
* determinism.
|
|
22
|
+
*/
|
|
23
|
+
async function applyChangesToWorktree(worktree, changes) {
|
|
24
|
+
for (const change of changes) {
|
|
25
|
+
const absolute = join(worktree, change.path);
|
|
26
|
+
if (change.content === null) {
|
|
27
|
+
await rm(absolute, { force: true }).catch(() => {});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
31
|
+
await writeFile(absolute, change.content, "utf-8");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/core/ops/reconcile/read.ts
|
|
36
|
+
/**
|
|
37
|
+
* Per-side promise-memo over a `RepoReader`: every `(path)` read and listing
|
|
38
|
+
* happens at most once for the whole plan, and concurrent duplicate requests
|
|
39
|
+
* coalesce onto the same promise instead of racing. On a GitHub-backed
|
|
40
|
+
* reader this is the difference between one API call per file and one per
|
|
41
|
+
* phase that touches it.
|
|
42
|
+
*
|
|
43
|
+
* The `RepoReader` contract is preserved (`readFile` throws on a missing
|
|
44
|
+
* file) so a `CachedReader` can be handed to `OverlayReader`,
|
|
45
|
+
* `validateProject` and `buildContextChange` unchanged; the planner's own
|
|
46
|
+
* code uses the null-tolerant extras.
|
|
47
|
+
*/
|
|
48
|
+
var CachedReader = class {
|
|
49
|
+
files = /* @__PURE__ */ new Map();
|
|
50
|
+
dirs = /* @__PURE__ */ new Map();
|
|
51
|
+
exists = /* @__PURE__ */ new Map();
|
|
52
|
+
constructor(inner) {
|
|
53
|
+
this.inner = inner;
|
|
54
|
+
}
|
|
55
|
+
/** Contract-shaped read: throws when missing. */
|
|
56
|
+
async readFile(path, _ref) {
|
|
57
|
+
const content = await this.readOrNull(path);
|
|
58
|
+
if (content === null) throw new Error(`ENOENT: no such file, open '${path}'`);
|
|
59
|
+
return content;
|
|
60
|
+
}
|
|
61
|
+
/** Planner-shaped read: `null` when missing, cached either way. */
|
|
62
|
+
readOrNull(path) {
|
|
63
|
+
let cached = this.files.get(path);
|
|
64
|
+
if (!cached) {
|
|
65
|
+
cached = this.inner.readFile(path).then((content) => content, () => null);
|
|
66
|
+
this.files.set(path, cached);
|
|
67
|
+
}
|
|
68
|
+
return cached;
|
|
69
|
+
}
|
|
70
|
+
listDirectory(path, _ref) {
|
|
71
|
+
let cached = this.dirs.get(path);
|
|
72
|
+
if (!cached) {
|
|
73
|
+
cached = this.inner.listDirectory(path).then((entries) => entries, () => []);
|
|
74
|
+
this.dirs.set(path, cached);
|
|
75
|
+
}
|
|
76
|
+
return cached;
|
|
77
|
+
}
|
|
78
|
+
fileExists(path, _ref) {
|
|
79
|
+
let cached = this.exists.get(path);
|
|
80
|
+
if (!cached) {
|
|
81
|
+
cached = this.inner.fileExists(path).then((v) => v, () => false);
|
|
82
|
+
this.exists.set(path, cached);
|
|
83
|
+
}
|
|
84
|
+
return cached;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/** Parse a JSON file, `null` when missing or unparseable. */
|
|
88
|
+
async function readJsonOrNull(reader, path) {
|
|
89
|
+
const raw = await reader.readOrNull(path);
|
|
90
|
+
if (raw === null) return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/core/ops/reconcile/resolutions.ts
|
|
99
|
+
/**
|
|
100
|
+
* The decisions carried into a second reconcile round, indexed by conflict
|
|
101
|
+
* id. Merge functions call `consume` at the exact point they would create a
|
|
102
|
+
* `ConflictItem`; a hit turns the conflict into a mechanical outcome, a
|
|
103
|
+
* miss reports it. After planning, `unconsumed()` names the stale
|
|
104
|
+
* resolutions — values changed between rounds, so the decision no longer
|
|
105
|
+
* applies and the conflict reappears with a fresh id.
|
|
106
|
+
*/
|
|
107
|
+
var ResolutionIndex = class {
|
|
108
|
+
byId = /* @__PURE__ */ new Map();
|
|
109
|
+
consumed = /* @__PURE__ */ new Set();
|
|
110
|
+
constructor(resolutions = []) {
|
|
111
|
+
for (const resolution of resolutions) this.byId.set(resolution.id, resolution);
|
|
112
|
+
}
|
|
113
|
+
consume(candidate) {
|
|
114
|
+
const id = conflictId(candidate);
|
|
115
|
+
const resolution = this.byId.get(id);
|
|
116
|
+
if (!resolution) return null;
|
|
117
|
+
this.consumed.add(id);
|
|
118
|
+
return resolution;
|
|
119
|
+
}
|
|
120
|
+
unconsumed() {
|
|
121
|
+
return [...this.byId.values()].filter((r) => !this.consumed.has(r.id));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Apply a consumed resolution to the candidate's values. `choose` picks a
|
|
126
|
+
* side (absence on that side means deletion → `undefined`); `value`
|
|
127
|
+
* supplies a replacement outright.
|
|
128
|
+
*/
|
|
129
|
+
function resolvedValue(resolution, candidate) {
|
|
130
|
+
if ("choose" in resolution) return resolution.choose === "ours" ? candidate.ours : candidate.theirs;
|
|
131
|
+
return resolution.value;
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/core/ops/reconcile/types.ts
|
|
135
|
+
const EMPTY_STATS = {
|
|
136
|
+
takenOurs: 0,
|
|
137
|
+
takenTheirs: 0,
|
|
138
|
+
fieldMerged: 0
|
|
139
|
+
};
|
|
140
|
+
function addStats(a, b) {
|
|
141
|
+
return {
|
|
142
|
+
takenOurs: a.takenOurs + b.takenOurs,
|
|
143
|
+
takenTheirs: a.takenTheirs + b.takenTheirs,
|
|
144
|
+
fieldMerged: a.fieldMerged + b.fieldMerged
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/core/ops/reconcile/three-way.ts
|
|
149
|
+
/**
|
|
150
|
+
* Canonical equality: both values normalized through `sortKeys` (which drops
|
|
151
|
+
* `null`/`undefined` values and sorts keys recursively — the same normal
|
|
152
|
+
* form `canonicalStringify` writes to disk), then compared as JSON. So
|
|
153
|
+
* `{ a: null }` equals `{}`, key order never matters, and absent equals
|
|
154
|
+
* null — exactly the distinctions the storage layer cannot represent.
|
|
155
|
+
*/
|
|
156
|
+
function eqCanonical(a, b) {
|
|
157
|
+
const na = sortKeys(a);
|
|
158
|
+
const nb = sortKeys(b);
|
|
159
|
+
if (na === void 0 || nb === void 0) return na === nb;
|
|
160
|
+
return JSON.stringify(na) === JSON.stringify(nb);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The three-way rule every policy row reduces to: a side that did not
|
|
164
|
+
* change keeps no vote; identical changes converge; a lone change wins;
|
|
165
|
+
* two different changes are a conflict — with delete-vs-edit split out
|
|
166
|
+
* because it needs a different question ("keep or drop?" instead of
|
|
167
|
+
* "which value?").
|
|
168
|
+
*/
|
|
169
|
+
function mergeLeaf3(base, ours, theirs) {
|
|
170
|
+
const oursSame = eqCanonical(ours, base);
|
|
171
|
+
const theirsSame = eqCanonical(theirs, base);
|
|
172
|
+
if (oursSame && theirsSame) return {
|
|
173
|
+
ok: true,
|
|
174
|
+
value: base,
|
|
175
|
+
from: "base"
|
|
176
|
+
};
|
|
177
|
+
if (eqCanonical(ours, theirs)) return {
|
|
178
|
+
ok: true,
|
|
179
|
+
value: ours,
|
|
180
|
+
from: "both"
|
|
181
|
+
};
|
|
182
|
+
if (oursSame) return {
|
|
183
|
+
ok: true,
|
|
184
|
+
value: theirs,
|
|
185
|
+
from: "theirs"
|
|
186
|
+
};
|
|
187
|
+
if (theirsSame) return {
|
|
188
|
+
ok: true,
|
|
189
|
+
value: ours,
|
|
190
|
+
from: "ours"
|
|
191
|
+
};
|
|
192
|
+
const baseAbsent = sortKeys(base) === void 0;
|
|
193
|
+
const oursAbsent = sortKeys(ours) === void 0;
|
|
194
|
+
const theirsAbsent = sortKeys(theirs) === void 0;
|
|
195
|
+
if (!baseAbsent && oursAbsent !== theirsAbsent) return {
|
|
196
|
+
ok: false,
|
|
197
|
+
reason: "delete_edit",
|
|
198
|
+
deletedBy: oursAbsent ? "ours" : "theirs"
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
reason: "both_changed"
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Three-way merge of a keyed record (entries by ID, dictionary by key,
|
|
207
|
+
* terms by slug, schema by property name): iterate the sorted union of
|
|
208
|
+
* keys, delegate each item to `mergeItem`, assemble the output map.
|
|
209
|
+
*/
|
|
210
|
+
function mergeKeyedRecord3(three, mergeItem) {
|
|
211
|
+
const keys = [...new Set([
|
|
212
|
+
...Object.keys(three.base ?? {}),
|
|
213
|
+
...Object.keys(three.ours ?? {}),
|
|
214
|
+
...Object.keys(three.theirs ?? {})
|
|
215
|
+
])].toSorted();
|
|
216
|
+
const merged = {};
|
|
217
|
+
const conflicts = [];
|
|
218
|
+
let stats = EMPTY_STATS;
|
|
219
|
+
for (const key of keys) {
|
|
220
|
+
const outcome = mergeItem(key, {
|
|
221
|
+
base: three.base?.[key],
|
|
222
|
+
ours: three.ours?.[key],
|
|
223
|
+
theirs: three.theirs?.[key]
|
|
224
|
+
});
|
|
225
|
+
if (outcome.value !== void 0) merged[key] = outcome.value;
|
|
226
|
+
conflicts.push(...outcome.conflicts);
|
|
227
|
+
stats = addStats(stats, outcome.stats);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
merged,
|
|
231
|
+
conflicts,
|
|
232
|
+
stats
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Attach the value-derived id — the only way a ConflictItem is ever built. */
|
|
236
|
+
function makeConflict(input) {
|
|
237
|
+
return {
|
|
238
|
+
id: conflictId(input),
|
|
239
|
+
...input
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** Stats bucket for a clean leaf outcome; `base`/`both` count nowhere. */
|
|
243
|
+
function statsFor(from) {
|
|
244
|
+
if (from === "ours") return {
|
|
245
|
+
takenOurs: 1,
|
|
246
|
+
takenTheirs: 0,
|
|
247
|
+
fieldMerged: 0
|
|
248
|
+
};
|
|
249
|
+
if (from === "theirs") return {
|
|
250
|
+
takenOurs: 0,
|
|
251
|
+
takenTheirs: 1,
|
|
252
|
+
fieldMerged: 0
|
|
253
|
+
};
|
|
254
|
+
return EMPTY_STATS;
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/core/ops/reconcile/merge-content.ts
|
|
258
|
+
/**
|
|
259
|
+
* Three-way merge of one entry's fields. Called when the entry as a whole
|
|
260
|
+
* changed on BOTH sides: different fields both win (the union), the same
|
|
261
|
+
* field with two values is a conflict, and a field deleted on one side but
|
|
262
|
+
* edited on the other asks a different question and gets its own code.
|
|
263
|
+
* An unresolved conflict keeps ours' value in the output — the plan is not
|
|
264
|
+
* applied while conflicts remain, so nothing is ever silently overwritten.
|
|
265
|
+
*/
|
|
266
|
+
function mergeEntryFields(three, ctx, entryKey) {
|
|
267
|
+
const fields = [...new Set([
|
|
268
|
+
...Object.keys(three.base ?? {}),
|
|
269
|
+
...Object.keys(three.ours ?? {}),
|
|
270
|
+
...Object.keys(three.theirs ?? {})
|
|
271
|
+
])].toSorted();
|
|
272
|
+
const merged = {};
|
|
273
|
+
const conflicts = [];
|
|
274
|
+
for (const field of fields) {
|
|
275
|
+
const candidate = {
|
|
276
|
+
path: ctx.outPath,
|
|
277
|
+
key: entryKey,
|
|
278
|
+
field,
|
|
279
|
+
locale: ctx.locale,
|
|
280
|
+
base: three.base?.[field],
|
|
281
|
+
ours: three.ours?.[field],
|
|
282
|
+
theirs: three.theirs?.[field]
|
|
283
|
+
};
|
|
284
|
+
const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
|
|
285
|
+
if (leaf.ok) {
|
|
286
|
+
if (leaf.value !== void 0) merged[field] = leaf.value;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
290
|
+
if (resolution) {
|
|
291
|
+
const value = resolvedValue(resolution, candidate);
|
|
292
|
+
if (value !== void 0) merged[field] = value;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (candidate.ours !== void 0) merged[field] = candidate.ours;
|
|
296
|
+
const label = entryKey ? `"${entryKey}" field "${field}"` : `field "${field}"`;
|
|
297
|
+
conflicts.push(makeConflict({
|
|
298
|
+
...candidate,
|
|
299
|
+
kind: ctx.kind,
|
|
300
|
+
model: ctx.model,
|
|
301
|
+
code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "field_value_conflict",
|
|
302
|
+
message: leaf.reason === "delete_edit" ? `${label} was deleted on one side and edited on the other in ${ctx.outPath}.` : `${label} was changed to different values on both sides in ${ctx.outPath}.`
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
merged,
|
|
307
|
+
conflicts
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Collection file: entry-level three-way over the object-map, field-level
|
|
312
|
+
* union when both sides touched the same entry.
|
|
313
|
+
*/
|
|
314
|
+
function mergeCollectionFile(three, ctx) {
|
|
315
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
316
|
+
if (fileLeaf.ok) return {
|
|
317
|
+
merged: fileLeaf.value,
|
|
318
|
+
conflicts: [],
|
|
319
|
+
stats: statsFor(fileLeaf.from)
|
|
320
|
+
};
|
|
321
|
+
return mergeKeyedRecord3(three, (entryId, entry) => {
|
|
322
|
+
const leaf = mergeLeaf3(entry.base, entry.ours, entry.theirs);
|
|
323
|
+
if (leaf.ok) return {
|
|
324
|
+
value: leaf.value,
|
|
325
|
+
conflicts: [],
|
|
326
|
+
stats: statsFor(leaf.from)
|
|
327
|
+
};
|
|
328
|
+
if (leaf.reason === "delete_edit") {
|
|
329
|
+
const candidate = {
|
|
330
|
+
path: ctx.outPath,
|
|
331
|
+
key: entryId,
|
|
332
|
+
locale: ctx.locale,
|
|
333
|
+
base: entry.base,
|
|
334
|
+
ours: entry.ours,
|
|
335
|
+
theirs: entry.theirs
|
|
336
|
+
};
|
|
337
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
338
|
+
if (resolution) return {
|
|
339
|
+
value: resolvedValue(resolution, candidate),
|
|
340
|
+
conflicts: [],
|
|
341
|
+
stats: EMPTY_STATS
|
|
342
|
+
};
|
|
343
|
+
return {
|
|
344
|
+
value: entry.ours,
|
|
345
|
+
conflicts: [makeConflict({
|
|
346
|
+
...candidate,
|
|
347
|
+
kind: ctx.kind,
|
|
348
|
+
model: ctx.model,
|
|
349
|
+
code: "delete_edit_conflict",
|
|
350
|
+
message: `Entry "${entryId}" was deleted on one side and edited on the other in ${ctx.outPath}.`
|
|
351
|
+
})],
|
|
352
|
+
stats: EMPTY_STATS
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const fieldMerge = mergeEntryFields(entry, ctx, entryId);
|
|
356
|
+
return {
|
|
357
|
+
value: fieldMerge.merged,
|
|
358
|
+
conflicts: fieldMerge.conflicts,
|
|
359
|
+
stats: fieldMerge.conflicts.length === 0 ? {
|
|
360
|
+
takenOurs: 0,
|
|
361
|
+
takenTheirs: 0,
|
|
362
|
+
fieldMerged: 1
|
|
363
|
+
} : EMPTY_STATS
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/** Singleton file: one record — straight field-level three-way. */
|
|
368
|
+
function mergeSingletonFile(three, ctx) {
|
|
369
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
370
|
+
if (fileLeaf.ok) return {
|
|
371
|
+
merged: fileLeaf.value,
|
|
372
|
+
conflicts: [],
|
|
373
|
+
stats: statsFor(fileLeaf.from)
|
|
374
|
+
};
|
|
375
|
+
if (fileLeaf.reason === "delete_edit") {
|
|
376
|
+
const candidate = {
|
|
377
|
+
path: ctx.outPath,
|
|
378
|
+
locale: ctx.locale,
|
|
379
|
+
base: three.base,
|
|
380
|
+
ours: three.ours,
|
|
381
|
+
theirs: three.theirs
|
|
382
|
+
};
|
|
383
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
384
|
+
if (resolution) return {
|
|
385
|
+
merged: resolvedValue(resolution, candidate),
|
|
386
|
+
conflicts: [],
|
|
387
|
+
stats: EMPTY_STATS
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
merged: three.ours,
|
|
391
|
+
conflicts: [makeConflict({
|
|
392
|
+
...candidate,
|
|
393
|
+
kind: ctx.kind,
|
|
394
|
+
model: ctx.model,
|
|
395
|
+
code: "delete_edit_conflict",
|
|
396
|
+
message: `${ctx.outPath} was deleted on one side and edited on the other.`
|
|
397
|
+
})],
|
|
398
|
+
stats: EMPTY_STATS
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const fieldMerge = mergeEntryFields(three, ctx);
|
|
402
|
+
return {
|
|
403
|
+
merged: fieldMerge.merged,
|
|
404
|
+
conflicts: fieldMerge.conflicts,
|
|
405
|
+
stats: fieldMerge.conflicts.length === 0 ? {
|
|
406
|
+
takenOurs: 0,
|
|
407
|
+
takenTheirs: 0,
|
|
408
|
+
fieldMerged: 1
|
|
409
|
+
} : EMPTY_STATS
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Dictionary file: keys are the identities, values are string leaves. The
|
|
414
|
+
* three-way generalization of the inline upsert semantics: with an author
|
|
415
|
+
* present (a save), the incoming value wins and is reported; with no
|
|
416
|
+
* author (reconcile), two changed values are a question, not a pick.
|
|
417
|
+
*/
|
|
418
|
+
function mergeDictionaryFile(three, ctx) {
|
|
419
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
420
|
+
if (fileLeaf.ok) return {
|
|
421
|
+
merged: fileLeaf.value,
|
|
422
|
+
conflicts: [],
|
|
423
|
+
stats: statsFor(fileLeaf.from)
|
|
424
|
+
};
|
|
425
|
+
return mergeKeyedRecord3(three, (key, item) => {
|
|
426
|
+
const leaf = mergeLeaf3(item.base, item.ours, item.theirs);
|
|
427
|
+
if (leaf.ok) return {
|
|
428
|
+
value: leaf.value,
|
|
429
|
+
conflicts: [],
|
|
430
|
+
stats: statsFor(leaf.from)
|
|
431
|
+
};
|
|
432
|
+
const candidate = {
|
|
433
|
+
path: ctx.outPath,
|
|
434
|
+
key,
|
|
435
|
+
locale: ctx.locale,
|
|
436
|
+
base: item.base,
|
|
437
|
+
ours: item.ours,
|
|
438
|
+
theirs: item.theirs
|
|
439
|
+
};
|
|
440
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
441
|
+
if (resolution) return {
|
|
442
|
+
value: resolvedValue(resolution, candidate),
|
|
443
|
+
conflicts: [],
|
|
444
|
+
stats: EMPTY_STATS
|
|
445
|
+
};
|
|
446
|
+
return {
|
|
447
|
+
value: item.ours,
|
|
448
|
+
conflicts: [makeConflict({
|
|
449
|
+
...candidate,
|
|
450
|
+
kind: "dictionary",
|
|
451
|
+
model: ctx.model,
|
|
452
|
+
code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "dictionary_value_conflict",
|
|
453
|
+
message: leaf.reason === "delete_edit" ? `Dictionary key "${key}" was deleted on one side and edited on the other in ${ctx.outPath}.` : `Dictionary key "${key}" has two different values in ${ctx.outPath}.`
|
|
454
|
+
})],
|
|
455
|
+
stats: EMPTY_STATS
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* `normalize-sources.json`: keyed tooling metadata — merged with the
|
|
461
|
+
* dictionary rule, values compared canonically whatever their shape.
|
|
462
|
+
*/
|
|
463
|
+
function mergeKeyedJson(three, ctx) {
|
|
464
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
465
|
+
if (fileLeaf.ok) return {
|
|
466
|
+
merged: fileLeaf.value,
|
|
467
|
+
conflicts: [],
|
|
468
|
+
stats: statsFor(fileLeaf.from)
|
|
469
|
+
};
|
|
470
|
+
const result = mergeKeyedRecord3(three, (key, item) => {
|
|
471
|
+
const leaf = mergeLeaf3(item.base, item.ours, item.theirs);
|
|
472
|
+
if (leaf.ok) return {
|
|
473
|
+
value: leaf.value,
|
|
474
|
+
conflicts: [],
|
|
475
|
+
stats: statsFor(leaf.from)
|
|
476
|
+
};
|
|
477
|
+
const candidate = {
|
|
478
|
+
path: ctx.outPath,
|
|
479
|
+
key,
|
|
480
|
+
base: item.base,
|
|
481
|
+
ours: item.ours,
|
|
482
|
+
theirs: item.theirs
|
|
483
|
+
};
|
|
484
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
485
|
+
if (resolution) return {
|
|
486
|
+
value: resolvedValue(resolution, candidate),
|
|
487
|
+
conflicts: [],
|
|
488
|
+
stats: EMPTY_STATS
|
|
489
|
+
};
|
|
490
|
+
return {
|
|
491
|
+
value: item.ours,
|
|
492
|
+
conflicts: [makeConflict({
|
|
493
|
+
...candidate,
|
|
494
|
+
kind: "file",
|
|
495
|
+
code: "file_conflict",
|
|
496
|
+
message: `Key "${key}" of ${ctx.outPath} was changed differently on both sides.`
|
|
497
|
+
})],
|
|
498
|
+
stats: EMPTY_STATS
|
|
499
|
+
};
|
|
500
|
+
});
|
|
501
|
+
return {
|
|
502
|
+
merged: result.merged,
|
|
503
|
+
conflicts: result.conflicts,
|
|
504
|
+
stats: result.stats
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
//#region src/core/ops/reconcile/merge-document.ts
|
|
509
|
+
/**
|
|
510
|
+
* Document (.md) three-way merge: frontmatter key-by-key, body as one leaf.
|
|
511
|
+
*
|
|
512
|
+
* The body is deliberately never text-merged. Interleaving two prose edits
|
|
513
|
+
* line-by-line produces syntactically plausible, editorially wrong hybrids —
|
|
514
|
+
* choosing between two versions of prose is a content decision, and MCP
|
|
515
|
+
* does not make those.
|
|
516
|
+
*
|
|
517
|
+
* The frontmatter parser round-trips only scalars and scalar arrays
|
|
518
|
+
* (nested maps serialize but do not parse back). A side whose frontmatter
|
|
519
|
+
* does not survive parse→serialize is not safe to rewrite, so the merge
|
|
520
|
+
* falls back to whole-file resolution for that document instead of
|
|
521
|
+
* silently destroying nested data.
|
|
522
|
+
*/
|
|
523
|
+
function mergeDocumentFile(three, ctx) {
|
|
524
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
525
|
+
if (fileLeaf.ok) return {
|
|
526
|
+
merged: fileLeaf.value,
|
|
527
|
+
conflicts: [],
|
|
528
|
+
stats: statsFor(fileLeaf.from),
|
|
529
|
+
advisories: []
|
|
530
|
+
};
|
|
531
|
+
if (fileLeaf.reason === "delete_edit") {
|
|
532
|
+
const candidate = {
|
|
533
|
+
path: ctx.outPath,
|
|
534
|
+
key: ctx.slug,
|
|
535
|
+
locale: ctx.locale,
|
|
536
|
+
base: three.base,
|
|
537
|
+
ours: three.ours,
|
|
538
|
+
theirs: three.theirs
|
|
539
|
+
};
|
|
540
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
541
|
+
if (resolution) return {
|
|
542
|
+
merged: resolvedValue(resolution, candidate),
|
|
543
|
+
conflicts: [],
|
|
544
|
+
stats: EMPTY_STATS,
|
|
545
|
+
advisories: []
|
|
546
|
+
};
|
|
547
|
+
return {
|
|
548
|
+
merged: three.ours,
|
|
549
|
+
conflicts: [makeConflict({
|
|
550
|
+
...candidate,
|
|
551
|
+
kind: "document",
|
|
552
|
+
model: ctx.model,
|
|
553
|
+
code: "delete_edit_conflict",
|
|
554
|
+
message: `Document "${ctx.slug}" was deleted on one side and edited on the other (${ctx.outPath}).`
|
|
555
|
+
})],
|
|
556
|
+
stats: EMPTY_STATS,
|
|
557
|
+
advisories: []
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const parsed = {
|
|
561
|
+
base: three.base === void 0 ? void 0 : parseMarkdownFrontmatter(three.base),
|
|
562
|
+
ours: three.ours === void 0 ? void 0 : parseMarkdownFrontmatter(three.ours),
|
|
563
|
+
theirs: three.theirs === void 0 ? void 0 : parseMarkdownFrontmatter(three.theirs)
|
|
564
|
+
};
|
|
565
|
+
if (["ours", "theirs"].some((side) => {
|
|
566
|
+
const doc = parsed[side];
|
|
567
|
+
if (!doc) return false;
|
|
568
|
+
const reserialized = parseMarkdownFrontmatter(serializeMarkdownFrontmatter(doc.frontmatter, doc.body));
|
|
569
|
+
return !eqCanonical(reserialized.frontmatter, doc.frontmatter) || reserialized.body !== doc.body;
|
|
570
|
+
})) {
|
|
571
|
+
const candidate = {
|
|
572
|
+
path: ctx.outPath,
|
|
573
|
+
key: ctx.slug,
|
|
574
|
+
locale: ctx.locale,
|
|
575
|
+
base: three.base,
|
|
576
|
+
ours: three.ours,
|
|
577
|
+
theirs: three.theirs
|
|
578
|
+
};
|
|
579
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
580
|
+
if (resolution) return {
|
|
581
|
+
merged: resolvedValue(resolution, candidate),
|
|
582
|
+
conflicts: [],
|
|
583
|
+
stats: EMPTY_STATS,
|
|
584
|
+
advisories: []
|
|
585
|
+
};
|
|
586
|
+
return {
|
|
587
|
+
merged: three.ours,
|
|
588
|
+
conflicts: [makeConflict({
|
|
589
|
+
...candidate,
|
|
590
|
+
kind: "document",
|
|
591
|
+
model: ctx.model,
|
|
592
|
+
code: "file_conflict",
|
|
593
|
+
message: `Document "${ctx.slug}" (${ctx.outPath}) changed on both sides and carries frontmatter the structured merge cannot round-trip — choose a side.`
|
|
594
|
+
})],
|
|
595
|
+
stats: EMPTY_STATS,
|
|
596
|
+
advisories: [`Document "${ctx.slug}": fell back to whole-file resolution (nested frontmatter does not round-trip).`]
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const conflicts = [];
|
|
600
|
+
const fmKeys = [...new Set([
|
|
601
|
+
...Object.keys(parsed.base?.frontmatter ?? {}),
|
|
602
|
+
...Object.keys(parsed.ours?.frontmatter ?? {}),
|
|
603
|
+
...Object.keys(parsed.theirs?.frontmatter ?? {})
|
|
604
|
+
])].toSorted();
|
|
605
|
+
const mergedFm = {};
|
|
606
|
+
for (const key of fmKeys) {
|
|
607
|
+
const candidate = {
|
|
608
|
+
path: ctx.outPath,
|
|
609
|
+
key: ctx.slug,
|
|
610
|
+
field: key,
|
|
611
|
+
locale: ctx.locale,
|
|
612
|
+
base: parsed.base?.frontmatter[key],
|
|
613
|
+
ours: parsed.ours?.frontmatter[key],
|
|
614
|
+
theirs: parsed.theirs?.frontmatter[key]
|
|
615
|
+
};
|
|
616
|
+
const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
|
|
617
|
+
if (leaf.ok) {
|
|
618
|
+
if (leaf.value !== void 0) mergedFm[key] = leaf.value;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
622
|
+
if (resolution) {
|
|
623
|
+
const value = resolvedValue(resolution, candidate);
|
|
624
|
+
if (value !== void 0) mergedFm[key] = value;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (candidate.ours !== void 0) mergedFm[key] = candidate.ours;
|
|
628
|
+
conflicts.push(makeConflict({
|
|
629
|
+
...candidate,
|
|
630
|
+
kind: "document",
|
|
631
|
+
model: ctx.model,
|
|
632
|
+
code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "frontmatter_value_conflict",
|
|
633
|
+
message: `Frontmatter key "${key}" of document "${ctx.slug}" differs on both sides (${ctx.outPath}).`
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
let mergedBody;
|
|
637
|
+
const bodyCandidate = {
|
|
638
|
+
path: ctx.outPath,
|
|
639
|
+
key: ctx.slug,
|
|
640
|
+
field: "body",
|
|
641
|
+
locale: ctx.locale,
|
|
642
|
+
base: parsed.base?.body,
|
|
643
|
+
ours: parsed.ours?.body,
|
|
644
|
+
theirs: parsed.theirs?.body
|
|
645
|
+
};
|
|
646
|
+
const bodyLeaf = mergeLeaf3(bodyCandidate.base, bodyCandidate.ours, bodyCandidate.theirs);
|
|
647
|
+
if (bodyLeaf.ok) mergedBody = bodyLeaf.value ?? "";
|
|
648
|
+
else {
|
|
649
|
+
const resolution = ctx.resolutions.consume(bodyCandidate);
|
|
650
|
+
if (resolution) mergedBody = resolvedValue(resolution, bodyCandidate) ?? "";
|
|
651
|
+
else {
|
|
652
|
+
mergedBody = bodyCandidate.ours ?? "";
|
|
653
|
+
conflicts.push(makeConflict({
|
|
654
|
+
...bodyCandidate,
|
|
655
|
+
kind: "document",
|
|
656
|
+
model: ctx.model,
|
|
657
|
+
code: "document_body_conflict",
|
|
658
|
+
message: `Body of document "${ctx.slug}" was edited on both sides (${ctx.outPath}) — bodies are never text-merged.`
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
merged: serializeMarkdownFrontmatter(mergedFm, mergedBody),
|
|
664
|
+
conflicts,
|
|
665
|
+
stats: conflicts.length === 0 ? {
|
|
666
|
+
takenOurs: 0,
|
|
667
|
+
takenTheirs: 0,
|
|
668
|
+
fieldMerged: 1
|
|
669
|
+
} : EMPTY_STATS,
|
|
670
|
+
advisories: []
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/core/ops/reconcile/merge-meta.ts
|
|
675
|
+
/**
|
|
676
|
+
* Three-way merge of one entry's meta. Special fields:
|
|
677
|
+
*
|
|
678
|
+
* - `updated_at`: the later timestamp wins (ISO strings compare lexically);
|
|
679
|
+
* a present value beats an absent one.
|
|
680
|
+
* - `updated_by`: follows the side that supplied the winning `updated_at`;
|
|
681
|
+
* with no timestamps to arbitrate, ours is kept and an advisory says so.
|
|
682
|
+
* - `status`: a publish decision belongs to whoever made it — one side's
|
|
683
|
+
* change wins mechanically, but two different decisions are a question
|
|
684
|
+
* for a human, never a pick.
|
|
685
|
+
*
|
|
686
|
+
* Entry-level delete-vs-edit resolves mechanically to the edited side with
|
|
687
|
+
* an advisory: meta is bookkeeping that trails content, and the CONTENT
|
|
688
|
+
* delete-vs-edit conflict (reported separately) is the real question. The
|
|
689
|
+
* orphan-meta validator catches any residue once that resolves.
|
|
690
|
+
*/
|
|
691
|
+
function mergeEntryMeta3(three, ctx, entryKey) {
|
|
692
|
+
const leaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
693
|
+
if (leaf.ok) return {
|
|
694
|
+
merged: leaf.value,
|
|
695
|
+
conflicts: [],
|
|
696
|
+
advisories: []
|
|
697
|
+
};
|
|
698
|
+
if (leaf.reason === "delete_edit") return {
|
|
699
|
+
merged: leaf.deletedBy === "ours" ? three.theirs : three.ours,
|
|
700
|
+
conflicts: [],
|
|
701
|
+
advisories: [`Kept the edited side of ${entryKey ? `meta of "${entryKey}"` : `meta ${ctx.outPath}`} (deleted on the other) — bookkeeping follows the content decision.`]
|
|
702
|
+
};
|
|
703
|
+
const conflicts = [];
|
|
704
|
+
const advisories = [];
|
|
705
|
+
const merged = {};
|
|
706
|
+
const oursAt = typeof three.ours?.["updated_at"] === "string" ? three.ours["updated_at"] : void 0;
|
|
707
|
+
const theirsAt = typeof three.theirs?.["updated_at"] === "string" ? three.theirs["updated_at"] : void 0;
|
|
708
|
+
let byWinner = null;
|
|
709
|
+
if (oursAt !== void 0 || theirsAt !== void 0) {
|
|
710
|
+
if (oursAt === void 0) byWinner = "theirs";
|
|
711
|
+
else if (theirsAt === void 0) byWinner = "ours";
|
|
712
|
+
else byWinner = theirsAt > oursAt ? "theirs" : "ours";
|
|
713
|
+
const winnerAt = byWinner === "theirs" ? theirsAt : oursAt;
|
|
714
|
+
if (winnerAt !== void 0) merged["updated_at"] = winnerAt;
|
|
715
|
+
}
|
|
716
|
+
const fields = [...new Set([
|
|
717
|
+
...Object.keys(three.base ?? {}),
|
|
718
|
+
...Object.keys(three.ours ?? {}),
|
|
719
|
+
...Object.keys(three.theirs ?? {})
|
|
720
|
+
])].toSorted().filter((f) => f !== "updated_at");
|
|
721
|
+
for (const field of fields) {
|
|
722
|
+
const candidate = {
|
|
723
|
+
path: ctx.outPath,
|
|
724
|
+
key: entryKey,
|
|
725
|
+
field,
|
|
726
|
+
locale: ctx.locale,
|
|
727
|
+
base: three.base?.[field],
|
|
728
|
+
ours: three.ours?.[field],
|
|
729
|
+
theirs: three.theirs?.[field]
|
|
730
|
+
};
|
|
731
|
+
if (field === "updated_by" && byWinner !== null) {
|
|
732
|
+
const value = byWinner === "theirs" ? candidate.theirs ?? candidate.ours : candidate.ours ?? candidate.theirs;
|
|
733
|
+
if (value !== void 0) merged[field] = value;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const fieldLeaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
|
|
737
|
+
if (fieldLeaf.ok) {
|
|
738
|
+
if (fieldLeaf.value !== void 0) merged[field] = fieldLeaf.value;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (field === "updated_by") {
|
|
742
|
+
if (candidate.ours !== void 0) merged[field] = candidate.ours;
|
|
743
|
+
advisories.push(`${entryKey ? `"${entryKey}": ` : ""}updated_by differed with no updated_at to arbitrate — kept ours.`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
747
|
+
if (resolution) {
|
|
748
|
+
const value = resolvedValue(resolution, candidate);
|
|
749
|
+
if (value !== void 0) merged[field] = value;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (candidate.ours !== void 0) merged[field] = candidate.ours;
|
|
753
|
+
conflicts.push(makeConflict({
|
|
754
|
+
...candidate,
|
|
755
|
+
kind: "meta",
|
|
756
|
+
model: ctx.model,
|
|
757
|
+
code: field === "status" ? "meta_status_conflict" : "field_value_conflict",
|
|
758
|
+
message: field === "status" ? `Publish status of ${entryKey ? `entry "${entryKey}"` : ctx.outPath} was changed differently on both sides.` : `Meta field "${field}" of ${entryKey ? `entry "${entryKey}"` : ctx.outPath} differs on both sides.`
|
|
759
|
+
}));
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
merged,
|
|
763
|
+
conflicts,
|
|
764
|
+
advisories
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* A meta file: keyed by entry ID for collections, a single record
|
|
769
|
+
* otherwise. Dispatch comes from the winning model definition — shapes are
|
|
770
|
+
* never sniffed, because a dictionary meta record is indistinguishable
|
|
771
|
+
* from a small collection map.
|
|
772
|
+
*/
|
|
773
|
+
function mergeMetaFile(three, ctx, perEntry) {
|
|
774
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
775
|
+
if (fileLeaf.ok) return {
|
|
776
|
+
merged: fileLeaf.value,
|
|
777
|
+
conflicts: [],
|
|
778
|
+
stats: statsFor(fileLeaf.from),
|
|
779
|
+
advisories: []
|
|
780
|
+
};
|
|
781
|
+
if (!perEntry) {
|
|
782
|
+
const single = mergeEntryMeta3(three, ctx);
|
|
783
|
+
return {
|
|
784
|
+
merged: single.merged,
|
|
785
|
+
conflicts: single.conflicts,
|
|
786
|
+
stats: EMPTY_STATS,
|
|
787
|
+
advisories: single.advisories
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const advisories = [];
|
|
791
|
+
const result = mergeKeyedRecord3(three, (entryId, entry) => {
|
|
792
|
+
const entryLeaf = mergeLeaf3(entry.base, entry.ours, entry.theirs);
|
|
793
|
+
if (entryLeaf.ok) return {
|
|
794
|
+
value: entryLeaf.value,
|
|
795
|
+
conflicts: [],
|
|
796
|
+
stats: statsFor(entryLeaf.from)
|
|
797
|
+
};
|
|
798
|
+
const entryMerge = mergeEntryMeta3(entry, ctx, entryId);
|
|
799
|
+
advisories.push(...entryMerge.advisories);
|
|
800
|
+
return {
|
|
801
|
+
value: entryMerge.merged,
|
|
802
|
+
conflicts: entryMerge.conflicts,
|
|
803
|
+
stats: EMPTY_STATS
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
return {
|
|
807
|
+
merged: result.merged,
|
|
808
|
+
conflicts: result.conflicts,
|
|
809
|
+
stats: result.stats,
|
|
810
|
+
advisories
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region src/core/ops/reconcile/merge-model.ts
|
|
815
|
+
/**
|
|
816
|
+
* Schema keys that decide where a model's files live and how they are
|
|
817
|
+
* keyed. When one of THESE is itself in conflict, the content of the model
|
|
818
|
+
* cannot be located or merged safely — the orchestrator blocks the model's
|
|
819
|
+
* content phase and surfaces only the model-level conflict.
|
|
820
|
+
*/
|
|
821
|
+
const STRUCTURAL_MODEL_KEYS = [
|
|
822
|
+
"kind",
|
|
823
|
+
"i18n",
|
|
824
|
+
"locale_strategy",
|
|
825
|
+
"content_path"
|
|
826
|
+
];
|
|
827
|
+
/**
|
|
828
|
+
* Model definition three-way: top-level keys are leaves, except `fields`,
|
|
829
|
+
* which recurses one level so two sides adding different fields both win.
|
|
830
|
+
* A key changed differently on both sides is a conflict with
|
|
831
|
+
* `suggested: 'theirs'` — the policy table's "the schema belongs to the
|
|
832
|
+
* developer" carried as a suggestion, never auto-applied.
|
|
833
|
+
*/
|
|
834
|
+
function mergeModelFile(three, ctx) {
|
|
835
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
836
|
+
if (fileLeaf.ok) return {
|
|
837
|
+
merged: fileLeaf.value,
|
|
838
|
+
conflicts: [],
|
|
839
|
+
stats: statsFor(fileLeaf.from),
|
|
840
|
+
structurallyBlocked: false
|
|
841
|
+
};
|
|
842
|
+
if (fileLeaf.reason === "delete_edit") return {
|
|
843
|
+
merged: three.ours,
|
|
844
|
+
conflicts: [],
|
|
845
|
+
stats: EMPTY_STATS,
|
|
846
|
+
deleteEdit: { deletedBy: fileLeaf.deletedBy },
|
|
847
|
+
structurallyBlocked: false
|
|
848
|
+
};
|
|
849
|
+
const conflicts = [];
|
|
850
|
+
const merged = {};
|
|
851
|
+
const keys = [...new Set([
|
|
852
|
+
...Object.keys(three.base ?? {}),
|
|
853
|
+
...Object.keys(three.ours ?? {}),
|
|
854
|
+
...Object.keys(three.theirs ?? {})
|
|
855
|
+
])].toSorted();
|
|
856
|
+
for (const key of keys) {
|
|
857
|
+
if (key === "fields") continue;
|
|
858
|
+
const candidate = {
|
|
859
|
+
path: ctx.outPath,
|
|
860
|
+
key,
|
|
861
|
+
base: three.base?.[key],
|
|
862
|
+
ours: three.ours?.[key],
|
|
863
|
+
theirs: three.theirs?.[key]
|
|
864
|
+
};
|
|
865
|
+
const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
|
|
866
|
+
if (leaf.ok) {
|
|
867
|
+
if (leaf.value !== void 0) merged[key] = leaf.value;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
871
|
+
if (resolution) {
|
|
872
|
+
const value = resolvedValue(resolution, candidate);
|
|
873
|
+
if (value !== void 0) merged[key] = value;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
if (candidate.ours !== void 0) merged[key] = candidate.ours;
|
|
877
|
+
conflicts.push(makeConflict({
|
|
878
|
+
...candidate,
|
|
879
|
+
kind: "model",
|
|
880
|
+
model: ctx.model,
|
|
881
|
+
code: "model_key_conflict",
|
|
882
|
+
message: `Model "${ctx.model}": schema key "${key}" was changed differently on both sides.`,
|
|
883
|
+
suggested: "theirs"
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
886
|
+
const fieldNames = [...new Set([
|
|
887
|
+
...Object.keys(three.base?.["fields"] ?? {}),
|
|
888
|
+
...Object.keys(three.ours?.["fields"] ?? {}),
|
|
889
|
+
...Object.keys(three.theirs?.["fields"] ?? {})
|
|
890
|
+
])].toSorted();
|
|
891
|
+
if (fieldNames.length > 0) {
|
|
892
|
+
const mergedFields = {};
|
|
893
|
+
for (const name of fieldNames) {
|
|
894
|
+
const candidate = {
|
|
895
|
+
path: ctx.outPath,
|
|
896
|
+
key: `fields.${name}`,
|
|
897
|
+
base: (three.base?.["fields"])?.[name],
|
|
898
|
+
ours: (three.ours?.["fields"])?.[name],
|
|
899
|
+
theirs: (three.theirs?.["fields"])?.[name]
|
|
900
|
+
};
|
|
901
|
+
const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
|
|
902
|
+
if (leaf.ok) {
|
|
903
|
+
if (leaf.value !== void 0) mergedFields[name] = leaf.value;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
907
|
+
if (resolution) {
|
|
908
|
+
const value = resolvedValue(resolution, candidate);
|
|
909
|
+
if (value !== void 0) mergedFields[name] = value;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (candidate.ours !== void 0) mergedFields[name] = candidate.ours;
|
|
913
|
+
conflicts.push(makeConflict({
|
|
914
|
+
...candidate,
|
|
915
|
+
kind: "model",
|
|
916
|
+
model: ctx.model,
|
|
917
|
+
code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "model_key_conflict",
|
|
918
|
+
message: `Model "${ctx.model}": field "${name}" was changed differently on both sides.`,
|
|
919
|
+
suggested: "theirs"
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
if (Object.keys(mergedFields).length > 0) merged["fields"] = mergedFields;
|
|
923
|
+
}
|
|
924
|
+
const structurallyBlocked = conflicts.some((c) => STRUCTURAL_MODEL_KEYS.includes(c.key ?? ""));
|
|
925
|
+
return {
|
|
926
|
+
merged,
|
|
927
|
+
conflicts,
|
|
928
|
+
stats: conflicts.length === 0 ? {
|
|
929
|
+
takenOurs: 0,
|
|
930
|
+
takenTheirs: 0,
|
|
931
|
+
fieldMerged: 1
|
|
932
|
+
} : EMPTY_STATS,
|
|
933
|
+
structurallyBlocked
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/core/ops/reconcile/merge-vocabulary.ts
|
|
938
|
+
/**
|
|
939
|
+
* Vocabulary three-way at TERM + LOCALE granularity — finer than the brief
|
|
940
|
+
* asked for, and the reason the collabers scenario resolves with zero
|
|
941
|
+
* human input: one side adding a term's Turkish while the other fixes its
|
|
942
|
+
* English are different leaves, both win. Only the same term in the same
|
|
943
|
+
* locale with two different translations is a question.
|
|
944
|
+
*
|
|
945
|
+
* Matches the inline save semantics (`planVocabularySave` merges
|
|
946
|
+
* per-locale) — one policy family, two doors.
|
|
947
|
+
*/
|
|
948
|
+
function mergeVocabulary(three, ctx) {
|
|
949
|
+
const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
|
|
950
|
+
if (fileLeaf.ok) return {
|
|
951
|
+
merged: fileLeaf.value,
|
|
952
|
+
conflicts: [],
|
|
953
|
+
stats: statsFor(fileLeaf.from)
|
|
954
|
+
};
|
|
955
|
+
const version = Math.max(three.base?.version ?? 1, three.ours?.version ?? 1, three.theirs?.version ?? 1);
|
|
956
|
+
const termResult = mergeKeyedRecord3({
|
|
957
|
+
base: three.base?.terms,
|
|
958
|
+
ours: three.ours?.terms,
|
|
959
|
+
theirs: three.theirs?.terms
|
|
960
|
+
}, (term, item) => {
|
|
961
|
+
const termLeaf = mergeLeaf3(item.base, item.ours, item.theirs);
|
|
962
|
+
if (termLeaf.ok) return {
|
|
963
|
+
value: termLeaf.value,
|
|
964
|
+
conflicts: [],
|
|
965
|
+
stats: statsFor(termLeaf.from)
|
|
966
|
+
};
|
|
967
|
+
if (termLeaf.reason === "delete_edit") {
|
|
968
|
+
const candidate = {
|
|
969
|
+
path: ctx.outPath,
|
|
970
|
+
key: term,
|
|
971
|
+
base: item.base,
|
|
972
|
+
ours: item.ours,
|
|
973
|
+
theirs: item.theirs
|
|
974
|
+
};
|
|
975
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
976
|
+
if (resolution) return {
|
|
977
|
+
value: resolvedValue(resolution, candidate),
|
|
978
|
+
conflicts: [],
|
|
979
|
+
stats: EMPTY_STATS
|
|
980
|
+
};
|
|
981
|
+
return {
|
|
982
|
+
value: item.ours,
|
|
983
|
+
conflicts: [makeConflict({
|
|
984
|
+
...candidate,
|
|
985
|
+
kind: "vocabulary",
|
|
986
|
+
code: "delete_edit_conflict",
|
|
987
|
+
message: `Vocabulary term "${term}" was deleted on one side and edited on the other.`
|
|
988
|
+
})],
|
|
989
|
+
stats: EMPTY_STATS
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const inner = mergeKeyedRecord3(item, (locale, translation) => {
|
|
993
|
+
const leaf = mergeLeaf3(translation.base, translation.ours, translation.theirs);
|
|
994
|
+
if (leaf.ok) return {
|
|
995
|
+
value: leaf.value,
|
|
996
|
+
conflicts: [],
|
|
997
|
+
stats: statsFor(leaf.from)
|
|
998
|
+
};
|
|
999
|
+
const candidate = {
|
|
1000
|
+
path: ctx.outPath,
|
|
1001
|
+
key: term,
|
|
1002
|
+
locale,
|
|
1003
|
+
base: translation.base,
|
|
1004
|
+
ours: translation.ours,
|
|
1005
|
+
theirs: translation.theirs
|
|
1006
|
+
};
|
|
1007
|
+
const resolution = ctx.resolutions.consume(candidate);
|
|
1008
|
+
if (resolution) return {
|
|
1009
|
+
value: resolvedValue(resolution, candidate),
|
|
1010
|
+
conflicts: [],
|
|
1011
|
+
stats: EMPTY_STATS
|
|
1012
|
+
};
|
|
1013
|
+
return {
|
|
1014
|
+
value: translation.ours,
|
|
1015
|
+
conflicts: [makeConflict({
|
|
1016
|
+
...candidate,
|
|
1017
|
+
kind: "vocabulary",
|
|
1018
|
+
code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "vocabulary_value_conflict",
|
|
1019
|
+
message: `Term "${term}" has two different "${locale}" translations.`
|
|
1020
|
+
})],
|
|
1021
|
+
stats: EMPTY_STATS
|
|
1022
|
+
};
|
|
1023
|
+
});
|
|
1024
|
+
return {
|
|
1025
|
+
value: Object.keys(inner.merged).length > 0 ? inner.merged : void 0,
|
|
1026
|
+
conflicts: inner.conflicts,
|
|
1027
|
+
stats: inner.conflicts.length === 0 ? addStats(inner.stats, {
|
|
1028
|
+
takenOurs: 0,
|
|
1029
|
+
takenTheirs: 0,
|
|
1030
|
+
fieldMerged: 1
|
|
1031
|
+
}) : inner.stats
|
|
1032
|
+
};
|
|
1033
|
+
});
|
|
1034
|
+
return {
|
|
1035
|
+
merged: {
|
|
1036
|
+
version,
|
|
1037
|
+
terms: termResult.merged
|
|
1038
|
+
},
|
|
1039
|
+
conflicts: termResult.conflicts,
|
|
1040
|
+
stats: termResult.stats
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/core/ops/reconcile/walk.ts
|
|
1045
|
+
const SIDES = [
|
|
1046
|
+
"base",
|
|
1047
|
+
"ours",
|
|
1048
|
+
"theirs"
|
|
1049
|
+
];
|
|
1050
|
+
/** Union of model ids across the three trees, sorted. */
|
|
1051
|
+
async function listModelIds(readers) {
|
|
1052
|
+
const listings = await Promise.all(SIDES.map((side) => readers[side].listDirectory(".contentrain/models")));
|
|
1053
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1054
|
+
for (const listing of listings) for (const entry of listing) if (entry.endsWith(".json")) ids.add(entry.slice(0, -5));
|
|
1055
|
+
return [...ids].toSorted();
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Enumerate a model's content files on one side, using THAT side's model
|
|
1059
|
+
* definition — `content_path` and `locale_strategy` may differ between
|
|
1060
|
+
* sides, so each side's files are found where that side actually keeps
|
|
1061
|
+
* them. Filenames are mapped back to (locale, slug) by inverting the
|
|
1062
|
+
* `paths.ts` layout for the declared strategy; nothing is guessed from
|
|
1063
|
+
* config.
|
|
1064
|
+
*/
|
|
1065
|
+
async function enumerateSideUnits(reader, def, defaultLocale) {
|
|
1066
|
+
const dir = contentDirPath(def);
|
|
1067
|
+
const strategy = def.locale_strategy ?? "file";
|
|
1068
|
+
const units = [];
|
|
1069
|
+
if (def.kind === "document") {
|
|
1070
|
+
if (!def.i18n || strategy === "none") {
|
|
1071
|
+
const entries = await reader.listDirectory(dir);
|
|
1072
|
+
for (const entry of entries) if (entry.endsWith(".md")) units.push({
|
|
1073
|
+
locale: defaultLocale,
|
|
1074
|
+
slug: entry.slice(0, -3),
|
|
1075
|
+
path: `${dir}/${entry}`
|
|
1076
|
+
});
|
|
1077
|
+
return units;
|
|
1078
|
+
}
|
|
1079
|
+
if (strategy === "suffix") {
|
|
1080
|
+
const entries = await reader.listDirectory(dir);
|
|
1081
|
+
for (const entry of entries) {
|
|
1082
|
+
const match = /^(.+)\.([a-z]{2}(?:-[A-Za-z]{2})?)\.md$/.exec(entry);
|
|
1083
|
+
if (match) units.push({
|
|
1084
|
+
slug: match[1],
|
|
1085
|
+
locale: match[2],
|
|
1086
|
+
path: `${dir}/${entry}`
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
return units;
|
|
1090
|
+
}
|
|
1091
|
+
if (strategy === "directory") {
|
|
1092
|
+
const locales = await reader.listDirectory(dir);
|
|
1093
|
+
const listings = await Promise.all(locales.map(async (locale) => ({
|
|
1094
|
+
locale,
|
|
1095
|
+
entries: locale.includes(".") ? [] : await reader.listDirectory(`${dir}/${locale}`)
|
|
1096
|
+
})));
|
|
1097
|
+
for (const { locale, entries } of listings) for (const entry of entries) if (entry.endsWith(".md")) units.push({
|
|
1098
|
+
locale,
|
|
1099
|
+
slug: entry.slice(0, -3),
|
|
1100
|
+
path: `${dir}/${locale}/${entry}`
|
|
1101
|
+
});
|
|
1102
|
+
return units;
|
|
1103
|
+
}
|
|
1104
|
+
const slugs = await reader.listDirectory(dir);
|
|
1105
|
+
const listings = await Promise.all(slugs.map(async (slug) => ({
|
|
1106
|
+
slug,
|
|
1107
|
+
entries: slug.includes(".") ? [] : await reader.listDirectory(`${dir}/${slug}`)
|
|
1108
|
+
})));
|
|
1109
|
+
for (const { slug, entries } of listings) for (const entry of entries) if (entry.endsWith(".md")) units.push({
|
|
1110
|
+
slug,
|
|
1111
|
+
locale: entry.slice(0, -3),
|
|
1112
|
+
path: `${dir}/${slug}/${entry}`
|
|
1113
|
+
});
|
|
1114
|
+
return units;
|
|
1115
|
+
}
|
|
1116
|
+
if (!def.i18n) {
|
|
1117
|
+
if (await reader.fileExists(`${dir}/data.json`)) units.push({
|
|
1118
|
+
locale: defaultLocale,
|
|
1119
|
+
path: `${dir}/data.json`
|
|
1120
|
+
});
|
|
1121
|
+
return units;
|
|
1122
|
+
}
|
|
1123
|
+
if (strategy === "none") {
|
|
1124
|
+
if (await reader.fileExists(`${dir}/${def.id}.json`)) units.push({
|
|
1125
|
+
locale: defaultLocale,
|
|
1126
|
+
path: `${dir}/${def.id}.json`
|
|
1127
|
+
});
|
|
1128
|
+
return units;
|
|
1129
|
+
}
|
|
1130
|
+
if (strategy === "suffix") {
|
|
1131
|
+
const entries = await reader.listDirectory(dir);
|
|
1132
|
+
const prefix = `${def.id}.`;
|
|
1133
|
+
for (const entry of entries) if (entry.startsWith(prefix) && entry.endsWith(".json")) {
|
|
1134
|
+
const locale = entry.slice(prefix.length, -5);
|
|
1135
|
+
if (locale && !locale.includes(".")) units.push({
|
|
1136
|
+
locale,
|
|
1137
|
+
path: `${dir}/${entry}`
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return units;
|
|
1141
|
+
}
|
|
1142
|
+
if (strategy === "directory") {
|
|
1143
|
+
const locales = await reader.listDirectory(dir);
|
|
1144
|
+
const checks = await Promise.all(locales.map(async (locale) => ({
|
|
1145
|
+
locale,
|
|
1146
|
+
exists: locale.includes(".") ? false : await reader.fileExists(`${dir}/${locale}/${def.id}.json`)
|
|
1147
|
+
})));
|
|
1148
|
+
for (const { locale, exists } of checks) if (exists) units.push({
|
|
1149
|
+
locale,
|
|
1150
|
+
path: `${dir}/${locale}/${def.id}.json`
|
|
1151
|
+
});
|
|
1152
|
+
return units;
|
|
1153
|
+
}
|
|
1154
|
+
const entries = await reader.listDirectory(dir);
|
|
1155
|
+
for (const entry of entries) if (entry.endsWith(".json") && entry !== "data.json") units.push({
|
|
1156
|
+
locale: entry.slice(0, -5),
|
|
1157
|
+
path: `${dir}/${entry}`
|
|
1158
|
+
});
|
|
1159
|
+
return units;
|
|
1160
|
+
}
|
|
1161
|
+
/** Resolve the output path of a logical unit under the WINNING definition. */
|
|
1162
|
+
function unitOutPath(winning, defaultLocale, locale, slug) {
|
|
1163
|
+
if (winning.kind === "document") return documentFilePath(winning, locale ?? defaultLocale, slug ?? "");
|
|
1164
|
+
return contentFilePath(winning, locale ?? defaultLocale);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Enumerate a model's meta files on one side. The meta layout is fixed
|
|
1168
|
+
* (`.contentrain/meta/{id}/[{slug}/]{locale}.json`) regardless of
|
|
1169
|
+
* `content_path` or locale strategy, so this stays definition-light and
|
|
1170
|
+
* works even while the definitions disagree.
|
|
1171
|
+
*/
|
|
1172
|
+
async function enumerateMetaUnits(reader, modelId) {
|
|
1173
|
+
const dir = `.contentrain/meta/${modelId}`;
|
|
1174
|
+
const entries = await reader.listDirectory(dir);
|
|
1175
|
+
const units = [];
|
|
1176
|
+
const slugDirs = [];
|
|
1177
|
+
for (const entry of entries) if (entry.endsWith(".json")) units.push({
|
|
1178
|
+
locale: entry.slice(0, -5),
|
|
1179
|
+
path: `${dir}/${entry}`
|
|
1180
|
+
});
|
|
1181
|
+
else if (!entry.includes(".")) slugDirs.push(entry);
|
|
1182
|
+
const nested = await Promise.all(slugDirs.map(async (slug) => ({
|
|
1183
|
+
slug,
|
|
1184
|
+
entries: await reader.listDirectory(`${dir}/${slug}`)
|
|
1185
|
+
})));
|
|
1186
|
+
for (const { slug, entries: files } of nested) for (const file of files) if (file.endsWith(".json")) units.push({
|
|
1187
|
+
locale: file.slice(0, -5),
|
|
1188
|
+
slug,
|
|
1189
|
+
path: `${dir}/${slug}/${file}`
|
|
1190
|
+
});
|
|
1191
|
+
return units;
|
|
1192
|
+
}
|
|
1193
|
+
const SCAN_MAX_DEPTH = 6;
|
|
1194
|
+
const SCAN_SKIP = new Set([".contentrain/client", ".contentrain/context.json"]);
|
|
1195
|
+
/**
|
|
1196
|
+
* Recursively list candidate text files (`.json` / `.md`) under `root` on
|
|
1197
|
+
* one side. The generated client and context.json are never candidates;
|
|
1198
|
+
* depth is bounded as a runaway guard. Anything that is not a candidate
|
|
1199
|
+
* extension is treated as a possible directory — listing a file yields an
|
|
1200
|
+
* empty array on every reader, so the probe is safe.
|
|
1201
|
+
*/
|
|
1202
|
+
async function listTextFiles(reader, root, depth = 0) {
|
|
1203
|
+
if (depth > SCAN_MAX_DEPTH) return [];
|
|
1204
|
+
const entries = await reader.listDirectory(root);
|
|
1205
|
+
return (await Promise.all(entries.map(async (entry) => {
|
|
1206
|
+
const path = root === "" ? entry : `${root}/${entry}`;
|
|
1207
|
+
if (SCAN_SKIP.has(path)) return [];
|
|
1208
|
+
if (entry.endsWith(".json") || entry.endsWith(".md")) return [path];
|
|
1209
|
+
return listTextFiles(reader, path, depth + 1);
|
|
1210
|
+
}))).flat();
|
|
1211
|
+
}
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/core/ops/reconcile/plan.ts
|
|
1214
|
+
const CONTEXT_PATH = ".contentrain/context.json";
|
|
1215
|
+
const VOCABULARY_PATH = ".contentrain/vocabulary.json";
|
|
1216
|
+
const SOURCES_PATH = ".contentrain/normalize-sources.json";
|
|
1217
|
+
const MAX_VALIDATION_ADVISORIES = 15;
|
|
1218
|
+
/**
|
|
1219
|
+
* Content-aware three-way merge of `.contentrain/**` (plus `content_path`
|
|
1220
|
+
* roots) between a merge-base, the content branch (ours) and the base
|
|
1221
|
+
* branch (theirs). Pure planning: reads through the three bound readers,
|
|
1222
|
+
* writes nothing, and returns `FileChange[]` to apply ON TOP OF OURS,
|
|
1223
|
+
* `ConflictItem[]` for whatever the policy table cannot decide, and a
|
|
1224
|
+
* regenerated context.json whenever the plan is not a no-op.
|
|
1225
|
+
*
|
|
1226
|
+
* Determinism: the same three trees with the same resolutions produce the
|
|
1227
|
+
* same changes and the same conflict ids — context.json's embedded
|
|
1228
|
+
* timestamp is the sole documented exception.
|
|
1229
|
+
*/
|
|
1230
|
+
async function planReconcile(input) {
|
|
1231
|
+
const readers = {
|
|
1232
|
+
base: new CachedReader(input.base),
|
|
1233
|
+
ours: new CachedReader(input.ours),
|
|
1234
|
+
theirs: new CachedReader(input.theirs)
|
|
1235
|
+
};
|
|
1236
|
+
const resolutions = new ResolutionIndex(input.resolutions ?? []);
|
|
1237
|
+
const conflicts = [];
|
|
1238
|
+
const advisories = [];
|
|
1239
|
+
const outputs = [];
|
|
1240
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1241
|
+
let stats = EMPTY_STATS;
|
|
1242
|
+
const defaultLocale = (await readJsonOrNull(readers.ours, ".contentrain/config.json"))?.locales?.default ?? "en";
|
|
1243
|
+
const modelIds = await listModelIds(readers);
|
|
1244
|
+
const modelStates = (await Promise.all(modelIds.map(async (id) => {
|
|
1245
|
+
const path = `.contentrain/models/${id}.json`;
|
|
1246
|
+
const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], path)));
|
|
1247
|
+
return {
|
|
1248
|
+
id,
|
|
1249
|
+
path,
|
|
1250
|
+
three: {
|
|
1251
|
+
base: base ?? void 0,
|
|
1252
|
+
ours: ours ?? void 0,
|
|
1253
|
+
theirs: theirs ?? void 0
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
}))).map((m) => {
|
|
1257
|
+
claimed.add(m.path);
|
|
1258
|
+
const result = mergeModelFile(m.three, {
|
|
1259
|
+
outPath: m.path,
|
|
1260
|
+
kind: "model",
|
|
1261
|
+
model: m.id,
|
|
1262
|
+
resolutions
|
|
1263
|
+
});
|
|
1264
|
+
return {
|
|
1265
|
+
id: m.id,
|
|
1266
|
+
path: m.path,
|
|
1267
|
+
three: m.three,
|
|
1268
|
+
result
|
|
1269
|
+
};
|
|
1270
|
+
});
|
|
1271
|
+
await Promise.all(modelStates.map(async (state) => {
|
|
1272
|
+
const { id, path, three, result } = state;
|
|
1273
|
+
const oursAbsent = three.ours === void 0;
|
|
1274
|
+
const theirsAbsent = three.theirs === void 0;
|
|
1275
|
+
const oneSideDeleted = three.base !== void 0 && oursAbsent !== theirsAbsent;
|
|
1276
|
+
if (result.deleteEdit || result.merged === void 0 && oneSideDeleted) {
|
|
1277
|
+
const deletedBy = result.deleteEdit?.deletedBy ?? (oursAbsent ? "ours" : "theirs");
|
|
1278
|
+
const survivor = deletedBy === "ours" ? "theirs" : "ours";
|
|
1279
|
+
await collapseModelDeletion(state.id, path, three, deletedBy, survivor, !!result.deleteEdit);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
conflicts.push(...result.conflicts);
|
|
1283
|
+
stats = addStats(stats, result.stats);
|
|
1284
|
+
if (result.merged === void 0) {
|
|
1285
|
+
await claimModelFiles(id, three);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (result.structurallyBlocked) {
|
|
1289
|
+
advisories.push(`Model "${id}": structural schema keys are in conflict — its content was left untouched until they are resolved.`);
|
|
1290
|
+
await claimModelFiles(id, three);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const winning = result.merged;
|
|
1294
|
+
if (typeof winning.kind !== "string" || typeof winning.id !== "string") {
|
|
1295
|
+
advisories.push(`Model "${id}": merged definition is structurally incomplete — content left untouched.`);
|
|
1296
|
+
await claimModelFiles(id, three);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
outputs.push({
|
|
1300
|
+
path,
|
|
1301
|
+
format: "model",
|
|
1302
|
+
value: result.merged
|
|
1303
|
+
});
|
|
1304
|
+
const sideDefs = {};
|
|
1305
|
+
for (const side of SIDES) {
|
|
1306
|
+
const def = three[side];
|
|
1307
|
+
if (def && typeof def.kind === "string") sideDefs[side] = def;
|
|
1308
|
+
}
|
|
1309
|
+
const perSide = await Promise.all(SIDES.map(async (side) => {
|
|
1310
|
+
const def = sideDefs[side];
|
|
1311
|
+
return {
|
|
1312
|
+
side,
|
|
1313
|
+
units: def ? await enumerateSideUnits(readers[side], def, defaultLocale) : []
|
|
1314
|
+
};
|
|
1315
|
+
}));
|
|
1316
|
+
const unitMap = /* @__PURE__ */ new Map();
|
|
1317
|
+
for (const { side, units } of perSide) for (const unit of units) {
|
|
1318
|
+
claimed.add(unit.path);
|
|
1319
|
+
const key = unitKey(unit);
|
|
1320
|
+
const entry = unitMap.get(key) ?? {
|
|
1321
|
+
locale: unit.locale,
|
|
1322
|
+
slug: unit.slug,
|
|
1323
|
+
paths: {}
|
|
1324
|
+
};
|
|
1325
|
+
entry.paths[side] = unit.path;
|
|
1326
|
+
unitMap.set(key, entry);
|
|
1327
|
+
}
|
|
1328
|
+
await Promise.all([...unitMap.values()].map(async (unit) => {
|
|
1329
|
+
const [baseRaw, oursRaw, theirsRaw] = await Promise.all(SIDES.map((side) => unit.paths[side] ? readers[side].readOrNull(unit.paths[side]) : Promise.resolve(null)));
|
|
1330
|
+
const outPath = unitOutPath(winning, defaultLocale, unit.locale, unit.slug);
|
|
1331
|
+
claimed.add(outPath);
|
|
1332
|
+
const ctx = {
|
|
1333
|
+
outPath,
|
|
1334
|
+
kind: winning.kind,
|
|
1335
|
+
model: id,
|
|
1336
|
+
locale: unit.locale,
|
|
1337
|
+
resolutions
|
|
1338
|
+
};
|
|
1339
|
+
if (winning.kind === "document") {
|
|
1340
|
+
const merge = mergeDocumentFile({
|
|
1341
|
+
base: baseRaw ?? void 0,
|
|
1342
|
+
ours: oursRaw ?? void 0,
|
|
1343
|
+
theirs: theirsRaw ?? void 0
|
|
1344
|
+
}, {
|
|
1345
|
+
...ctx,
|
|
1346
|
+
slug: unit.slug ?? ""
|
|
1347
|
+
});
|
|
1348
|
+
conflicts.push(...merge.conflicts);
|
|
1349
|
+
advisories.push(...merge.advisories);
|
|
1350
|
+
stats = addStats(stats, merge.stats);
|
|
1351
|
+
outputs.push({
|
|
1352
|
+
path: outPath,
|
|
1353
|
+
format: "md",
|
|
1354
|
+
value: merge.merged ?? null
|
|
1355
|
+
});
|
|
1356
|
+
} else {
|
|
1357
|
+
const parsed = {
|
|
1358
|
+
base: parseJson(baseRaw ?? null),
|
|
1359
|
+
ours: parseJson(oursRaw ?? null),
|
|
1360
|
+
theirs: parseJson(theirsRaw ?? null)
|
|
1361
|
+
};
|
|
1362
|
+
const merge = winning.kind === "dictionary" ? mergeDictionaryFile(parsed, ctx) : winning.kind === "singleton" ? mergeSingletonFile(parsed, ctx) : mergeCollectionFile(parsed, ctx);
|
|
1363
|
+
conflicts.push(...merge.conflicts);
|
|
1364
|
+
stats = addStats(stats, merge.stats);
|
|
1365
|
+
outputs.push({
|
|
1366
|
+
path: outPath,
|
|
1367
|
+
format: "json",
|
|
1368
|
+
value: merge.merged ?? null
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
if (unit.paths.ours && unit.paths.ours !== outPath) outputs.push({
|
|
1372
|
+
path: unit.paths.ours,
|
|
1373
|
+
format: "raw",
|
|
1374
|
+
value: null
|
|
1375
|
+
});
|
|
1376
|
+
}));
|
|
1377
|
+
const metaPerSide = await Promise.all(SIDES.map((side) => enumerateMetaUnits(readers[side], id)));
|
|
1378
|
+
const metaMap = /* @__PURE__ */ new Map();
|
|
1379
|
+
for (const units of metaPerSide) for (const unit of units) {
|
|
1380
|
+
claimed.add(unit.path);
|
|
1381
|
+
metaMap.set(unit.path, unit);
|
|
1382
|
+
}
|
|
1383
|
+
await Promise.all([...metaMap.values()].map(async (unit) => {
|
|
1384
|
+
const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], unit.path)));
|
|
1385
|
+
const merge = mergeMetaFile({
|
|
1386
|
+
base: base ?? void 0,
|
|
1387
|
+
ours: ours ?? void 0,
|
|
1388
|
+
theirs: theirs ?? void 0
|
|
1389
|
+
}, {
|
|
1390
|
+
outPath: unit.path,
|
|
1391
|
+
kind: "meta",
|
|
1392
|
+
model: id,
|
|
1393
|
+
locale: unit.locale,
|
|
1394
|
+
resolutions
|
|
1395
|
+
}, winning.kind === "collection");
|
|
1396
|
+
conflicts.push(...merge.conflicts);
|
|
1397
|
+
advisories.push(...merge.advisories);
|
|
1398
|
+
stats = addStats(stats, merge.stats);
|
|
1399
|
+
outputs.push({
|
|
1400
|
+
path: unit.path,
|
|
1401
|
+
format: "json",
|
|
1402
|
+
value: merge.merged ?? null
|
|
1403
|
+
});
|
|
1404
|
+
}));
|
|
1405
|
+
}));
|
|
1406
|
+
claimed.add(VOCABULARY_PATH);
|
|
1407
|
+
claimed.add(SOURCES_PATH);
|
|
1408
|
+
{
|
|
1409
|
+
const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], VOCABULARY_PATH)));
|
|
1410
|
+
if (base !== null || ours !== null || theirs !== null) {
|
|
1411
|
+
const merge = mergeVocabulary({
|
|
1412
|
+
base: base ?? void 0,
|
|
1413
|
+
ours: ours ?? void 0,
|
|
1414
|
+
theirs: theirs ?? void 0
|
|
1415
|
+
}, {
|
|
1416
|
+
outPath: VOCABULARY_PATH,
|
|
1417
|
+
kind: "vocabulary",
|
|
1418
|
+
resolutions
|
|
1419
|
+
});
|
|
1420
|
+
conflicts.push(...merge.conflicts);
|
|
1421
|
+
stats = addStats(stats, merge.stats);
|
|
1422
|
+
outputs.push({
|
|
1423
|
+
path: VOCABULARY_PATH,
|
|
1424
|
+
format: "json",
|
|
1425
|
+
value: merge.merged ?? null
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
{
|
|
1430
|
+
const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], SOURCES_PATH)));
|
|
1431
|
+
if (base !== null || ours !== null || theirs !== null) {
|
|
1432
|
+
const merge = mergeKeyedJson({
|
|
1433
|
+
base: base ?? void 0,
|
|
1434
|
+
ours: ours ?? void 0,
|
|
1435
|
+
theirs: theirs ?? void 0
|
|
1436
|
+
}, {
|
|
1437
|
+
outPath: SOURCES_PATH,
|
|
1438
|
+
kind: "file",
|
|
1439
|
+
resolutions
|
|
1440
|
+
});
|
|
1441
|
+
conflicts.push(...merge.conflicts);
|
|
1442
|
+
stats = addStats(stats, merge.stats);
|
|
1443
|
+
outputs.push({
|
|
1444
|
+
path: SOURCES_PATH,
|
|
1445
|
+
format: "json",
|
|
1446
|
+
value: merge.merged ?? null
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
const roots = new Set([".contentrain"]);
|
|
1451
|
+
for (const state of modelStates) for (const side of SIDES) {
|
|
1452
|
+
const contentPath = state.three[side]?.content_path;
|
|
1453
|
+
if (typeof contentPath === "string" && contentPath.length > 0) roots.add(contentPath);
|
|
1454
|
+
}
|
|
1455
|
+
const scanned = await Promise.all([...roots].map(async (root) => Promise.all(SIDES.map((side) => listTextFiles(readers[side], root)))));
|
|
1456
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1457
|
+
for (const perRoot of scanned) for (const files of perRoot) for (const file of files) if (!claimed.has(file)) candidates.add(file);
|
|
1458
|
+
await Promise.all([...candidates].toSorted().map(async (path) => {
|
|
1459
|
+
const [baseRaw, oursRaw, theirsRaw] = await Promise.all(SIDES.map((side) => readers[side].readOrNull(path)));
|
|
1460
|
+
const leaf = mergeLeaf3(baseRaw ?? void 0, oursRaw ?? void 0, theirsRaw ?? void 0);
|
|
1461
|
+
if (leaf.ok) {
|
|
1462
|
+
if (leaf.from === "theirs") outputs.push({
|
|
1463
|
+
path,
|
|
1464
|
+
format: "raw",
|
|
1465
|
+
value: leaf.value ?? null
|
|
1466
|
+
});
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const digestCandidate = {
|
|
1470
|
+
path,
|
|
1471
|
+
base: baseRaw == null ? void 0 : stableHash(baseRaw),
|
|
1472
|
+
ours: oursRaw == null ? void 0 : stableHash(oursRaw),
|
|
1473
|
+
theirs: theirsRaw == null ? void 0 : stableHash(theirsRaw)
|
|
1474
|
+
};
|
|
1475
|
+
const resolution = resolutions.consume(digestCandidate);
|
|
1476
|
+
if (resolution && "choose" in resolution) {
|
|
1477
|
+
const chosen = resolution.choose === "ours" ? oursRaw : theirsRaw;
|
|
1478
|
+
outputs.push({
|
|
1479
|
+
path,
|
|
1480
|
+
format: "raw",
|
|
1481
|
+
value: chosen
|
|
1482
|
+
});
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
if (resolution) advisories.push(`Resolution for "${path}" supplied a value — file-level conflicts accept only { choose } and the resolution was ignored.`);
|
|
1486
|
+
conflicts.push({
|
|
1487
|
+
id: conflictId(digestCandidate),
|
|
1488
|
+
path,
|
|
1489
|
+
kind: "file",
|
|
1490
|
+
code: "file_conflict",
|
|
1491
|
+
message: `${path} was changed on both sides and is not content this planner understands — choose a side.`
|
|
1492
|
+
});
|
|
1493
|
+
}));
|
|
1494
|
+
const changes = [];
|
|
1495
|
+
await Promise.all(outputs.map(async (out) => {
|
|
1496
|
+
const oursRaw = await readers.ours.readOrNull(out.path);
|
|
1497
|
+
if (out.value === null) {
|
|
1498
|
+
if (oursRaw !== null) changes.push({
|
|
1499
|
+
path: out.path,
|
|
1500
|
+
content: null
|
|
1501
|
+
});
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (out.format === "md" || out.format === "raw") {
|
|
1505
|
+
const next = out.value;
|
|
1506
|
+
if (oursRaw === null) {
|
|
1507
|
+
changes.push({
|
|
1508
|
+
path: out.path,
|
|
1509
|
+
content: next
|
|
1510
|
+
});
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (out.format === "raw") {
|
|
1514
|
+
if (oursRaw !== next) changes.push({
|
|
1515
|
+
path: out.path,
|
|
1516
|
+
content: next
|
|
1517
|
+
});
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const a = parseMarkdownFrontmatter(oursRaw);
|
|
1521
|
+
const b = parseMarkdownFrontmatter(next);
|
|
1522
|
+
if (!eqCanonical(a.frontmatter, b.frontmatter) || a.body !== b.body) changes.push({
|
|
1523
|
+
path: out.path,
|
|
1524
|
+
content: next
|
|
1525
|
+
});
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const oursParsed = parseJson(oursRaw);
|
|
1529
|
+
if (eqCanonical(out.value, oursParsed)) return;
|
|
1530
|
+
const fieldOrder = out.format === "model" ? MODEL_FIELD_ORDER : void 0;
|
|
1531
|
+
changes.push({
|
|
1532
|
+
path: out.path,
|
|
1533
|
+
content: canonicalStringify(out.value, fieldOrder)
|
|
1534
|
+
});
|
|
1535
|
+
}));
|
|
1536
|
+
const filesMerged = changes.length;
|
|
1537
|
+
const regenerated = [];
|
|
1538
|
+
if (changes.length > 0) try {
|
|
1539
|
+
const contextChange = await buildContextChange(new OverlayReader(readers.ours, changes), {
|
|
1540
|
+
tool: "contentrain_reconcile",
|
|
1541
|
+
model: "*"
|
|
1542
|
+
}, input.source);
|
|
1543
|
+
changes.push(contextChange);
|
|
1544
|
+
regenerated.push(CONTEXT_PATH);
|
|
1545
|
+
} catch {
|
|
1546
|
+
advisories.push("context.json could not be regenerated — run contentrain_validate after applying.");
|
|
1547
|
+
}
|
|
1548
|
+
if (changes.length > 0) try {
|
|
1549
|
+
const issues = (await validateProject(new OverlayReader(readers.ours, changes), {})).issues.filter((i) => i.severity !== "notice");
|
|
1550
|
+
const shown = issues.slice(0, MAX_VALIDATION_ADVISORIES);
|
|
1551
|
+
for (const issue of shown) advisories.push(`validation (${issue.severity}): ${issue.model ?? ""}${issue.field ? `.${issue.field}` : ""} — ${issue.message}`);
|
|
1552
|
+
if (issues.length > shown.length) advisories.push(`validation: ${issues.length - shown.length} more issue(s) — run contentrain_validate for the full report.`);
|
|
1553
|
+
} catch {
|
|
1554
|
+
advisories.push("Post-merge validation could not run — validate after applying.");
|
|
1555
|
+
}
|
|
1556
|
+
for (const stale of resolutions.unconsumed()) advisories.push(`Resolution ${stale.id} no longer matches any conflict — values changed since the dry-run; the conflict is re-reported with a new id.`);
|
|
1557
|
+
if (changes.length === 0 && conflicts.length === 0) advisories.push("Trees are already reconciled — nothing to merge.");
|
|
1558
|
+
changes.sort((a, b) => a.path.localeCompare(b.path, "en"));
|
|
1559
|
+
conflicts.sort((a, b) => a.path.localeCompare(b.path, "en") || (a.key ?? "").localeCompare(b.key ?? "", "en") || (a.field ?? "").localeCompare(b.field ?? "", "en") || (a.locale ?? "").localeCompare(b.locale ?? "", "en"));
|
|
1560
|
+
return {
|
|
1561
|
+
changes,
|
|
1562
|
+
conflicts,
|
|
1563
|
+
advisories,
|
|
1564
|
+
result: {
|
|
1565
|
+
files_merged: filesMerged,
|
|
1566
|
+
entries_taken_ours: stats.takenOurs,
|
|
1567
|
+
entries_taken_theirs: stats.takenTheirs,
|
|
1568
|
+
entries_field_merged: stats.fieldMerged,
|
|
1569
|
+
regenerated
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
/** Claim every side's files of a model so the scan never double-reports. */
|
|
1573
|
+
async function claimModelFiles(id, three) {
|
|
1574
|
+
await Promise.all(SIDES.map(async (side) => {
|
|
1575
|
+
const def = three[side];
|
|
1576
|
+
if (def && typeof def.kind === "string") {
|
|
1577
|
+
const units = await enumerateSideUnits(readers[side], def, defaultLocale);
|
|
1578
|
+
for (const unit of units) claimed.add(unit.path);
|
|
1579
|
+
}
|
|
1580
|
+
const meta = await enumerateMetaUnits(readers[side], id);
|
|
1581
|
+
for (const unit of meta) claimed.add(unit.path);
|
|
1582
|
+
}));
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* One side deleted the model. If the surviving side left everything
|
|
1586
|
+
* untouched, the deletion cascades mechanically over content and meta.
|
|
1587
|
+
* The moment the survivor edited anything — the definition or one entry —
|
|
1588
|
+
* the whole model collapses into a SINGLE delete-vs-edit question
|
|
1589
|
+
* instead of one conflict per file.
|
|
1590
|
+
*/
|
|
1591
|
+
async function collapseModelDeletion(id, modelPath, three, deletedBy, survivor, modelFileEdited) {
|
|
1592
|
+
await claimModelFiles(id, three);
|
|
1593
|
+
const survivorDef = three[survivor];
|
|
1594
|
+
const survivorFiles = await modelFileDigestList(survivor, id, survivorDef);
|
|
1595
|
+
const baseFiles = await modelFileDigestList("base", id, three.base);
|
|
1596
|
+
const contentEdited = JSON.stringify(survivorFiles) !== JSON.stringify(baseFiles);
|
|
1597
|
+
if (!modelFileEdited && !contentEdited) {
|
|
1598
|
+
await deleteModelEverywhere(id, three);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const digestFor = async (side) => {
|
|
1602
|
+
const def = three[side];
|
|
1603
|
+
if (!def && side !== "base") return void 0;
|
|
1604
|
+
const files = await modelFileDigestList(side, id, def);
|
|
1605
|
+
return {
|
|
1606
|
+
model: three[side] ? stableHash(JSON.stringify(three[side])) : null,
|
|
1607
|
+
files: stableHash(JSON.stringify(files))
|
|
1608
|
+
};
|
|
1609
|
+
};
|
|
1610
|
+
const [baseDigest, oursDigest, theirsDigest] = await Promise.all([
|
|
1611
|
+
digestFor("base"),
|
|
1612
|
+
digestFor("ours"),
|
|
1613
|
+
digestFor("theirs")
|
|
1614
|
+
]);
|
|
1615
|
+
const candidate = {
|
|
1616
|
+
path: modelPath,
|
|
1617
|
+
base: baseDigest,
|
|
1618
|
+
ours: oursDigest,
|
|
1619
|
+
theirs: theirsDigest
|
|
1620
|
+
};
|
|
1621
|
+
const resolution = resolutions.consume(candidate);
|
|
1622
|
+
if (resolution && "choose" in resolution) {
|
|
1623
|
+
if (resolution.choose === deletedBy) await deleteModelEverywhere(id, three);
|
|
1624
|
+
else await keepModelWholesale(id, modelPath, three, survivor);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
if (resolution) advisories.push(`Resolution for model "${id}" supplied a value — model delete-vs-edit accepts only { choose } and the resolution was ignored.`);
|
|
1628
|
+
conflicts.push(makeConflict({
|
|
1629
|
+
...candidate,
|
|
1630
|
+
kind: "model",
|
|
1631
|
+
model: id,
|
|
1632
|
+
code: "delete_edit_conflict",
|
|
1633
|
+
message: `Model "${id}" was deleted on one side (${deletedBy}) while the other side edited its ${modelFileEdited ? "definition" : "content"} — keep the model or drop it.`
|
|
1634
|
+
}));
|
|
1635
|
+
}
|
|
1636
|
+
/** Digest list of one side's model-owned files (content + meta), sorted. */
|
|
1637
|
+
async function modelFileDigestList(side, id, def) {
|
|
1638
|
+
const units = def && typeof def.kind === "string" ? await enumerateSideUnits(readers[side], def, defaultLocale) : [];
|
|
1639
|
+
const meta = await enumerateMetaUnits(readers[side], id);
|
|
1640
|
+
const paths = [...units.map((u) => u.path), ...meta.map((u) => u.path)].toSorted();
|
|
1641
|
+
const raws = await Promise.all(paths.map((path) => readers[side].readOrNull(path)));
|
|
1642
|
+
return paths.map((path, i) => [path, stableHash(raws[i] ?? "")]);
|
|
1643
|
+
}
|
|
1644
|
+
/** Emit deletions for every model-owned file ours still carries. */
|
|
1645
|
+
async function deleteModelEverywhere(id, three) {
|
|
1646
|
+
outputs.push({
|
|
1647
|
+
path: `.contentrain/models/${id}.json`,
|
|
1648
|
+
format: "raw",
|
|
1649
|
+
value: null
|
|
1650
|
+
});
|
|
1651
|
+
const oursDef = three.ours;
|
|
1652
|
+
const units = oursDef && typeof oursDef.kind === "string" ? await enumerateSideUnits(readers.ours, oursDef, defaultLocale) : [];
|
|
1653
|
+
const meta = await enumerateMetaUnits(readers.ours, id);
|
|
1654
|
+
for (const unit of [...units.map((u) => u.path), ...meta.map((u) => u.path)]) outputs.push({
|
|
1655
|
+
path: unit,
|
|
1656
|
+
format: "raw",
|
|
1657
|
+
value: null
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
/** Take the surviving side wholesale: definition, content, meta. */
|
|
1661
|
+
async function keepModelWholesale(id, modelPath, three, survivor) {
|
|
1662
|
+
outputs.push({
|
|
1663
|
+
path: modelPath,
|
|
1664
|
+
format: "model",
|
|
1665
|
+
value: three[survivor]
|
|
1666
|
+
});
|
|
1667
|
+
const survivorDef = three[survivor];
|
|
1668
|
+
const units = survivorDef && typeof survivorDef.kind === "string" ? await enumerateSideUnits(readers[survivor], survivorDef, defaultLocale) : [];
|
|
1669
|
+
const meta = await enumerateMetaUnits(readers[survivor], id);
|
|
1670
|
+
const survivorPaths = [...units.map((u) => u.path), ...meta.map((u) => u.path)];
|
|
1671
|
+
const raws = await Promise.all(survivorPaths.map((path) => readers[survivor].readOrNull(path)));
|
|
1672
|
+
survivorPaths.forEach((path, i) => {
|
|
1673
|
+
outputs.push({
|
|
1674
|
+
path,
|
|
1675
|
+
format: "raw",
|
|
1676
|
+
value: raws[i]
|
|
1677
|
+
});
|
|
1678
|
+
});
|
|
1679
|
+
const oursDef = three.ours;
|
|
1680
|
+
const oursUnits = oursDef && typeof oursDef.kind === "string" ? await enumerateSideUnits(readers.ours, oursDef, defaultLocale) : [];
|
|
1681
|
+
const oursMeta = await enumerateMetaUnits(readers.ours, id);
|
|
1682
|
+
const survivorSet = new Set(survivorPaths);
|
|
1683
|
+
for (const path of [...oursUnits.map((u) => u.path), ...oursMeta.map((u) => u.path)]) if (!survivorSet.has(path)) outputs.push({
|
|
1684
|
+
path,
|
|
1685
|
+
format: "raw",
|
|
1686
|
+
value: null
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
function unitKey(u) {
|
|
1691
|
+
return `${u.locale ?? ""}::${u.slug ?? ""}`;
|
|
1692
|
+
}
|
|
1693
|
+
function parseJson(raw) {
|
|
1694
|
+
if (raw === null) return void 0;
|
|
1695
|
+
try {
|
|
1696
|
+
return JSON.parse(raw);
|
|
1697
|
+
} catch {
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
//#endregion
|
|
1702
|
+
export { applyChangesToWorktree as n, planReconcile as t };
|
|
1703
|
+
|
|
1704
|
+
//# sourceMappingURL=reconcile-D5FTVW5z.mjs.map
|