@wildwinter/toolkit 0.2.1 → 0.2.3

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @wildwinter/toolkit
2
2
 
3
+ ## 0.2.3
4
+
5
+ ### Added
6
+
7
+ - **`@wildwinter/toolkit/vcs`: version-control setup for git, Perforce, Plastic SCM and SVN**, lifted from Patter so
8
+ both families take one copy. `vcsConfigFiles(format, vcs, posture)` writes the `vcs-setup.md` guide, git's
9
+ `.gitattributes` and the system's ignore file for a format that says what its files are (`VcsFormat`);
10
+ `bundlePostureOf` reads back whether the compiled bundle is committed or ignored; `runMergetool` is the wrapper a
11
+ system with one global merge tool calls, sending the format's source to its structured merge and anything else to
12
+ the team's own tool. Node-only, as `./archive` is.
13
+
14
+ ## 0.2.2
15
+
16
+ ### Added
17
+
18
+ - **Comment threads merge message by message** (`./extensions`): `mergeComments(base, ours, theirs)`, and the
19
+ `comments` strategy (`COMMENTS`) for `mergeTree`, lifted from Patter's merge so both families take one rule.
20
+ Two replies to one thread both land, oldest first; a withdrawn message stays withdrawn, and one a side removed
21
+ outright, or lost with its thread, counts as withdrawn by that side; one message edited two ways is a clash.
22
+ A thread's own fields (anchor, marker, `resolved`) merge one by one, and a thread with nothing readable left
23
+ goes. Threads come back oldest first.
24
+
3
25
  ## 0.2.1
4
26
 
