@indigoai-us/hq-cli 5.288.0 → 5.290.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 +22 -0
- package/assets/bot-workers/setup/skills/explain-hq.md +35 -0
- package/assets/bot-workers/setup/worker.yaml +25 -23
- package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
- package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
- package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
- package/dist/command-catalog.generated.d.ts +51 -0
- package/dist/command-catalog.generated.js +63 -0
- package/dist/commands/core-handoff.d.ts +22 -0
- package/dist/commands/core-handoff.js +1240 -0
- package/dist/commands/core-ontology.d.ts +6 -1
- package/dist/commands/core-ontology.js +42 -7
- package/dist/commands/core.js +8 -1
- package/dist/commands/lanes.js +157 -5
- package/dist/lib/bot/run.js +92 -2
- package/dist/lib/bot/setup-explain.d.ts +68 -0
- package/dist/lib/bot/setup-explain.js +141 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
- package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
- package/dist/lib/lanes/store.d.ts +4 -2
- package/dist/lib/lanes/store.js +37 -15
- package/dist/lib/lanes/types.d.ts +6 -2
- package/dist/lib/lanes/types.js +1 -1
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +232 -1
- package/package.json +1 -1
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
* merge cannot rewrite (a copy, a knowledge file, a signal) is held.
|
|
27
27
|
*
|
|
28
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.
|
|
29
|
+
* server-owned, so the ref cannot be re-pointed from here. With `rewriteRefs`
|
|
30
|
+
* (flag `checkpoint.ontology.merge-rewrite-refs`) signal refs, invoice and
|
|
31
|
+
* bill links, source and knowledge links are re-pointed at the survivor, the
|
|
32
|
+
* survivor receives the copy's `external_ids` and `web_domains`, and only a
|
|
33
|
+
* link from another copy or an external id conflict holds a pair.
|
|
30
34
|
*
|
|
31
35
|
* With `pairKind: "variants"` (flag `checkpoint.ontology.merge-variants`) the
|
|
32
36
|
* pairs are two-file duplicate groups whose canonical names differ only in
|
|
@@ -39,7 +43,7 @@ import * as path from "node:path";
|
|
|
39
43
|
import { isDeepStrictEqual } from "node:util";
|
|
40
44
|
import { freshAliases, ownNames } from "./aliases.js";
|
|
41
45
|
import { entitiesRoot, safeWriteFile } from "./company.js";
|
|
42
|
-
import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile } from "./entity-file.js";
|
|
46
|
+
import { appendBackRefs, OWNED_KEYS, parseEntityFile, patchEntityFile, setIdentityKeys } from "./entity-file.js";
|
|
43
47
|
import { isWritableEntityType, normalizeEntityName } from "./identity.js";
|
|
44
48
|
import { findDuplicateGroups, readBackRefs, recordOutbox } from "./multi-writer.js";
|
|
45
49
|
import { buildEntityIndex, listSignalFiles, readSignal } from "./tree.js";
|
|
@@ -49,6 +53,12 @@ export const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
|
|
|
49
53
|
* names differ only in spacing, case or punctuation (CO-65).
|
|
50
54
|
*/
|
|
51
55
|
export const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
|
|
56
|
+
/**
|
|
57
|
+
* `merge-copies --rewrite-refs`: re-point signal `entity_refs`, invoice and
|
|
58
|
+
* bill links, source and knowledge links from a copy to its survivor, and
|
|
59
|
+
* carry the copy's `external_ids` and `web_domains` to the survivor.
|
|
60
|
+
*/
|
|
61
|
+
export const MERGE_REWRITE_REFS_FLAG = "checkpoint.ontology.merge-rewrite-refs";
|
|
52
62
|
/** `<slug> <n>.md`, the name a file manager gives a second copy. */
|
|
53
63
|
const COPY_NAME = /^(.+) (\d+)\.md$/;
|
|
54
64
|
/** A relationship list item with a link, as the gardener and checkpoint write them. */
|
|
@@ -65,8 +75,17 @@ function splitBody(content) {
|
|
|
65
75
|
const match = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/.exec(content);
|
|
66
76
|
return match ? { frontmatter: match[1], body: match[2] } : null;
|
|
67
77
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
78
|
+
/**
|
|
79
|
+
* A line of the copy whose every link points at the copy itself or at its
|
|
80
|
+
* original. Moved to the original it would link the survivor to itself or to
|
|
81
|
+
* the file about to be deleted, so it is neither moved nor unmerged content.
|
|
82
|
+
*/
|
|
83
|
+
function isPairSelfLine(line, pair) {
|
|
84
|
+
const targets = [...line.matchAll(MD_LINK)].map((match) => resolveLink(pair.copy, match[1]));
|
|
85
|
+
return targets.length > 0 && targets.every((target) => target === pair.copy || target === pair.original);
|
|
86
|
+
}
|
|
87
|
+
/** Relationship lines of the copy that the original lacks (back-references and self links excluded). */
|
|
88
|
+
function missingRelationshipLines(originalContent, copyContent, pair) {
|
|
70
89
|
const original = splitBody(originalContent);
|
|
71
90
|
const copy = splitBody(copyContent);
|
|
72
91
|
if (!original || !copy)
|
|
@@ -74,7 +93,7 @@ function missingRelationshipLines(originalContent, copyContent) {
|
|
|
74
93
|
const have = new Set(original.body.split(/\r?\n/).map((line) => line.trimEnd()));
|
|
75
94
|
const out = [];
|
|
76
95
|
for (const line of sectionLines(copy.body)) {
|
|
77
|
-
if (!LINK_ITEM.test(line) || BACKREF_ITEM.test(line) || have.has(line))
|
|
96
|
+
if (!LINK_ITEM.test(line) || BACKREF_ITEM.test(line) || have.has(line) || isPairSelfLine(line, pair))
|
|
78
97
|
continue;
|
|
79
98
|
have.add(line);
|
|
80
99
|
out.push(line);
|
|
@@ -284,7 +303,7 @@ function scanOtherLinks(company, copies) {
|
|
|
284
303
|
for (const match of content.matchAll(/\[\[(ontology\/entities\/[^\]|\n]+?)(?:\|[^\]\n]*)?\]\]/g)) {
|
|
285
304
|
add(match[1].endsWith(".md") ? match[1] : `${match[1]}.md`, rel);
|
|
286
305
|
}
|
|
287
|
-
for (const match of content.matchAll(/^\s*path:\s*["']?(ontology\/entities\/[^"'\n]+?\.md)["']?\s*$/gm))
|
|
306
|
+
for (const match of content.matchAll(/^\s*(?:- )?path:\s*["']?(ontology\/entities\/[^"'\n]+?\.md)["']?\s*$/gm))
|
|
288
307
|
add(match[1], rel);
|
|
289
308
|
}
|
|
290
309
|
return out;
|
|
@@ -321,7 +340,8 @@ function scanSignalRefs(company, copies) {
|
|
|
321
340
|
}
|
|
322
341
|
for (const rel of listSignalFiles(company.dir)) {
|
|
323
342
|
for (const ref of readSignal(company.dir, rel)?.refs ?? []) {
|
|
324
|
-
|
|
343
|
+
// Signals write refs both as `type/slug` and as `type/slug.md`.
|
|
344
|
+
const copy = byRef.get(ref.endsWith(".md") ? ref.slice(0, -3) : ref);
|
|
325
345
|
if (copy)
|
|
326
346
|
out.set(copy, [...(out.get(copy) ?? []), rel]);
|
|
327
347
|
}
|
|
@@ -342,6 +362,350 @@ function relink(fromRel, content, copy, original) {
|
|
|
342
362
|
return `](${target.slice(0, cut + 1)}${encoded}${fragment})`;
|
|
343
363
|
});
|
|
344
364
|
}
|
|
365
|
+
const FRONT_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
|
|
366
|
+
const REL_REF = /^(\s*(?:- )?[A-Za-z_][A-Za-z0-9_]*:\s*)(["']?)(\.\.\/[^"'\n]+?\.md)\2\s*$/;
|
|
367
|
+
const WIKI_LINK = /\[\[(ontology\/entities\/[^\]|\n]+?)(\|[^\]\n]*)?\]\]/g;
|
|
368
|
+
const PATH_LINE = /^(\s*(?:- )?path:\s*)(["']?)(ontology\/entities\/[^"'\n]+?\.md)\2\s*$/;
|
|
369
|
+
function frontLines(content) {
|
|
370
|
+
const match = FRONT_RE.exec(content);
|
|
371
|
+
if (!match)
|
|
372
|
+
return null;
|
|
373
|
+
return { start: match.index + match[0].indexOf(match[1]), lines: match[1].split(/\r?\n/) };
|
|
374
|
+
}
|
|
375
|
+
/** Put rewritten front-matter lines back; every byte outside them stays. */
|
|
376
|
+
function withFrontLines(content, front, lines, newline) {
|
|
377
|
+
const originalLength = front.lines.join(newline).length;
|
|
378
|
+
return `${content.slice(0, front.start)}${lines.join(newline)}${content.slice(front.start + originalLength)}`;
|
|
379
|
+
}
|
|
380
|
+
function fileNewline(content) {
|
|
381
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
382
|
+
}
|
|
383
|
+
/** In the front matter only, re-point a `../…md` value that resolves to `copy`. */
|
|
384
|
+
function rewriteRelRefs(fromRel, content, copy, original) {
|
|
385
|
+
const front = frontLines(content);
|
|
386
|
+
if (!front)
|
|
387
|
+
return content;
|
|
388
|
+
const name = path.posix.basename(original);
|
|
389
|
+
let changed = false;
|
|
390
|
+
const lines = front.lines.map((line) => {
|
|
391
|
+
const match = REL_REF.exec(line);
|
|
392
|
+
if (!match || resolveLink(fromRel, match[3]) !== copy)
|
|
393
|
+
return line;
|
|
394
|
+
const value = match[3];
|
|
395
|
+
const cut = value.lastIndexOf("/");
|
|
396
|
+
changed = true;
|
|
397
|
+
return `${match[1]}${match[2]}${value.slice(0, cut + 1)}${name}${match[2]}`;
|
|
398
|
+
});
|
|
399
|
+
if (!changed)
|
|
400
|
+
return content;
|
|
401
|
+
return withFrontLines(content, front, lines, fileNewline(content));
|
|
402
|
+
}
|
|
403
|
+
/** Entity files whose front matter points at a copy through a relative ref. */
|
|
404
|
+
function scanRelRefs(company, copies) {
|
|
405
|
+
const out = new Map();
|
|
406
|
+
if (copies.size === 0)
|
|
407
|
+
return out;
|
|
408
|
+
for (const rel of listEntityFiles(company)) {
|
|
409
|
+
const front = frontLines(read(company, rel));
|
|
410
|
+
if (!front)
|
|
411
|
+
continue;
|
|
412
|
+
for (const line of front.lines) {
|
|
413
|
+
const match = REL_REF.exec(line);
|
|
414
|
+
if (!match)
|
|
415
|
+
continue;
|
|
416
|
+
const target = resolveLink(rel, match[3]);
|
|
417
|
+
if (!target || !copies.has(target))
|
|
418
|
+
continue;
|
|
419
|
+
let set = out.get(target);
|
|
420
|
+
if (!set)
|
|
421
|
+
out.set(target, (set = new Set()));
|
|
422
|
+
set.add(rel);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
function listItemBounds(lines, pathIndex) {
|
|
428
|
+
let start = pathIndex;
|
|
429
|
+
let dashIndent = -1;
|
|
430
|
+
while (start >= 0) {
|
|
431
|
+
const dash = /^(\s*)- /.exec(lines[start]);
|
|
432
|
+
if (dash) {
|
|
433
|
+
dashIndent = dash[1].length;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
start -= 1;
|
|
437
|
+
}
|
|
438
|
+
if (dashIndent < 0)
|
|
439
|
+
return null;
|
|
440
|
+
let keyIndent = -1;
|
|
441
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
442
|
+
if (lines[i].trim() === "")
|
|
443
|
+
continue;
|
|
444
|
+
const indent = /^(\s*)/.exec(lines[i])[1].length;
|
|
445
|
+
if (indent <= dashIndent)
|
|
446
|
+
break;
|
|
447
|
+
keyIndent = indent;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
if (keyIndent < 0)
|
|
451
|
+
keyIndent = dashIndent + 1;
|
|
452
|
+
let end = start + 1;
|
|
453
|
+
while (end < lines.length) {
|
|
454
|
+
const line = lines[end];
|
|
455
|
+
const dash = /^(\s*)- /.exec(line);
|
|
456
|
+
if (dash && dash[1].length === dashIndent)
|
|
457
|
+
break;
|
|
458
|
+
if (line.trim() !== "" && /^(\s*)/.exec(line)[1].length < keyIndent)
|
|
459
|
+
break;
|
|
460
|
+
end += 1;
|
|
461
|
+
}
|
|
462
|
+
return { start, end };
|
|
463
|
+
}
|
|
464
|
+
function rewriteWikiLinks(content, copy, original) {
|
|
465
|
+
WIKI_LINK.lastIndex = 0;
|
|
466
|
+
return content.replace(WIKI_LINK, (whole, target, label) => {
|
|
467
|
+
const resolved = target.endsWith(".md") ? target : `${target}.md`;
|
|
468
|
+
if (resolved !== copy)
|
|
469
|
+
return whole;
|
|
470
|
+
const next = target.endsWith(".md") ? original : original.slice(0, -3);
|
|
471
|
+
return `[[${next}${label ?? ""}]]`;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Re-point markdown links, wiki-links, annotation `path:` lines and, when both
|
|
476
|
+
* ids are known, the `id:` of that same list item.
|
|
477
|
+
*/
|
|
478
|
+
function rewriteOtherFile(fromRel, content, copy, original, copyId, originalId) {
|
|
479
|
+
const linked = rewriteWikiLinks(relink(fromRel, content, copy, original), copy, original);
|
|
480
|
+
const newline = fileNewline(content);
|
|
481
|
+
const lines = linked.split(/\r?\n/);
|
|
482
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
483
|
+
const pathMatch = PATH_LINE.exec(lines[i]);
|
|
484
|
+
if (!pathMatch || pathMatch[3] !== copy)
|
|
485
|
+
continue;
|
|
486
|
+
lines[i] = `${pathMatch[1]}${pathMatch[2]}${original}${pathMatch[2]}`;
|
|
487
|
+
if (typeof copyId !== "string" || typeof originalId !== "string")
|
|
488
|
+
continue;
|
|
489
|
+
const bounds = listItemBounds(lines, i);
|
|
490
|
+
if (!bounds)
|
|
491
|
+
continue;
|
|
492
|
+
for (let j = bounds.start; j < bounds.end; j += 1) {
|
|
493
|
+
const idMatch = /^(\s*id:\s*)(["']?)([^"'\n]*?)\2(\s*)$/.exec(lines[j]);
|
|
494
|
+
if (idMatch && idMatch[3] === copyId)
|
|
495
|
+
lines[j] = `${idMatch[1]}${idMatch[2]}${originalId}${idMatch[2]}${idMatch[4]}`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return lines.join(newline);
|
|
499
|
+
}
|
|
500
|
+
/** True when `content` still names `copy` by a link, wiki-link, path or front-matter ref. */
|
|
501
|
+
function referencesCopy(fromRel, content, copy) {
|
|
502
|
+
MD_LINK.lastIndex = 0;
|
|
503
|
+
for (const match of content.matchAll(MD_LINK)) {
|
|
504
|
+
if (resolveLink(fromRel, match[1]) === copy)
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
WIKI_LINK.lastIndex = 0;
|
|
508
|
+
for (const match of content.matchAll(WIKI_LINK)) {
|
|
509
|
+
const target = match[1].endsWith(".md") ? match[1] : `${match[1]}.md`;
|
|
510
|
+
if (target === copy)
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
for (const line of content.split(/\r?\n/)) {
|
|
514
|
+
const pathMatch = PATH_LINE.exec(line);
|
|
515
|
+
if (pathMatch && pathMatch[3] === copy)
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
const front = frontLines(content);
|
|
519
|
+
if (front) {
|
|
520
|
+
for (const line of front.lines) {
|
|
521
|
+
const rel = REL_REF.exec(line);
|
|
522
|
+
if (rel && resolveLink(fromRel, rel[3]) === copy)
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
function entityRefOf(rel) {
|
|
529
|
+
const [, , type, file] = rel.split("/");
|
|
530
|
+
return `${type}/${file.slice(0, -3)}`;
|
|
531
|
+
}
|
|
532
|
+
/** Re-point block-style `entity_refs` items from a copy to its survivor. */
|
|
533
|
+
function rewriteSignalRefs(content, copyRef, originalRef) {
|
|
534
|
+
const front = frontLines(content);
|
|
535
|
+
if (!front)
|
|
536
|
+
return content;
|
|
537
|
+
const index = front.lines.findIndex((line) => /^entity_refs:\s*$/.test(line));
|
|
538
|
+
if (index === -1) {
|
|
539
|
+
const inline = front.lines.some((line) => /^entity_refs:\s*\S/.test(line));
|
|
540
|
+
if (inline && content.includes(copyRef))
|
|
541
|
+
return "signal_ref_format";
|
|
542
|
+
return content;
|
|
543
|
+
}
|
|
544
|
+
let end = index + 1;
|
|
545
|
+
// A block sequence may sit at the key's own indent: `entity_refs:\n- company/x`.
|
|
546
|
+
while (end < front.lines.length && (/^\s/.test(front.lines[end]) || /^- /.test(front.lines[end])))
|
|
547
|
+
end += 1;
|
|
548
|
+
const itemRe = /^(\s*)- (["']?)([^"'\n]+?)\2\s*$/;
|
|
549
|
+
const refs = [];
|
|
550
|
+
for (let i = index + 1; i < end; i += 1) {
|
|
551
|
+
const match = itemRe.exec(front.lines[i]);
|
|
552
|
+
if (!match)
|
|
553
|
+
continue;
|
|
554
|
+
const hadMd = match[3].endsWith(".md");
|
|
555
|
+
refs.push({
|
|
556
|
+
index: i,
|
|
557
|
+
value: hadMd ? match[3].slice(0, -3) : match[3],
|
|
558
|
+
hadMd,
|
|
559
|
+
indent: match[1],
|
|
560
|
+
quote: match[2],
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const copyItems = refs.filter((item) => item.value === copyRef);
|
|
564
|
+
if (copyItems.length === 0)
|
|
565
|
+
return content;
|
|
566
|
+
const lines = [...front.lines];
|
|
567
|
+
const remove = new Set();
|
|
568
|
+
let seenOriginal = refs.some((item) => item.value === originalRef);
|
|
569
|
+
for (const item of copyItems) {
|
|
570
|
+
if (seenOriginal) {
|
|
571
|
+
remove.add(item.index);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
const value = item.hadMd ? `${originalRef}.md` : originalRef;
|
|
575
|
+
lines[item.index] = `${item.indent}- ${item.quote}${value}${item.quote}`;
|
|
576
|
+
seenOriginal = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const next = withFrontLines(content, front, lines.filter((_, i) => !remove.has(i)), fileNewline(content));
|
|
580
|
+
const oldParsed = parseEntityFile(content);
|
|
581
|
+
const newParsed = parseEntityFile(next);
|
|
582
|
+
if (!oldParsed || !newParsed || oldParsed.body !== newParsed.body)
|
|
583
|
+
return "signal_ref_format";
|
|
584
|
+
const keys = new Set([...Object.keys(oldParsed.frontmatter), ...Object.keys(newParsed.frontmatter)]);
|
|
585
|
+
for (const key of keys) {
|
|
586
|
+
if (key === "entity_refs")
|
|
587
|
+
continue;
|
|
588
|
+
if (!isDeepStrictEqual(oldParsed.frontmatter[key], newParsed.frontmatter[key]))
|
|
589
|
+
return "signal_ref_format";
|
|
590
|
+
}
|
|
591
|
+
const newRefs = newParsed.frontmatter.entity_refs;
|
|
592
|
+
if (!Array.isArray(newRefs) || !newRefs.every((ref) => typeof ref === "string"))
|
|
593
|
+
return "signal_ref_format";
|
|
594
|
+
const normalized = newRefs.map((ref) => (ref.endsWith(".md") ? ref.slice(0, -3) : ref));
|
|
595
|
+
if (!normalized.includes(originalRef) || normalized.includes(copyRef))
|
|
596
|
+
return "signal_ref_format";
|
|
597
|
+
return next;
|
|
598
|
+
}
|
|
599
|
+
const REF_KEYS = new Set(["system", "namespace", "objectType", "recordId"]);
|
|
600
|
+
function refKey(ref) {
|
|
601
|
+
return `${ref.system}:${ref.namespace}:${ref.recordId}`.toLowerCase();
|
|
602
|
+
}
|
|
603
|
+
/** An `external_refs` entry, or null when it has another shape (so it is never rewritten). */
|
|
604
|
+
function readRef(raw) {
|
|
605
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
606
|
+
return null;
|
|
607
|
+
const record = raw;
|
|
608
|
+
if (!Object.keys(record).every((key) => REF_KEYS.has(key)))
|
|
609
|
+
return null;
|
|
610
|
+
if (!Object.values(record).every((value) => typeof value === "string" && value.length > 0))
|
|
611
|
+
return null;
|
|
612
|
+
if (record.system === undefined || record.namespace === undefined || record.recordId === undefined)
|
|
613
|
+
return null;
|
|
614
|
+
return record;
|
|
615
|
+
}
|
|
616
|
+
/** `external_ids` and `web_domains` of a front matter, or "identity_shape". */
|
|
617
|
+
function readIdentity(frontmatter) {
|
|
618
|
+
let ids = {};
|
|
619
|
+
if (frontmatter.external_ids !== undefined) {
|
|
620
|
+
const raw = frontmatter.external_ids;
|
|
621
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
622
|
+
return "identity_shape";
|
|
623
|
+
ids = {};
|
|
624
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
625
|
+
if (typeof value !== "string")
|
|
626
|
+
return "identity_shape";
|
|
627
|
+
ids[key] = value;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (frontmatter.web_domains !== undefined) {
|
|
631
|
+
if (!Array.isArray(frontmatter.web_domains) || !frontmatter.web_domains.every((domain) => typeof domain === "string")) {
|
|
632
|
+
return "identity_shape";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const domains = frontmatter.web_domains === undefined ? [] : frontmatter.web_domains;
|
|
636
|
+
const refs = [];
|
|
637
|
+
if (frontmatter.external_refs !== undefined) {
|
|
638
|
+
if (!Array.isArray(frontmatter.external_refs))
|
|
639
|
+
return "identity_shape";
|
|
640
|
+
for (const raw of frontmatter.external_refs) {
|
|
641
|
+
const ref = readRef(raw);
|
|
642
|
+
if (!ref)
|
|
643
|
+
return "identity_shape";
|
|
644
|
+
refs.push(ref);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return { ids, domains, refs };
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* The survivor's identity after taking in the copy's. Two different ids for
|
|
651
|
+
* one provider are a conflict unless `resolution` names one of them as
|
|
652
|
+
* primary; the other is then kept in `external_refs`.
|
|
653
|
+
*/
|
|
654
|
+
function unionIdentity(original, copy, resolution) {
|
|
655
|
+
const ids = { ...original.ids };
|
|
656
|
+
const addIds = [];
|
|
657
|
+
const conflicts = [];
|
|
658
|
+
const refs = [...original.refs];
|
|
659
|
+
const addRefs = [];
|
|
660
|
+
const seenRefs = new Set(refs.map(refKey));
|
|
661
|
+
const addRef = (ref) => {
|
|
662
|
+
const key = refKey(ref);
|
|
663
|
+
if (seenRefs.has(key))
|
|
664
|
+
return;
|
|
665
|
+
seenRefs.add(key);
|
|
666
|
+
refs.push(ref);
|
|
667
|
+
addRefs.push(key);
|
|
668
|
+
};
|
|
669
|
+
for (const ref of copy.refs)
|
|
670
|
+
addRef(ref);
|
|
671
|
+
for (const [key, value] of Object.entries(copy.ids)) {
|
|
672
|
+
if (!(key in ids)) {
|
|
673
|
+
ids[key] = value;
|
|
674
|
+
addIds.push(key);
|
|
675
|
+
}
|
|
676
|
+
else if (ids[key] !== value) {
|
|
677
|
+
const settle = Object.hasOwn(resolution ?? {}, key) ? resolution[key] : undefined;
|
|
678
|
+
if (!settle || (settle.primary !== ids[key] && settle.primary !== value)) {
|
|
679
|
+
conflicts.push(key);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const secondary = settle.primary === ids[key] ? value : ids[key];
|
|
683
|
+
ids[key] = settle.primary;
|
|
684
|
+
addRef({
|
|
685
|
+
system: key,
|
|
686
|
+
namespace: settle.namespace,
|
|
687
|
+
...(settle.object_type !== undefined ? { objectType: settle.object_type } : {}),
|
|
688
|
+
recordId: secondary,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const domains = [...original.domains];
|
|
693
|
+
const addDomains = [];
|
|
694
|
+
const seen = new Set(original.domains.map((domain) => domain.toLowerCase().trim()));
|
|
695
|
+
for (const domain of copy.domains) {
|
|
696
|
+
const key = domain.toLowerCase().trim();
|
|
697
|
+
if (seen.has(key))
|
|
698
|
+
continue;
|
|
699
|
+
seen.add(key);
|
|
700
|
+
domains.push(domain);
|
|
701
|
+
addDomains.push(domain);
|
|
702
|
+
}
|
|
703
|
+
return { ids, domains, refs, addIds, addDomains, addRefs, conflicts };
|
|
704
|
+
}
|
|
705
|
+
function frontMatterId(content) {
|
|
706
|
+
const id = parseEntityFile(content)?.frontmatter.id;
|
|
707
|
+
return typeof id === "string" ? id : null;
|
|
708
|
+
}
|
|
345
709
|
/**
|
|
346
710
|
* The original's content with the copy merged in. Never removes anything from
|
|
347
711
|
* the original. `siblings` are all copies of the same original; none of them
|
|
@@ -374,7 +738,7 @@ function mergeCopy(index, pair, siblings, originalContent, copyContent, now) {
|
|
|
374
738
|
return normalized !== "" && !own.has(normalized) && !moved.has(normalized);
|
|
375
739
|
});
|
|
376
740
|
const withRefs = appendBackRefs(originalContent, readBackRefs(copy.body));
|
|
377
|
-
const lines = missingRelationshipLines(withRefs.content, copyContent);
|
|
741
|
+
const lines = missingRelationshipLines(withRefs.content, copyContent, pair);
|
|
378
742
|
const body = appendRelationshipLines(withRefs.content, lines);
|
|
379
743
|
const raise = copyEntity.signal_count > originalEntity.signal_count ? copyEntity.signal_count : undefined;
|
|
380
744
|
const patched = patchEntityFile(body, {
|
|
@@ -386,9 +750,16 @@ function mergeCopy(index, pair, siblings, originalContent, copyContent, now) {
|
|
|
386
750
|
if (!patched.ok)
|
|
387
751
|
return patched.reason;
|
|
388
752
|
const have = new Set(splitBody(patched.content)?.body.split(/\r?\n/).map((line) => line.trim()) ?? []);
|
|
753
|
+
// The copy's `# Name` heading is not content: its name moves to an alias.
|
|
754
|
+
// The `## Relationships` heading is not content either: its lines count one by one.
|
|
755
|
+
const heading = `# ${copyEntity.canonical_name}`;
|
|
389
756
|
const leftover = (splitBody(copyContent)?.body.split(/\r?\n/) ?? [])
|
|
390
757
|
.map((line) => line.trim())
|
|
391
|
-
.filter((line) => line !== "" &&
|
|
758
|
+
.filter((line) => line !== "" &&
|
|
759
|
+
line !== heading &&
|
|
760
|
+
!/^##\s+Relationships$/.test(line) &&
|
|
761
|
+
!have.has(line) &&
|
|
762
|
+
!(LINK_ITEM.test(line) && !BACKREF_ITEM.test(line) && isPairSelfLine(line, pair))).length;
|
|
392
763
|
const keys = [...new Set([...Object.keys(original.frontmatter), ...Object.keys(copy.frontmatter)])].sort();
|
|
393
764
|
const kept = keys.filter((key) => !OWNED_KEYS.has(key) && !IGNORED_KEYS.has(key) && !isDeepStrictEqual(original.frontmatter[key], copy.frontmatter[key]));
|
|
394
765
|
return {
|
|
@@ -423,10 +794,19 @@ function briefLinks(company, copies) {
|
|
|
423
794
|
}
|
|
424
795
|
return [...out].sort();
|
|
425
796
|
}
|
|
426
|
-
function plan(company, index, pairs, allPairs, now) {
|
|
797
|
+
function plan(company, index, pairs, allPairs, now, rewriteRefs, resolutions = new Map()) {
|
|
427
798
|
const links = scanLinks(company);
|
|
428
799
|
const copies = new Set(allPairs.map((pair) => pair.copy));
|
|
429
800
|
const other = scanOtherLinks(company, copies);
|
|
801
|
+
if (rewriteRefs) {
|
|
802
|
+
for (const [copy, rels] of scanRelRefs(company, copies)) {
|
|
803
|
+
let set = other.get(copy);
|
|
804
|
+
if (!set)
|
|
805
|
+
other.set(copy, (set = new Set()));
|
|
806
|
+
for (const rel of rels)
|
|
807
|
+
set.add(rel);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
430
810
|
const signalRefs = scanSignalRefs(company, copies);
|
|
431
811
|
const siblingsOf = new Map();
|
|
432
812
|
for (const pair of allPairs)
|
|
@@ -438,7 +818,39 @@ function plan(company, index, pairs, allPairs, now) {
|
|
|
438
818
|
const siblings = siblingsOf.get(pair.original) ?? [pair.copy];
|
|
439
819
|
const copyContent = read(company, pair.copy);
|
|
440
820
|
const onDisk = read(company, pair.original);
|
|
441
|
-
|
|
821
|
+
let merged = mergeCopy(index, pair, siblings, out.finals.get(pair.original) ?? onDisk, copyContent, now);
|
|
822
|
+
let identity = null;
|
|
823
|
+
const resolution = resolutions.get(pair.copy);
|
|
824
|
+
if (rewriteRefs && typeof merged !== "string") {
|
|
825
|
+
const mergedFront = parseEntityFile(merged.content)?.frontmatter;
|
|
826
|
+
const copyFront = parseEntityFile(copyContent)?.frontmatter;
|
|
827
|
+
const mergedIdentity = mergedFront ? readIdentity(mergedFront) : "identity_shape";
|
|
828
|
+
const copyIdentity = copyFront ? readIdentity(copyFront) : "identity_shape";
|
|
829
|
+
if (!mergedFront || !copyFront || mergedIdentity === "identity_shape" || copyIdentity === "identity_shape") {
|
|
830
|
+
merged = "identity_shape";
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
const union = unionIdentity(mergedIdentity, copyIdentity, resolution);
|
|
834
|
+
if (union.conflicts.length > 0) {
|
|
835
|
+
identity = union;
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
const ids = setIdentityKeys(merged.content, {
|
|
839
|
+
externalIds: union.ids,
|
|
840
|
+
webDomains: union.domains,
|
|
841
|
+
...(union.addRefs.length > 0 ? { externalRefs: union.refs } : {}),
|
|
842
|
+
lastUpdated: now.toISOString(),
|
|
843
|
+
});
|
|
844
|
+
if (!ids.ok) {
|
|
845
|
+
merged = ids.reason;
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
merged = { ...merged, content: ids.content, changed: merged.changed || ids.changed };
|
|
849
|
+
identity = union;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
442
854
|
const entry = {
|
|
443
855
|
...pair,
|
|
444
856
|
add_aliases: typeof merged === "string" ? [] : merged.aliases,
|
|
@@ -451,12 +863,23 @@ function plan(company, index, pairs, allPairs, now) {
|
|
|
451
863
|
signal_refs: refs,
|
|
452
864
|
unmerged_body_lines: typeof merged === "string" ? 0 : merged.leftover,
|
|
453
865
|
};
|
|
454
|
-
|
|
866
|
+
if (rewriteRefs && identity && identity.conflicts.length === 0 && typeof merged !== "string") {
|
|
867
|
+
entry.add_external_ids = identity.addIds;
|
|
868
|
+
entry.add_web_domains = identity.addDomains;
|
|
869
|
+
entry.add_external_refs = identity.addRefs;
|
|
870
|
+
}
|
|
871
|
+
const unwritable = rewriteRefs
|
|
872
|
+
? inbound.filter((rel) => copies.has(rel))
|
|
873
|
+
: inbound.filter((rel) => !rel.startsWith("ontology/entities/") || !isWritableEntityType(rel.split("/")[2]) || copies.has(rel));
|
|
455
874
|
if (typeof merged === "string") {
|
|
456
875
|
entry.held = "merge_invalid";
|
|
457
876
|
entry.detail = merged;
|
|
458
877
|
}
|
|
459
|
-
else if (
|
|
878
|
+
else if (identity && identity.conflicts.length > 0) {
|
|
879
|
+
entry.held = "external_id_conflict";
|
|
880
|
+
entry.detail = identity.conflicts.join(", ");
|
|
881
|
+
}
|
|
882
|
+
else if (!rewriteRefs && refs.length > 0) {
|
|
460
883
|
entry.held = "signal_refs";
|
|
461
884
|
}
|
|
462
885
|
else if (unwritable.length > 0) {
|
|
@@ -468,15 +891,53 @@ function plan(company, index, pairs, allPairs, now) {
|
|
|
468
891
|
out.finals.set(pair.original, merged.content);
|
|
469
892
|
// Deletion is judged against the original as it is on disk now.
|
|
470
893
|
const current = mergeCopy(index, pair, siblings, onDisk, copyContent, now);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
894
|
+
let reason;
|
|
895
|
+
if (!rewriteRefs) {
|
|
896
|
+
reason =
|
|
897
|
+
typeof current === "string"
|
|
898
|
+
? "merge_invalid"
|
|
899
|
+
: current.changed
|
|
900
|
+
? "not_merged"
|
|
901
|
+
: current.leftover > 0
|
|
902
|
+
? "unmerged_body"
|
|
903
|
+
: inbound.length > 0
|
|
904
|
+
? "linked"
|
|
905
|
+
: null;
|
|
906
|
+
}
|
|
907
|
+
else if (typeof current === "string") {
|
|
908
|
+
reason = "merge_invalid";
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
const diskFront = parseEntityFile(onDisk)?.frontmatter;
|
|
912
|
+
const copyFront = parseEntityFile(copyContent)?.frontmatter;
|
|
913
|
+
const diskIdentity = diskFront ? readIdentity(diskFront) : "identity_shape";
|
|
914
|
+
const copyIdentity = copyFront ? readIdentity(copyFront) : "identity_shape";
|
|
915
|
+
if (!diskFront || !copyFront || diskIdentity === "identity_shape" || copyIdentity === "identity_shape") {
|
|
916
|
+
reason = "merge_invalid";
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
const union = unionIdentity(diskIdentity, copyIdentity, resolution);
|
|
920
|
+
const patched = setIdentityKeys(current.content, {
|
|
921
|
+
externalIds: union.ids,
|
|
922
|
+
webDomains: union.domains,
|
|
923
|
+
...(union.addRefs.length > 0 ? { externalRefs: union.refs } : {}),
|
|
924
|
+
lastUpdated: now.toISOString(),
|
|
925
|
+
});
|
|
926
|
+
reason = union.conflicts.length > 0
|
|
927
|
+
? "external_id_conflict"
|
|
928
|
+
: !patched.ok
|
|
929
|
+
? "merge_invalid"
|
|
930
|
+
: current.changed || patched.changed
|
|
931
|
+
? "not_merged"
|
|
932
|
+
: current.leftover > 0
|
|
933
|
+
? "unmerged_body"
|
|
934
|
+
: inbound.length > 0
|
|
935
|
+
? "linked"
|
|
936
|
+
: refs.length > 0
|
|
937
|
+
? "signal_refs"
|
|
938
|
+
: null;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
480
941
|
if (reason)
|
|
481
942
|
out.notReady.set(pair.copy, reason);
|
|
482
943
|
}
|
|
@@ -505,7 +966,13 @@ export async function mergeCopies(options) {
|
|
|
505
966
|
: found.pairs;
|
|
506
967
|
const pairs = options.limit !== undefined ? listed.slice(0, Math.max(0, options.limit)) : listed;
|
|
507
968
|
const before = scanLinks(company).broken;
|
|
508
|
-
const
|
|
969
|
+
const rewriteRefs = options.rewriteRefs === true;
|
|
970
|
+
const resolutions = new Map();
|
|
971
|
+
for (const entry of options.only ?? []) {
|
|
972
|
+
if (entry.resolve_external_ids)
|
|
973
|
+
resolutions.set(entry.copy, entry.resolve_external_ids);
|
|
974
|
+
}
|
|
975
|
+
const { plans, merges, finals, notReady } = plan(company, index, pairs, found.pairs, now, rewriteRefs, resolutions);
|
|
509
976
|
const report = {
|
|
510
977
|
company: company.slug,
|
|
511
978
|
mode,
|
|
@@ -529,6 +996,7 @@ export async function mergeCopies(options) {
|
|
|
529
996
|
: {}),
|
|
530
997
|
merged: [],
|
|
531
998
|
relinked: [],
|
|
999
|
+
...(rewriteRefs ? { rewritten: [] } : {}),
|
|
532
1000
|
deleted: [],
|
|
533
1001
|
vault_already_absent: [],
|
|
534
1002
|
not_ready: [],
|
|
@@ -554,15 +1022,61 @@ export async function mergeCopies(options) {
|
|
|
554
1022
|
for (const entry of plans) {
|
|
555
1023
|
if (!merges.has(entry.copy))
|
|
556
1024
|
continue;
|
|
1025
|
+
const copyId = rewriteRefs ? frontMatterId(read(company, entry.copy)) : null;
|
|
1026
|
+
const originalId = rewriteRefs ? frontMatterId(read(company, entry.original)) : null;
|
|
557
1027
|
for (const rel of entry.inbound_links) {
|
|
558
1028
|
try {
|
|
559
1029
|
const content = read(company, rel);
|
|
560
|
-
|
|
1030
|
+
if (!rewriteRefs) {
|
|
1031
|
+
const next = relink(rel, content, entry.copy, entry.original);
|
|
1032
|
+
if (next === content)
|
|
1033
|
+
continue;
|
|
1034
|
+
writeEntity(hqRoot, company, rel, next, now);
|
|
1035
|
+
if (!report.relinked.includes(rel))
|
|
1036
|
+
report.relinked.push(rel);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const next = rel.startsWith("ontology/entities/")
|
|
1040
|
+
? rewriteRelRefs(rel, relink(rel, content, entry.copy, entry.original), entry.copy, entry.original)
|
|
1041
|
+
: rewriteOtherFile(rel, content, entry.copy, entry.original, copyId, originalId);
|
|
1042
|
+
if (next === content)
|
|
1043
|
+
continue;
|
|
1044
|
+
if (referencesCopy(rel, next, entry.copy)) {
|
|
1045
|
+
report.errors.push({ path: rel, error: "link_not_rewritten" });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (rel.startsWith("ontology/entities/")) {
|
|
1049
|
+
writeEntity(hqRoot, company, rel, next, now);
|
|
1050
|
+
if (!report.relinked.includes(rel))
|
|
1051
|
+
report.relinked.push(rel);
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
safeWriteFile(hqRoot, [company.dir], path.join(company.dir, rel), next);
|
|
1055
|
+
if (!report.rewritten.includes(rel))
|
|
1056
|
+
report.rewritten.push(rel);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
report.errors.push({ path: rel, error: error.name });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (!rewriteRefs)
|
|
1064
|
+
continue;
|
|
1065
|
+
const copyRef = entityRefOf(entry.copy);
|
|
1066
|
+
const originalRef = entityRefOf(entry.original);
|
|
1067
|
+
for (const rel of entry.signal_refs) {
|
|
1068
|
+
try {
|
|
1069
|
+
const content = read(company, rel);
|
|
1070
|
+
const next = rewriteSignalRefs(content, copyRef, originalRef);
|
|
1071
|
+
if (next === "signal_ref_format") {
|
|
1072
|
+
report.errors.push({ path: rel, error: "signal_ref_format" });
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
561
1075
|
if (next === content)
|
|
562
1076
|
continue;
|
|
563
|
-
|
|
564
|
-
if (!report.
|
|
565
|
-
report.
|
|
1077
|
+
safeWriteFile(hqRoot, [company.dir], path.join(company.dir, rel), next);
|
|
1078
|
+
if (!report.rewritten.includes(rel))
|
|
1079
|
+
report.rewritten.push(rel);
|
|
566
1080
|
}
|
|
567
1081
|
catch (error) {
|
|
568
1082
|
report.errors.push({ path: rel, error: error.name });
|