@indigoai-us/hq-cli 5.270.0 → 5.272.0
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/CHANGELOG.md +21 -0
- package/dist/command-catalog.generated.d.ts +30 -0
- package/dist/command-catalog.generated.js +39 -0
- package/dist/commands/core-ontology.d.ts +16 -0
- package/dist/commands/core-ontology.js +86 -0
- package/dist/commands/files.d.ts +16 -0
- package/dist/commands/files.js +1 -1
- package/dist/commands/lanes-message.js +6 -3
- package/dist/commands/lanes-resume.js +6 -3
- package/dist/commands/lanes-shared.d.ts +20 -4
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +212 -33
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +116 -0
- package/dist/lib/checkpoint-ontology/merge-copies.js +521 -0
- package/package.json +1 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge numbered entity copies (`<slug> 2.md`) into their original file and
|
|
3
|
+
* delete them (CO-54, owner decision CO-D-007 option 2).
|
|
4
|
+
*
|
|
5
|
+
* Gated by `checkpoint.ontology.merge-copies`. A copy is a file named
|
|
6
|
+
* `<slug> <n>.md` next to `<slug>.md` of the same type, with the same `id`,
|
|
7
|
+
* in one duplicate group. Other members of a group are never touched.
|
|
8
|
+
*
|
|
9
|
+
* Two steps, run separately so sync can push the merged original before its
|
|
10
|
+
* copy goes away:
|
|
11
|
+
*
|
|
12
|
+
* - merge: the copy's fresh aliases, back-references and relationship lines
|
|
13
|
+
* are added to the original, `signal_count` takes the higher value, and
|
|
14
|
+
* entity files that link to the copy are re-pointed at the original. Every
|
|
15
|
+
* rewritten file is recorded in the outbox before it is written, so sync
|
|
16
|
+
* pushes it. Keys the checkpoint does not own keep the original's value.
|
|
17
|
+
* - delete: a copy is deleted from the vault, by exact key, only when merging
|
|
18
|
+
* it again would change nothing and no entity file or signal links to it.
|
|
19
|
+
* The vault keeps every old version. The local file goes after the vault
|
|
20
|
+
* delete succeeds, or when the vault no longer has the key.
|
|
21
|
+
*
|
|
22
|
+
* Copies of one original merge in path order, each into the result of the
|
|
23
|
+
* previous one, and none of them counts as holding a name the others move.
|
|
24
|
+
* A copy whose body has lines the merged original lacks (prose the merge does
|
|
25
|
+
* not carry) is merged but never deleted. A copy linked from any file the
|
|
26
|
+
* merge cannot rewrite (a copy, a knowledge file, a signal) is held.
|
|
27
|
+
*
|
|
28
|
+
* A signal whose `entity_refs` names a copy holds that copy: signals are
|
|
29
|
+
* server-owned, so the ref cannot be re-pointed from here.
|
|
30
|
+
*/
|
|
31
|
+
import * as fs from "node:fs";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { isDeepStrictEqual } from "node:util";
|
|
34
|
+
import { freshAliases, ownNames } from "./aliases.js";
|
|
35
|
+
import { entitiesRoot, safeWriteFile } from "./company.js";
|
|
36
|
+
import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile } from "./entity-file.js";
|
|
37
|
+
import { isWritableEntityType, normalizeEntityName } from "./identity.js";
|
|
38
|
+
import { findDuplicateGroups, readBackRefs, recordOutbox } from "./multi-writer.js";
|
|
39
|
+
import { buildEntityIndex, listSignalFiles, readSignal } from "./tree.js";
|
|
40
|
+
export const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
|
|
41
|
+
/** `<slug> <n>.md`, the name a file manager gives a second copy. */
|
|
42
|
+
const COPY_NAME = /^(.+) (\d+)\.md$/;
|
|
43
|
+
/** A relationship list item with a link, as the gardener and checkpoint write them. */
|
|
44
|
+
const LINK_ITEM = /^- .*\]\([^)\n]+\)\s*$/;
|
|
45
|
+
const BACKREF_ITEM = /^- Referenced in \[/;
|
|
46
|
+
const MD_LINK = /\]\(([^)\n]+)\)/g;
|
|
47
|
+
/** Keys that differ between copies for reasons that say nothing about content. */
|
|
48
|
+
const IGNORED_KEYS = new Set(["first_seen", "confidence"]);
|
|
49
|
+
function sectionLines(body) {
|
|
50
|
+
const match = /(^|\n)##\s+Relationships\s*\n([\s\S]*?)(?=\n##\s|$)/.exec(body);
|
|
51
|
+
return match ? match[2].split(/\r?\n/).map((line) => line.trimEnd()) : [];
|
|
52
|
+
}
|
|
53
|
+
function splitBody(content) {
|
|
54
|
+
const match = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/.exec(content);
|
|
55
|
+
return match ? { frontmatter: match[1], body: match[2] } : null;
|
|
56
|
+
}
|
|
57
|
+
/** Relationship lines of the copy that the original lacks (back-references excluded). */
|
|
58
|
+
function missingRelationshipLines(originalContent, copyContent) {
|
|
59
|
+
const original = splitBody(originalContent);
|
|
60
|
+
const copy = splitBody(copyContent);
|
|
61
|
+
if (!original || !copy)
|
|
62
|
+
return [];
|
|
63
|
+
const have = new Set(original.body.split(/\r?\n/).map((line) => line.trimEnd()));
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const line of sectionLines(copy.body)) {
|
|
66
|
+
if (!LINK_ITEM.test(line) || BACKREF_ITEM.test(line) || have.has(line))
|
|
67
|
+
continue;
|
|
68
|
+
have.add(line);
|
|
69
|
+
out.push(line);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** Append lines to `## Relationships`, creating the section when it is missing. */
|
|
74
|
+
function appendRelationshipLines(content, lines) {
|
|
75
|
+
if (lines.length === 0)
|
|
76
|
+
return content;
|
|
77
|
+
const parts = splitBody(content);
|
|
78
|
+
const body = parts.body;
|
|
79
|
+
const section = /(^|\n)(##\s+Relationships\s*\n)([\s\S]*?)(?=\n##\s|\s*$)/.exec(body);
|
|
80
|
+
let next;
|
|
81
|
+
if (section) {
|
|
82
|
+
const before = body.slice(0, section.index + section[1].length);
|
|
83
|
+
const kept = section[3].replace(/\s+$/, "");
|
|
84
|
+
const after = body.slice(section.index + section[0].length);
|
|
85
|
+
next = `${before}${section[2]}${kept ? `${kept}\n${lines.join("\n")}` : lines.join("\n")}\n${after}`;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const trimmed = body.replace(/\s+$/, "");
|
|
89
|
+
next = `${trimmed.length > 0 ? `${trimmed}\n\n` : "\n"}## Relationships\n\n${lines.join("\n")}\n`;
|
|
90
|
+
}
|
|
91
|
+
return `${parts.frontmatter}${next}`;
|
|
92
|
+
}
|
|
93
|
+
/** Remove one entity from the name indexes so its names stop counting as taken. */
|
|
94
|
+
function unindex(index, rel) {
|
|
95
|
+
index.byRel.delete(rel);
|
|
96
|
+
for (const map of [index.canonical, index.alias, index.squashed]) {
|
|
97
|
+
for (const [key, rels] of map) {
|
|
98
|
+
if (!rels.delete(rel))
|
|
99
|
+
continue;
|
|
100
|
+
if (rels.size === 0)
|
|
101
|
+
map.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Copies in scope, and the ones skipped with a reason. Read-only. */
|
|
106
|
+
export function findCopyPairs(index) {
|
|
107
|
+
const groupOf = new Map();
|
|
108
|
+
findDuplicateGroups(index).forEach((group, i) => {
|
|
109
|
+
for (const rel of group.paths)
|
|
110
|
+
groupOf.set(rel, i);
|
|
111
|
+
});
|
|
112
|
+
const pairs = [];
|
|
113
|
+
const skipped = [];
|
|
114
|
+
const candidates = [...index.byRel.values()]
|
|
115
|
+
.filter((entity) => isWritableEntityType(entity.type) && COPY_NAME.test(path.posix.basename(entity.rel)))
|
|
116
|
+
.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
117
|
+
for (const copy of candidates) {
|
|
118
|
+
const base = COPY_NAME.exec(path.posix.basename(copy.rel))[1];
|
|
119
|
+
const original = `${path.posix.dirname(copy.rel)}/${base}.md`;
|
|
120
|
+
const target = index.byRel.get(original);
|
|
121
|
+
if (!target) {
|
|
122
|
+
skipped.push({ copy: copy.rel, reason: index.invalid.has(original) ? "original_invalid" : "no_original" });
|
|
123
|
+
}
|
|
124
|
+
else if (target.id !== copy.id || target.type !== copy.type) {
|
|
125
|
+
skipped.push({ copy: copy.rel, reason: "identity_differs" });
|
|
126
|
+
}
|
|
127
|
+
else if (groupOf.get(copy.rel) === undefined || groupOf.get(copy.rel) !== groupOf.get(original)) {
|
|
128
|
+
skipped.push({ copy: copy.rel, reason: "not_in_duplicate_group" });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
pairs.push({ type: copy.type, copy: copy.rel, original });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { pairs, skipped };
|
|
135
|
+
}
|
|
136
|
+
/** Resolve a relative link in `fromRel` to a company-relative path, or null. */
|
|
137
|
+
function resolveLink(fromRel, href) {
|
|
138
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("/"))
|
|
139
|
+
return null;
|
|
140
|
+
let decoded;
|
|
141
|
+
try {
|
|
142
|
+
decoded = decodeURI(href.split("#")[0]);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), decoded));
|
|
148
|
+
return resolved.startsWith("..") ? null : resolved;
|
|
149
|
+
}
|
|
150
|
+
function listEntityFiles(company) {
|
|
151
|
+
const root = entitiesRoot(company);
|
|
152
|
+
const out = [];
|
|
153
|
+
let types;
|
|
154
|
+
try {
|
|
155
|
+
types = fs.readdirSync(root, { withFileTypes: true });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === "ENOENT")
|
|
159
|
+
return out;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
for (const type of types.filter((entry) => entry.isDirectory())) {
|
|
163
|
+
for (const file of fs.readdirSync(path.join(root, type.name), { withFileTypes: true })) {
|
|
164
|
+
if (file.isFile() && file.name.endsWith(".md"))
|
|
165
|
+
out.push(`ontology/entities/${type.name}/${file.name}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out.sort();
|
|
169
|
+
}
|
|
170
|
+
/** Company-relative `.md` files outside the entity tree and the rendered brief. */
|
|
171
|
+
function listOtherFiles(company) {
|
|
172
|
+
const out = [];
|
|
173
|
+
const walk = (rel) => {
|
|
174
|
+
for (const entry of fs.readdirSync(path.join(company.dir, rel), { withFileTypes: true })) {
|
|
175
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
176
|
+
continue;
|
|
177
|
+
const child = rel ? `${rel}/${entry.name}` : entry.name;
|
|
178
|
+
if (entry.isDirectory()) {
|
|
179
|
+
if (child !== "ontology/entities")
|
|
180
|
+
walk(child);
|
|
181
|
+
}
|
|
182
|
+
else if (entry.isFile() && entry.name.endsWith(".md") && child !== "company-brief.md") {
|
|
183
|
+
out.push(child);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
walk("");
|
|
188
|
+
return out.sort();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Files outside the entity tree that link to a copy, by markdown link,
|
|
192
|
+
* `[[ontology/entities/...]]` annotation or a front-matter `path:` entry.
|
|
193
|
+
* These files are not the merge's to rewrite, so a copy they name is held.
|
|
194
|
+
*/
|
|
195
|
+
function scanOtherLinks(company, copies) {
|
|
196
|
+
const out = new Map();
|
|
197
|
+
if (copies.size === 0)
|
|
198
|
+
return out;
|
|
199
|
+
const names = [...new Set([...copies].map((copy) => path.posix.basename(copy, ".md")))];
|
|
200
|
+
const add = (target, rel) => {
|
|
201
|
+
if (!copies.has(target))
|
|
202
|
+
return;
|
|
203
|
+
let set = out.get(target);
|
|
204
|
+
if (!set)
|
|
205
|
+
out.set(target, (set = new Set()));
|
|
206
|
+
set.add(rel);
|
|
207
|
+
};
|
|
208
|
+
for (const rel of listOtherFiles(company)) {
|
|
209
|
+
const content = read(company, rel);
|
|
210
|
+
if (!names.some((name) => content.includes(name) || content.includes(encodeURI(name))))
|
|
211
|
+
continue;
|
|
212
|
+
for (const match of content.matchAll(MD_LINK)) {
|
|
213
|
+
const target = resolveLink(rel, match[1]);
|
|
214
|
+
if (target)
|
|
215
|
+
add(target, rel);
|
|
216
|
+
}
|
|
217
|
+
for (const match of content.matchAll(/\[\[(ontology\/entities\/[^\]|\n]+?)(?:\|[^\]\n]*)?\]\]/g)) {
|
|
218
|
+
add(match[1].endsWith(".md") ? match[1] : `${match[1]}.md`, rel);
|
|
219
|
+
}
|
|
220
|
+
for (const match of content.matchAll(/^\s*path:\s*["']?(ontology\/entities\/[^"'\n]+?\.md)["']?\s*$/gm))
|
|
221
|
+
add(match[1], rel);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
function scanLinks(company) {
|
|
226
|
+
const scan = { inbound: new Map(), broken: 0 };
|
|
227
|
+
for (const rel of listEntityFiles(company)) {
|
|
228
|
+
const content = fs.readFileSync(path.join(company.dir, rel), "utf8");
|
|
229
|
+
for (const match of content.matchAll(MD_LINK)) {
|
|
230
|
+
const target = resolveLink(rel, match[1]);
|
|
231
|
+
if (!target)
|
|
232
|
+
continue;
|
|
233
|
+
if (!fs.existsSync(path.join(company.dir, target)))
|
|
234
|
+
scan.broken += 1;
|
|
235
|
+
if (target === rel)
|
|
236
|
+
continue;
|
|
237
|
+
let set = scan.inbound.get(target);
|
|
238
|
+
if (!set)
|
|
239
|
+
scan.inbound.set(target, (set = new Set()));
|
|
240
|
+
set.add(rel);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return scan;
|
|
244
|
+
}
|
|
245
|
+
/** Signal files whose `entity_refs` name a copy, keyed by the copy's path. */
|
|
246
|
+
function scanSignalRefs(company, copies) {
|
|
247
|
+
const out = new Map();
|
|
248
|
+
if (copies.size === 0)
|
|
249
|
+
return out;
|
|
250
|
+
const byRef = new Map();
|
|
251
|
+
for (const copy of copies) {
|
|
252
|
+
const [, , type, file] = copy.split("/");
|
|
253
|
+
byRef.set(`${type}/${file.slice(0, -3)}`, copy);
|
|
254
|
+
}
|
|
255
|
+
for (const rel of listSignalFiles(company.dir)) {
|
|
256
|
+
for (const ref of readSignal(company.dir, rel)?.refs ?? []) {
|
|
257
|
+
const copy = byRef.get(ref);
|
|
258
|
+
if (copy)
|
|
259
|
+
out.set(copy, [...(out.get(copy) ?? []), rel]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/** Re-point every link in `content` that resolves to `copy` at `original`. */
|
|
265
|
+
function relink(fromRel, content, copy, original) {
|
|
266
|
+
return content.replace(MD_LINK, (whole, href) => {
|
|
267
|
+
if (resolveLink(fromRel, href) !== copy)
|
|
268
|
+
return whole;
|
|
269
|
+
const hash = href.indexOf("#");
|
|
270
|
+
const target = hash === -1 ? href : href.slice(0, hash);
|
|
271
|
+
const fragment = hash === -1 ? "" : href.slice(hash);
|
|
272
|
+
const cut = target.lastIndexOf("/");
|
|
273
|
+
const name = path.posix.basename(original);
|
|
274
|
+
const encoded = target.slice(cut + 1).includes("%20") ? encodeURI(name) : name;
|
|
275
|
+
return `](${target.slice(0, cut + 1)}${encoded}${fragment})`;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The original's content with the copy merged in. Never removes anything from
|
|
280
|
+
* the original. `siblings` are all copies of the same original; none of them
|
|
281
|
+
* counts as holding a name.
|
|
282
|
+
*/
|
|
283
|
+
function mergeCopy(index, pair, siblings, originalContent, copyContent, now) {
|
|
284
|
+
const original = parseEntityFile(originalContent);
|
|
285
|
+
const copy = parseEntityFile(copyContent);
|
|
286
|
+
if (!original || !copy)
|
|
287
|
+
return "unparseable";
|
|
288
|
+
const copyEntity = index.byRel.get(pair.copy);
|
|
289
|
+
const originalEntity = index.byRel.get(pair.original);
|
|
290
|
+
if (!copyEntity || !originalEntity)
|
|
291
|
+
return "not_indexed";
|
|
292
|
+
const scratch = {
|
|
293
|
+
...index,
|
|
294
|
+
byRel: new Map(index.byRel),
|
|
295
|
+
canonical: new Map([...index.canonical].map(([k, v]) => [k, new Set(v)])),
|
|
296
|
+
alias: new Map([...index.alias].map(([k, v]) => [k, new Set(v)])),
|
|
297
|
+
squashed: new Map([...index.squashed].map(([k, v]) => [k, new Set(v)])),
|
|
298
|
+
};
|
|
299
|
+
for (const sibling of siblings)
|
|
300
|
+
unindex(scratch, sibling);
|
|
301
|
+
const own = ownNames(scratch, pair.original, []);
|
|
302
|
+
const names = [copyEntity.canonical_name, ...copyEntity.aliases];
|
|
303
|
+
const aliases = freshAliases(scratch, pair.type, pair.original, own, names);
|
|
304
|
+
const moved = new Set(aliases.map((alias) => normalizeEntityName(alias, pair.type)));
|
|
305
|
+
const dropped = names.filter((name) => {
|
|
306
|
+
const normalized = normalizeEntityName(name, pair.type);
|
|
307
|
+
return normalized !== "" && !own.has(normalized) && !moved.has(normalized);
|
|
308
|
+
});
|
|
309
|
+
const withRefs = appendBackRefs(originalContent, readBackRefs(copy.body));
|
|
310
|
+
const lines = missingRelationshipLines(withRefs.content, copyContent);
|
|
311
|
+
const body = appendRelationshipLines(withRefs.content, lines);
|
|
312
|
+
const raise = copyEntity.signal_count > originalEntity.signal_count ? copyEntity.signal_count : undefined;
|
|
313
|
+
const patched = patchEntityFile(body, {
|
|
314
|
+
addAliases: aliases,
|
|
315
|
+
signalCount: copyEntity.signal_count,
|
|
316
|
+
lastUpdated: now.toISOString(),
|
|
317
|
+
bodyChanged: withRefs.added > 0 || lines.length > 0,
|
|
318
|
+
});
|
|
319
|
+
if (!patched.ok)
|
|
320
|
+
return patched.reason;
|
|
321
|
+
const have = new Set(splitBody(patched.content)?.body.split(/\r?\n/).map((line) => line.trim()) ?? []);
|
|
322
|
+
const leftover = (splitBody(copyContent)?.body.split(/\r?\n/) ?? [])
|
|
323
|
+
.map((line) => line.trim())
|
|
324
|
+
.filter((line) => line !== "" && !have.has(line)).length;
|
|
325
|
+
const keys = [...new Set([...Object.keys(original.frontmatter), ...Object.keys(copy.frontmatter)])].sort();
|
|
326
|
+
const kept = keys.filter((key) => !OWNED_KEYS.has(key) && !IGNORED_KEYS.has(key) && !isDeepStrictEqual(original.frontmatter[key], copy.frontmatter[key]));
|
|
327
|
+
return {
|
|
328
|
+
content: patched.content,
|
|
329
|
+
changed: patched.changed,
|
|
330
|
+
aliases,
|
|
331
|
+
dropped,
|
|
332
|
+
backrefs: withRefs.added,
|
|
333
|
+
lines: lines.length,
|
|
334
|
+
...(raise !== undefined ? { raise } : {}),
|
|
335
|
+
kept,
|
|
336
|
+
leftover,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function read(company, rel) {
|
|
340
|
+
return fs.readFileSync(path.join(company.dir, rel), "utf8");
|
|
341
|
+
}
|
|
342
|
+
/** Links to copies in the company brief (rendered, so reported and not rewritten). */
|
|
343
|
+
function briefLinks(company, copies) {
|
|
344
|
+
let content;
|
|
345
|
+
try {
|
|
346
|
+
content = fs.readFileSync(path.join(company.dir, "company-brief.md"), "utf8");
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
const out = new Set();
|
|
352
|
+
for (const match of content.matchAll(MD_LINK)) {
|
|
353
|
+
const target = resolveLink("company-brief.md", match[1]);
|
|
354
|
+
if (target && copies.has(target))
|
|
355
|
+
out.add(target);
|
|
356
|
+
}
|
|
357
|
+
return [...out].sort();
|
|
358
|
+
}
|
|
359
|
+
function plan(company, index, pairs, allPairs, now) {
|
|
360
|
+
const links = scanLinks(company);
|
|
361
|
+
const copies = new Set(allPairs.map((pair) => pair.copy));
|
|
362
|
+
const other = scanOtherLinks(company, copies);
|
|
363
|
+
const signalRefs = scanSignalRefs(company, copies);
|
|
364
|
+
const siblingsOf = new Map();
|
|
365
|
+
for (const pair of allPairs)
|
|
366
|
+
siblingsOf.set(pair.original, [...(siblingsOf.get(pair.original) ?? []), pair.copy]);
|
|
367
|
+
const out = { plans: [], merges: new Map(), finals: new Map(), notReady: new Map() };
|
|
368
|
+
for (const pair of pairs) {
|
|
369
|
+
const inbound = [...new Set([...(links.inbound.get(pair.copy) ?? []), ...(other.get(pair.copy) ?? [])])].sort();
|
|
370
|
+
const refs = (signalRefs.get(pair.copy) ?? []).sort();
|
|
371
|
+
const siblings = siblingsOf.get(pair.original) ?? [pair.copy];
|
|
372
|
+
const copyContent = read(company, pair.copy);
|
|
373
|
+
const onDisk = read(company, pair.original);
|
|
374
|
+
const merged = mergeCopy(index, pair, siblings, out.finals.get(pair.original) ?? onDisk, copyContent, now);
|
|
375
|
+
const entry = {
|
|
376
|
+
...pair,
|
|
377
|
+
add_aliases: typeof merged === "string" ? [] : merged.aliases,
|
|
378
|
+
dropped_aliases: typeof merged === "string" ? [] : merged.dropped,
|
|
379
|
+
add_backrefs: typeof merged === "string" ? 0 : merged.backrefs,
|
|
380
|
+
add_relationship_lines: typeof merged === "string" ? 0 : merged.lines,
|
|
381
|
+
...(typeof merged !== "string" && merged.raise !== undefined ? { raise_signal_count: merged.raise } : {}),
|
|
382
|
+
kept_original_keys: typeof merged === "string" ? [] : merged.kept,
|
|
383
|
+
inbound_links: inbound,
|
|
384
|
+
signal_refs: refs,
|
|
385
|
+
unmerged_body_lines: typeof merged === "string" ? 0 : merged.leftover,
|
|
386
|
+
};
|
|
387
|
+
const unwritable = inbound.filter((rel) => !rel.startsWith("ontology/entities/") || !isWritableEntityType(rel.split("/")[2]) || copies.has(rel));
|
|
388
|
+
if (typeof merged === "string") {
|
|
389
|
+
entry.held = "merge_invalid";
|
|
390
|
+
entry.detail = merged;
|
|
391
|
+
}
|
|
392
|
+
else if (refs.length > 0) {
|
|
393
|
+
entry.held = "signal_refs";
|
|
394
|
+
}
|
|
395
|
+
else if (unwritable.length > 0) {
|
|
396
|
+
entry.held = "linked_from_unwritable";
|
|
397
|
+
entry.detail = unwritable.join(", ");
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
out.merges.set(pair.copy, merged);
|
|
401
|
+
out.finals.set(pair.original, merged.content);
|
|
402
|
+
// Deletion is judged against the original as it is on disk now.
|
|
403
|
+
const current = mergeCopy(index, pair, siblings, onDisk, copyContent, now);
|
|
404
|
+
const reason = typeof current === "string"
|
|
405
|
+
? "merge_invalid"
|
|
406
|
+
: current.changed
|
|
407
|
+
? "not_merged"
|
|
408
|
+
: current.leftover > 0
|
|
409
|
+
? "unmerged_body"
|
|
410
|
+
: inbound.length > 0
|
|
411
|
+
? "linked"
|
|
412
|
+
: null;
|
|
413
|
+
if (reason)
|
|
414
|
+
out.notReady.set(pair.copy, reason);
|
|
415
|
+
}
|
|
416
|
+
out.plans.push(entry);
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
function writeEntity(hqRoot, company, rel, content, now) {
|
|
421
|
+
// Outbox first: sync pushes an entity file only when its bytes match.
|
|
422
|
+
const write = { rel, content };
|
|
423
|
+
recordOutbox(hqRoot, company, [write], now);
|
|
424
|
+
safeWriteFile(hqRoot, [entitiesRoot(company)], path.join(company.dir, rel), content);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Plan, merge or delete numbered copies for one company. The dry run writes
|
|
428
|
+
* nothing; merge writes entity files only; delete removes vault keys and
|
|
429
|
+
* local copy files only.
|
|
430
|
+
*/
|
|
431
|
+
export async function mergeCopies(options) {
|
|
432
|
+
const { hqRoot, company, mode, now } = options;
|
|
433
|
+
const index = buildEntityIndex(company.dir);
|
|
434
|
+
const found = findCopyPairs(index);
|
|
435
|
+
const pairs = options.limit !== undefined ? found.pairs.slice(0, Math.max(0, options.limit)) : found.pairs;
|
|
436
|
+
const before = scanLinks(company).broken;
|
|
437
|
+
const { plans, merges, finals, notReady } = plan(company, index, pairs, found.pairs, now);
|
|
438
|
+
const report = {
|
|
439
|
+
company: company.slug,
|
|
440
|
+
mode,
|
|
441
|
+
duplicate_groups_before: findDuplicateGroups(index).length,
|
|
442
|
+
duplicate_groups_after: 0,
|
|
443
|
+
copies_found: found.pairs.length,
|
|
444
|
+
plans,
|
|
445
|
+
skipped: found.skipped,
|
|
446
|
+
merged: [],
|
|
447
|
+
relinked: [],
|
|
448
|
+
deleted: [],
|
|
449
|
+
vault_already_absent: [],
|
|
450
|
+
not_ready: [],
|
|
451
|
+
errors: [],
|
|
452
|
+
broken_links_before: before,
|
|
453
|
+
broken_links_after: before,
|
|
454
|
+
brief_links_to_copies: briefLinks(company, new Set(pairs.map((pair) => pair.copy))),
|
|
455
|
+
};
|
|
456
|
+
if (mode === "merge") {
|
|
457
|
+
// Originals first, once each with every copy merged in; then links, read
|
|
458
|
+
// back from disk so a relinked original keeps its merge.
|
|
459
|
+
for (const [original, content] of finals) {
|
|
460
|
+
try {
|
|
461
|
+
if (content === read(company, original))
|
|
462
|
+
continue;
|
|
463
|
+
writeEntity(hqRoot, company, original, content, now);
|
|
464
|
+
report.merged.push(original);
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
report.errors.push({ path: original, error: error.name });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
for (const entry of plans) {
|
|
471
|
+
if (!merges.has(entry.copy))
|
|
472
|
+
continue;
|
|
473
|
+
for (const rel of entry.inbound_links) {
|
|
474
|
+
try {
|
|
475
|
+
const content = read(company, rel);
|
|
476
|
+
const next = relink(rel, content, entry.copy, entry.original);
|
|
477
|
+
if (next === content)
|
|
478
|
+
continue;
|
|
479
|
+
writeEntity(hqRoot, company, rel, next, now);
|
|
480
|
+
if (!report.relinked.includes(rel))
|
|
481
|
+
report.relinked.push(rel);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
report.errors.push({ path: rel, error: error.name });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (mode === "delete") {
|
|
490
|
+
if (!options.deleteVaultKey)
|
|
491
|
+
throw new Error("merge-copies: delete needs a vault client");
|
|
492
|
+
for (const entry of plans) {
|
|
493
|
+
const reason = entry.held ?? notReady.get(entry.copy) ?? null;
|
|
494
|
+
if (reason) {
|
|
495
|
+
report.not_ready.push({ copy: entry.copy, reason });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const deleted = await options.deleteVaultKey(entry.copy);
|
|
500
|
+
// 0 means the exact key is already gone (a run that stopped before the
|
|
501
|
+
// local removal, or a copy that never synced): finish the local side.
|
|
502
|
+
if (deleted !== 1 && deleted !== 0) {
|
|
503
|
+
report.errors.push({ path: entry.copy, error: `vault_deleted_${deleted}` });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
fs.rmSync(path.join(company.dir, entry.copy), { force: true });
|
|
507
|
+
report.deleted.push(entry.copy);
|
|
508
|
+
if (deleted === 0)
|
|
509
|
+
report.vault_already_absent.push(entry.copy);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
report.errors.push({ path: entry.copy, error: error.message || error.name });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const after = buildEntityIndex(company.dir);
|
|
517
|
+
report.duplicate_groups_after = findDuplicateGroups(after).length;
|
|
518
|
+
report.broken_links_after = scanLinks(company).broken;
|
|
519
|
+
return report;
|
|
520
|
+
}
|
|
521
|
+
//# sourceMappingURL=merge-copies.js.map
|