5
27
  ### Changed
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  Small shared pieces of the **authoring tooling** behind
4
4
  [Storylet Studio](https://github.com/storylet-studio) and
5
5
  [Patter](https://patterkit.com): opaque ids, a stable hash, JSON5 source parsing,
6
- find/replace matching, the archive guards, and (in `./extensions`) the spec and merge
7
- for Patter extensions and Storylet extensions.
6
+ find/replace matching, the archive guards, version-control setup (in `./vcs`), and (in
7
+ `./extensions`) the spec and merge for Patter extensions and Storylet extensions.
8
8
 
9
9
  **Nothing here runs in a game.** This is the editor/CLI/pipeline side. The
10
10
  runtime language lives in [`@wildwinter/expr`](../expr), and unlike that package
@@ -93,6 +93,21 @@ pack **deterministic**: a fixed entry timestamp, no folder entries, no streaming
93
93
  A pack of an unchanged project must be byte-identical to the last one, or every
94
94
  delivery churns. That is easy to leave out, and silent when you do.
95
95
 
96
+ ## Version control
97
+
98
+ A separate, node-only entry point, because `runMergetool` starts the team's own merge tool:
99
+
100
+ ```ts
101
+ import { VCS_KINDS, vcsConfigFiles, bundlePostureOf, runMergetool } from "@wildwinter/toolkit/vcs";
102
+ ```
103
+
104
+ A format says what it is called and what its files are (`VcsFormat`: the tool, the git driver's name, the source
105
+ extensions, the bundle, the packed document, the conflict sidecar). `vcsConfigFiles(format, vcs, posture)` is then
106
+ everything a project is set up with for git, Perforce, Plastic SCM or SVN: the `vcs-setup.md` guide, git's
107
+ `.gitattributes`, and the system's ignore file, with the compiled bundle committed or ignored. `bundlePostureOf`
108
+ reads the posture back, so a switch of system keeps it. `runMergetool` is the one global merge tool Perforce,
109
+ Plastic SCM and SVN allow: the format's source goes to its structured merge, anything else to the team's own tool.
110
+
96
111
  ## Licence
97
112
 
98
113
  MIT
@@ -49,7 +49,52 @@
49
49
  "base": { "k": { "a": 1 } }, "ours": { "k": { "a": 2 } }, "theirs": { "k": { "a": 1, "b": 1 } },
50
50
  "merged": { "k": { "a": 2, "b": 1 } }, "conflicts": [] },
51
51
  { "name": "a value whose shape no longer fits its strategy merges as one value", "strategy": { "kind": "map", "of": { "kind": "atomic" } },
52
- "base": { "a": 1 }, "ours": "gone", "theirs": { "a": 2 }, "merged": "gone", "conflicts": [{ "path": "", "kind": "both-changed" }] }
52
+ "base": { "a": 1 }, "ours": "gone", "theirs": { "a": 2 }, "merged": "gone", "conflicts": [{ "path": "", "kind": "both-changed" }] },
53
+ { "name": "comments: two replies to one thread both land, in time order", "strategy": { "kind": "comments" },
54
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
55
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
56
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Cy", "ts": "2026-01-01T11:00", "body": "Ask Bo." }] }],
57
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Cy", "ts": "2026-01-01T11:00", "body": "Ask Bo." }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
58
+ "conflicts": [] },
59
+ { "name": "comments: a message withdrawn on one side stays withdrawn", "strategy": { "kind": "comments" },
60
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
61
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
62
+ "theirs": [{ "id": "t1", "anchor": "c1", "resolved": true, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
63
+ "merged": [{ "id": "t1", "anchor": "c1", "resolved": true, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
64
+ "conflicts": [] },
65
+ { "name": "comments: a message one side removed outright is withdrawn, not brought back", "strategy": { "kind": "comments" },
66
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
67
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
68
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "Oops" }] }],
69
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T11:00", "body": "", "deleted": true }] }],
70
+ "conflicts": [] },
71
+ { "name": "comments: a thread one side removed goes, unless the other side replied", "strategy": { "kind": "comments" },
72
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] },
73
+ { "id": "t2", "anchor": "c2", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:30", "body": "Typo" }] }],
74
+ "ours": [],
75
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] },
76
+ { "id": "t2", "anchor": "c2", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:30", "body": "Typo" }] }],
77
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "", "deleted": true }, { "author": "Bo", "ts": "2026-01-01T12:00", "body": "Pacing." }] }],
78
+ "conflicts": [] },
79
+ { "name": "comments: one message edited two ways clashes and keeps ours", "strategy": { "kind": "comments" },
80
+ "base": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
81
+ "ours": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why this?" }] }],
82
+ "theirs": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why that?" }] }],
83
+ "merged": [{ "id": "t1", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why this?" }] }],
84
+ "conflicts": [{ "path": "[t1].messages[Ann|2026-01-01T10:00]", "kind": "both-changed" }] },
85
+ { "name": "comments: a thread's own fields merge one by one, a marker moved two ways clashes", "strategy": { "kind": "comments" },
86
+ "base": [{ "id": "t1", "anchor": "c1", "mark": { "canvas": "d1", "x": 0, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
87
+ "ours": [{ "id": "t1", "anchor": "c1", "mark": { "canvas": "d1", "x": 5, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
88
+ "theirs": [{ "id": "t1", "anchor": "c1", "resolved": true, "mark": { "canvas": "d1", "x": 9, "y": 9 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
89
+ "merged": [{ "id": "t1", "anchor": "c1", "resolved": true, "mark": { "canvas": "d1", "x": 5, "y": 0 }, "messages": [{ "author": "Ann", "ts": "2026-01-01T10:00", "body": "Why?" }] }],
90
+ "conflicts": [{ "path": "[t1].mark", "kind": "both-changed" }] },
91
+ { "name": "comments: threads come back oldest first, whatever their ids", "strategy": { "kind": "comments" },
92
+ "base": [],
93
+ "ours": [{ "id": "zz", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T09:00", "body": "First" }] }],
94
+ "theirs": [{ "id": "aa", "anchor": "c2", "messages": [{ "author": "Bo", "ts": "2026-01-02T09:00", "body": "Second" }] }],
95
+ "merged": [{ "id": "zz", "anchor": "c1", "messages": [{ "author": "Ann", "ts": "2026-01-01T09:00", "body": "First" }] },
96
+ { "id": "aa", "anchor": "c2", "messages": [{ "author": "Bo", "ts": "2026-01-02T09:00", "body": "Second" }] }],
97
+ "conflicts": [] }
53
98
  ],
54
99
  "manifest": {
55
100
  "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
@@ -641,6 +641,7 @@ function effectiveClaim(claim, hostDecl) {
641
641
  // src/extensions/merge.ts
642
642
  var ATOMIC = { kind: "atomic" };
643
643
  var SET = { kind: "set" };
644
+ var COMMENTS = { kind: "comments" };
644
645
  var mapOf = (of) => ({ kind: "map", of });
645
646
  var keyedBy = (key, of, opts) => ({
646
647
  kind: "keyed",
@@ -683,6 +684,7 @@ function hasShape(spec, v) {
683
684
  return true;
684
685
  case "set":
685
686
  case "keyed":
687
+ case "comments":
686
688
  return Array.isArray(v);
687
689
  case "map":
688
690
  case "object":
@@ -722,6 +724,8 @@ function mergeValue(spec, b, o, t, id, path, ctx) {
722
724
  }
723
725
  case "keyed":
724
726
  return mergeKeyed(spec, asArr(b), o, t, path, ctx);
727
+ case "comments":
728
+ return mergeThreads(asArr(b), o, t, path, ctx);
725
729
  }
726
730
  }
727
731
  function mergeKeyed(spec, B, O, T, path, ctx) {
@@ -800,6 +804,70 @@ function preservedOrder(B, O, T, survivors, path, ctx) {
800
804
  for (const k of [...survivors.keys()].sort()) if (!result.includes(k)) result.push(k);
801
805
  return result;
802
806
  }
807
+ function mergeComments(base, ours, theirs, opts) {
808
+ return mergeTree(COMMENTS, base, ours, theirs, opts);
809
+ }
810
+ function mergeThreads(B, O, T, path, ctx) {
811
+ const index = (arr) => {
812
+ const m = /* @__PURE__ */ new Map();
813
+ for (const c of arr) if (isObject(c) && typeof c["id"] === "string") m.set(c["id"], c);
814
+ return m;
815
+ };
816
+ const Bm = index(B), Om = index(O), Tm = index(T);
817
+ const out = [];
818
+ for (const id of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
819
+ const b = Bm.get(id), o = Om.get(id), t = Tm.get(id);
820
+ const here = `${path}[${id}]`;
821
+ if (o === void 0 && t === void 0) continue;
822
+ if (![b, o, t].some((c) => Array.isArray(c?.["messages"]))) {
823
+ const v = o !== void 0 && t !== void 0 ? merge3(b, o, t, id, here, ctx) : b !== void 0 && ctx.eq(o ?? t, b) ? void 0 : o ?? t;
824
+ if (isObject(v)) out.push(v);
825
+ continue;
826
+ }
827
+ const messages = mergeMessages(b, o, t, id, here, ctx);
828
+ if (messages.length === 0 || messages.every((m) => m["deleted"] === true)) continue;
829
+ const fields = {};
830
+ if (o !== void 0 && t !== void 0) {
831
+ const B0 = b ?? {};
832
+ const flag = (c, k) => k === "resolved" ? c[k] === true ? true : void 0 : c[k];
833
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(o), ...Object.keys(t), ...Object.keys(B0)])) {
834
+ if (k === "messages") continue;
835
+ const v = merge3(flag(B0, k), flag(o, k), flag(t, k), id, `${here}.${k}`, ctx);
836
+ if (v !== void 0) fields[k] = v;
837
+ }
838
+ } else {
839
+ for (const [k, v] of Object.entries(o ?? t)) if (k !== "messages") fields[k] = v;
840
+ }
841
+ out.push({ ...fields, messages });
842
+ }
843
+ const first = (c) => {
844
+ const m = asArr(c["messages"])[0];
845
+ return isObject(m) && typeof m["ts"] === "string" ? m["ts"] : "";
846
+ };
847
+ return out.sort((x, y) => first(x).localeCompare(first(y)) || String(x["id"]).localeCompare(String(y["id"])));
848
+ }
849
+ function mergeMessages(b, o, t, id, here, ctx) {
850
+ const keyOf = (m) => `${String(m["author"] ?? "")}|${String(m["ts"] ?? "")}`;
851
+ const index = (c) => {
852
+ const m = /* @__PURE__ */ new Map();
853
+ for (const x of asArr(c?.["messages"])) if (isObject(x)) m.set(keyOf(x), x);
854
+ return m;
855
+ };
856
+ const Bm = index(b), Om = index(o), Tm = index(t);
857
+ const out = [];
858
+ for (const k of /* @__PURE__ */ new Set([...Bm.keys(), ...Om.keys(), ...Tm.keys()])) {
859
+ const mb = Bm.get(k), mo = Om.get(k), mt = Tm.get(k);
860
+ if ([mb, mo, mt].some((m) => m?.["deleted"] === true) || mb !== void 0 && (mo === void 0 || mt === void 0)) {
861
+ out.push({ ...mo ?? mt ?? mb, body: "", deleted: true });
862
+ } else if (mo !== void 0 && mt !== void 0) {
863
+ out.push(merge3(mb, mo, mt, id, `${here}.messages[${k}]`, ctx));
864
+ } else {
865
+ out.push(mo ?? mt);
866
+ }
867
+ }
868
+ const ts = (m) => String(m["ts"] ?? "");
869
+ return out.sort((x, y) => ts(x).localeCompare(ts(y)) || String(x["author"] ?? "").localeCompare(String(y["author"] ?? "")));
870
+ }
803
871
  function strategyForType(decl) {
804
872
  switch (decl.type) {
805
873
  case "flags":
@@ -908,6 +976,7 @@ function orphanedEntries(data, exists) {
908
976
  }
909
977
 
910
978
  exports.ATOMIC = ATOMIC;
979
+ exports.COMMENTS = COMMENTS;
911
980
  exports.HOSTS = HOSTS;
912
981
  exports.MANIFEST_FILE_SUFFIX = MANIFEST_FILE_SUFFIX;
913
982
  exports.MANIFEST_SCHEMA = MANIFEST_SCHEMA;
@@ -928,6 +997,7 @@ exports.keyedBy = keyedBy;
928
997
  exports.manifestProblems = manifestProblems;
929
998
  exports.mapOf = mapOf;
930
999
  exports.markUnresolved = markUnresolved;
1000
+ exports.mergeComments = mergeComments;
931
1001
  exports.mergeExtensionData = mergeExtensionData;
932
1002
  exports.mergeTree = mergeTree;
933
1003
  exports.objectOf = objectOf;