@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.
@@ -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
- /** Relationship lines of the copy that the original lacks (back-references excluded). */
69
- function missingRelationshipLines(originalContent, copyContent) {
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
- const copy = byRef.get(ref);
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 !== "" && !have.has(line)).length;
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
- const merged = mergeCopy(index, pair, siblings, out.finals.get(pair.original) ?? onDisk, copyContent, now);
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
- const unwritable = inbound.filter((rel) => !rel.startsWith("ontology/entities/") || !isWritableEntityType(rel.split("/")[2]) || copies.has(rel));
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 (refs.length > 0) {
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
- const reason = typeof current === "string"
472
- ? "merge_invalid"
473
- : current.changed
474
- ? "not_merged"
475
- : current.leftover > 0
476
- ? "unmerged_body"
477
- : inbound.length > 0
478
- ? "linked"
479
- : null;
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 { plans, merges, finals, notReady } = plan(company, index, pairs, found.pairs, now);
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
- const next = relink(rel, content, entry.copy, entry.original);
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
- writeEntity(hqRoot, company, rel, next, now);
564
- if (!report.relinked.includes(rel))
565
- report.relinked.push(rel);
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 });