@wildwinter/toolkit 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,45 @@
1
+ # @wildwinter/toolkit
2
+
3
+ ## 0.2.0
4
+
5
+ ### Added
6
+
7
+ - **`@wildwinter/toolkit/extensions`**: the shared spec and merge for Patter extensions and Storylet
8
+ extensions ([extensions.md](extensions.md)). The manifest and its checks (`manifestProblems`), the value types
9
+ and their checks (`valueProblems`, `entryProblems`) on the kernel's `valueFits`, `qualityDeclarationProblems`
10
+ and `stagesEitherSide`, defaults (`defaultOf`, through `defaultFor`), what a bundle carries (`compiledTarget`,
11
+ `compileEntry`), stage renames (`renameStages`), claims on host fields (`resolvedClaims`, `claimClashes`), the
12
+ three-way merge engine (`mergeTree`) and its manifest-built strategies (`mergeExtensionData`), the
13
+ `unresolved` mark (`markUnresolved`, `unresolvedProblems`) and orphaned entries (`orphanedEntries`).
14
+ Hand-written conformance cases in `conformance/extensions/`. `@wildwinter/expr` and
15
+ `@wildwinter/scoperegistry` are optional peer dependencies, needed by this entry only.
16
+
17
+ ## 0.1.0
18
+
19
+ First release. Small shared pieces of the authoring tooling, each of which
20
+ arrived by being found duplicated across the two consuming product families
21
+ rather than by being anticipated.
22
+
23
+ Nothing here runs in a game, and nothing here is about the expression language:
24
+ that is `@wildwinter/expr`, and game-side sharing is the vendored source in
25
+ `expr/ports`, because C++, C# and GDScript cannot consume an npm package.
26
+
27
+ ### Added
28
+
29
+ - **`newId` / `slug`** (`ids.ts`): opaque, rejection-sampled base-36 ids, so
30
+ every alphabet character is equally likely. A plain `byte % 36` over-weights
31
+ the first four.
32
+ - **`fnv32`** (`hash.ts`): FNV-1a, for short stable handles and change
33
+ detection. Not cryptographic.
34
+ - **`parseSource`** (`source.ts`): JSON5 with a tolerated byte-order mark, which
35
+ JSON5 alone will not accept and Windows editors add.
36
+ - **`findMatcher`** (`find.ts`): the escaped, global find regex behind find and
37
+ replace, so a query of `a.b` means those three characters.
38
+ - **`@wildwinter/toolkit/archive`**: the zip entry guards (`isUnsafeEntry`,
39
+ `escapesTarget`) and the settings that make an archive byte-reproducible. A
40
+ separate entry point because it needs `node:path`, and the main entry stays
41
+ isomorphic so a browser bundle can take it.
42
+
43
+ The guards were correct in both families before this, which is the state
44
+ BEFORE a drift rather than evidence there will not be one: a subtle weakening
45
+ of one copy is a vulnerability nobody reads a diff for.
package/README.md CHANGED
@@ -3,7 +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, and the archive guards.
6
+ find/replace matching, the archive guards, and (in `./extensions`) the spec and merge
7
+ for Patter extensions and Storylet extensions.
7
8
 
8
9
  **Nothing here runs in a game.** This is the editor/CLI/pipeline side. The
9
10
  runtime language lives in [`@wildwinter/expr`](../expr), and unlike that package
@@ -15,7 +16,21 @@ Each was found duplicated, almost but not exactly, across both families. That
15
16
  one careless edit away from being correct in one, and nobody reads the diff of a
16
17
  file they believe is a copy.
17
18
 
18
- ESM + CJS + types. `json5` is the only dependency.
19
+ ESM + CJS + types. `json5` is the only dependency of the main entry.
20
+
21
+ ## Extensions (`@wildwinter/toolkit/extensions`)
22
+
23
+ The one part with shared semantics: the manifest a Patter extension or a Storylet extension ships, the types its
24
+ values take, the checks both editors and both command lines run, and the type-driven three-way merge both formats
25
+ use for extension data, with the `unresolved` mark an unresolved merge leaves in a shard. The spec is
26
+ [extensions.md](extensions.md) and its contract the hand-written cases in `conformance/extensions/`.
27
+
28
+ It is built on the kernel's own value and ladder rules, so it needs `@wildwinter/expr` and
29
+ `@wildwinter/scoperegistry`: optional peer dependencies, which every host of it already has.
30
+
31
+ ```ts
32
+ import { manifestProblems, mergeExtensionData } from "@wildwinter/toolkit/extensions";
33
+ ```
19
34
 
20
35
  ```ts
21
36
  import { newId, slug, fnv32, parseSource, findMatcher } from "@wildwinter/toolkit";
@@ -0,0 +1,69 @@
1
+ {
2
+ "about": "Defaults, compilation and stage renames. `defaults`: defaultOf for a declaration. `compile`: compiledTarget (declarations as a bundle carries them: editor-only fields gone, every default resolved, label, help, valueHelp and group gone) and compileEntry (a node's values as a bundle carries them: editor-only and undeclared fields gone, renamed stages renamed, otherwise raw). `renames`: renameStages over an entry, with the renames it made.",
3
+ "defaults": [
4
+ { "name": "a given default wins", "decl": { "name": "a", "type": "number", "default": 4 }, "default": 4 },
5
+ { "name": "text", "decl": { "name": "a", "type": "text" }, "default": "" },
6
+ { "name": "multiline", "decl": { "name": "a", "type": "multiline" }, "default": "" },
7
+ { "name": "number", "decl": { "name": "a", "type": "number" }, "default": 0 },
8
+ { "name": "boolean", "decl": { "name": "a", "type": "boolean" }, "default": false },
9
+ { "name": "enum: the first value", "decl": { "name": "a", "type": "enum", "values": ["x", "y"] }, "default": "x" },
10
+ { "name": "an enum may default to none chosen", "decl": { "name": "a", "type": "enum", "values": ["x", "y"], "default": "" }, "default": "" },
11
+ { "name": "flags: none", "decl": { "name": "a", "type": "flags", "values": ["x"] }, "default": [] },
12
+ { "name": "quality: the first stage", "decl": { "name": "a", "type": "quality", "stages": ["p", "q"] }, "default": "p" },
13
+ { "name": "reference: none", "decl": { "name": "a", "type": "reference", "to": "card" }, "default": "" },
14
+ { "name": "record: each field's default", "decl": { "name": "a", "type": "record", "fields": [
15
+ { "name": "s", "type": "text" }, { "name": "n", "type": "number", "default": 2 }, { "name": "q", "type": "quality", "stages": ["p", "q"] }
16
+ ] }, "default": { "n": 2, "q": "p", "s": "" } },
17
+ { "name": "record: a given default wins whole", "decl": { "name": "a", "type": "record", "fields": [{ "name": "s", "type": "text" }], "default": { "s": "x" } }, "default": { "s": "x" } },
18
+ { "name": "map: empty", "decl": { "name": "a", "type": "map", "key": { "type": "text" }, "of": { "type": "number" } }, "default": {} },
19
+ { "name": "list: empty", "decl": { "name": "a", "type": "list", "of": { "type": "number" } }, "default": [] }
20
+ ],
21
+ "compile": [
22
+ {
23
+ "name": "a target as the bundle carries it",
24
+ "target": { "fields": [
25
+ { "name": "note", "type": "text", "label": "Note", "help": "Help.", "group": "G", "required": true },
26
+ { "name": "draft", "type": "multiline", "editorOnly": true },
27
+ { "name": "tone", "type": "enum", "values": ["calm", "tense"], "valueHelp": { "calm": "x" } },
28
+ { "name": "act", "type": "quality", "stages": ["setup", "turn"], "renamedStages": { "opening": "setup" } },
29
+ { "name": "cue", "type": "record", "fields": [{ "name": "gain", "type": "number", "min": 0, "help": "Loudness." }] }
30
+ ], "claims": [{ "setting": "moodField", "type": "text" }] },
31
+ "compiled": [
32
+ { "name": "note", "type": "text", "default": "", "required": true },
33
+ { "name": "tone", "type": "enum", "values": ["calm", "tense"], "default": "calm" },
34
+ { "name": "act", "type": "quality", "stages": ["setup", "turn"], "default": "setup" },
35
+ { "name": "cue", "type": "record", "fields": [{ "name": "gain", "type": "number", "min": 0, "default": 0 }], "default": { "gain": 0 } }
36
+ ],
37
+ "entry": { "note": "n", "draft": "only in the editor", "act": "opening", "stray": 1, "cue": { "gain": 2 } },
38
+ "compiledEntry": { "act": "setup", "cue": { "gain": 2 }, "note": "n" }
39
+ },
40
+ {
41
+ "name": "an entry with nothing compiled compiles to nothing",
42
+ "target": { "fields": [{ "name": "draft", "type": "text", "editorOnly": true }] },
43
+ "compiled": [],
44
+ "entry": { "draft": "x" },
45
+ "compiledEntry": null
46
+ }
47
+ ],
48
+ "renames": [
49
+ {
50
+ "name": "stages renamed wherever a quality is, at any depth",
51
+ "target": { "fields": [
52
+ { "name": "act", "type": "quality", "stages": ["setup", "turn"], "renamedStages": { "opening": "setup", "middle": "turn" } },
53
+ { "name": "beats", "type": "list", "id": "id", "of": { "type": "record", "fields": [
54
+ { "name": "id", "type": "text" },
55
+ { "name": "phase", "type": "quality", "stages": ["in", "out"], "renamedStages": { "enter": "in" } }
56
+ ] } },
57
+ { "name": "byTag", "type": "map", "key": { "type": "text" }, "of": { "type": "quality", "stages": ["lo", "hi"], "renamedStages": { "low": "lo" } } },
58
+ { "name": "tone", "type": "text" }
59
+ ] },
60
+ "entry": { "act": "middle", "beats": [{ "id": "b1", "phase": "enter" }, { "id": "b2", "phase": "out" }], "byTag": { "a": "low", "b": "hi" }, "tone": "opening" },
61
+ "renamed": { "act": "turn", "beats": [{ "id": "b1", "phase": "in" }, { "id": "b2", "phase": "out" }], "byTag": { "a": "lo", "b": "hi" }, "tone": "opening" },
62
+ "renames": [
63
+ { "path": "act", "from": "middle", "to": "turn" },
64
+ { "path": "beats[0].phase", "from": "enter", "to": "in" },
65
+ { "path": "byTag.a", "from": "low", "to": "lo" }
66
+ ]
67
+ }
68
+ ]
69
+ }
@@ -0,0 +1,264 @@
1
+ {
2
+ "about": "Manifest validation. Each case gives a manifest and every problem manifestProblems reports for it, by path and code, in the order reported. A case with no problems is a manifest that must pass.",
3
+ "cases": [
4
+ {
5
+ "name": "a minimal manifest passes",
6
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
7
+ "targets": { "storylets": { "card": { "fields": [{ "name": "mood", "type": "text" }] } } } },
8
+ "problems": []
9
+ },
10
+ {
11
+ "name": "every type, a claim and a setting pass together",
12
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "2.1.0",
13
+ "docs": "https://example.com/probe",
14
+ "settings": [{ "name": "moodField", "label": "Mood field", "default": "mood" }],
15
+ "targets": {
16
+ "storylets": {
17
+ "card": {
18
+ "fields": [
19
+ { "name": "note", "type": "text", "label": "Note", "help": "A line for the crew." },
20
+ { "name": "body", "type": "multiline", "editorOnly": true },
21
+ { "name": "weight", "type": "number", "min": 0, "max": 10, "default": 1 },
22
+ { "name": "loud", "type": "boolean", "required": true },
23
+ { "name": "tone", "type": "enum", "values": ["calm", "tense"], "valueHelp": { "calm": "Unhurried." } },
24
+ { "name": "marks", "type": "flags", "values": ["night", "rain"], "default": ["rain"] },
25
+ { "name": "act", "type": "quality", "stages": ["setup", "turn", "close"], "renamedStages": { "opening": "setup" } },
26
+ { "name": "next", "type": "reference", "to": "card" },
27
+ { "name": "cue", "type": "record", "fields": [{ "name": "sound", "type": "text" }, { "name": "gain", "type": "number" }] },
28
+ { "name": "byTag", "type": "map", "key": { "type": "reference", "to": "tag" }, "of": { "type": "number" } },
29
+ { "name": "byName", "type": "map", "key": { "type": "text" }, "of": { "type": "record", "fields": [{ "name": "look", "type": "text" }] } },
30
+ { "name": "beats", "type": "list", "id": "id", "of": { "type": "record", "fields": [{ "name": "id", "type": "text" }, { "name": "at", "type": "number" }] } },
31
+ { "name": "words", "type": "list", "of": { "type": "text" } }
32
+ ],
33
+ "claims": [{ "setting": "moodField", "type": "enum", "values": ["calm", "tense"], "help": "The card's mood." }]
34
+ },
35
+ "project": { "fields": [{ "name": "studio", "type": "text" }] }
36
+ },
37
+ "patter": { "line": { "fields": [{ "name": "to", "type": "reference", "to": "cast" }] } }
38
+ } },
39
+ "problems": []
40
+ },
41
+ {
42
+ "name": "identity is checked",
43
+ "manifest": { "schema": "wildwinter/extension@1", "id": "Probe Two", "name": " ", "version": "1.0", "docs": 3,
44
+ "targets": { "storylets": { "card": { "fields": [] } } } },
45
+ "problems": [
46
+ { "path": "schema", "code": "schema" },
47
+ { "path": "id", "code": "id" },
48
+ { "path": "name", "code": "name" },
49
+ { "path": "version", "code": "version" },
50
+ { "path": "docs", "code": "docs" }
51
+ ]
52
+ },
53
+ {
54
+ "name": "a manifest must target something it knows",
55
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
56
+ "targets": { "ink": { "knot": {} }, "storylets": { "scene": {} } } },
57
+ "problems": [
58
+ { "path": "targets.ink", "code": "unknown-host" },
59
+ { "path": "targets.storylets.scene", "code": "unknown-kind" }
60
+ ]
61
+ },
62
+ {
63
+ "name": "no targets at all",
64
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0", "targets": {} },
65
+ "problems": [{ "path": "targets", "code": "no-targets" }]
66
+ },
67
+ {
68
+ "name": "field names are identifiers, once each",
69
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
70
+ "targets": { "storylets": { "card": { "fields": [
71
+ { "name": "mood", "type": "text" }, { "name": "mood", "type": "number" }, { "name": "9lives", "type": "text" }
72
+ ] } } } },
73
+ "problems": [
74
+ { "path": "targets.storylets.card.fields[1].name", "code": "duplicate-field" },
75
+ { "path": "targets.storylets.card.fields[2].name", "code": "field-name" }
76
+ ]
77
+ },
78
+ {
79
+ "name": "the text type is spelled text, and an unknown type is refused",
80
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
81
+ "targets": { "storylets": { "card": { "fields": [
82
+ { "name": "a", "type": "string" }, { "name": "b", "type": "colour" }
83
+ ] } } } },
84
+ "problems": [
85
+ { "path": "targets.storylets.card.fields[0].type", "code": "string-type" },
86
+ { "path": "targets.storylets.card.fields[1].type", "code": "unknown-type" }
87
+ ]
88
+ },
89
+ {
90
+ "name": "enum and flags values: some, none empty, none twice",
91
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
92
+ "targets": { "storylets": { "card": { "fields": [
93
+ { "name": "a", "type": "enum", "values": [] },
94
+ { "name": "b", "type": "flags", "values": ["x", "", "x"] },
95
+ { "name": "c", "type": "enum" }
96
+ ] } } } },
97
+ "problems": [
98
+ { "path": "targets.storylets.card.fields[0].values", "code": "values-empty" },
99
+ { "path": "targets.storylets.card.fields[1].values[1]", "code": "value-empty" },
100
+ { "path": "targets.storylets.card.fields[1].values[2]", "code": "value-duplicate" },
101
+ { "path": "targets.storylets.card.fields[2].values", "code": "values-empty" }
102
+ ]
103
+ },
104
+ {
105
+ "name": "a quality's ladder follows the kernel's rule",
106
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
107
+ "targets": { "storylets": { "card": { "fields": [
108
+ { "name": "a", "type": "quality", "stages": ["only"] },
109
+ { "name": "b", "type": "quality", "stages": ["x", "x"] },
110
+ { "name": "c", "type": "quality", "stages": ["x", "y"], "default": "z" }
111
+ ] } } } },
112
+ "problems": [
113
+ { "path": "targets.storylets.card.fields[0].stages", "code": "quality-too-few-stages" },
114
+ { "path": "targets.storylets.card.fields[1].stages", "code": "quality-duplicate-stage" },
115
+ { "path": "targets.storylets.card.fields[2].default", "code": "default-misfit" }
116
+ ]
117
+ },
118
+ {
119
+ "name": "renamed stages map old names to current stages",
120
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
121
+ "targets": { "storylets": { "card": { "fields": [
122
+ { "name": "act", "type": "quality", "stages": ["setup", "turn"], "renamedStages": { "turn": "setup", "old": "gone" } },
123
+ { "name": "tone", "type": "enum", "values": ["a", "b"], "renamedStages": { "x": "a" } }
124
+ ] } } } },
125
+ "problems": [
126
+ { "path": "targets.storylets.card.fields[0].renamedStages.turn", "code": "renamed-stages" },
127
+ { "path": "targets.storylets.card.fields[0].renamedStages.old", "code": "renamed-stages" },
128
+ { "path": "targets.storylets.card.fields[1].renamedStages", "code": "renamed-stages" }
129
+ ]
130
+ },
131
+ {
132
+ "name": "value help names only values the field has",
133
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
134
+ "targets": { "storylets": { "card": { "fields": [
135
+ { "name": "tone", "type": "enum", "values": ["a", "b"], "valueHelp": { "a": "Fine.", "c": "No such value." } },
136
+ { "name": "n", "type": "number", "valueHelp": { "1": "One." } }
137
+ ] } } } },
138
+ "problems": [
139
+ { "path": "targets.storylets.card.fields[0].valueHelp.c", "code": "value-help" },
140
+ { "path": "targets.storylets.card.fields[1].valueHelp", "code": "value-help" }
141
+ ]
142
+ },
143
+ {
144
+ "name": "a reference names a kind its host can point at",
145
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
146
+ "targets": {
147
+ "storylets": { "card": { "fields": [{ "name": "who", "type": "reference", "to": "cast" }, { "name": "where", "type": "reference" }] } },
148
+ "patter": { "line": { "fields": [{ "name": "card", "type": "reference", "to": "card" }] } }
149
+ } },
150
+ "problems": [
151
+ { "path": "targets.storylets.card.fields[0].to", "code": "reference-to" },
152
+ { "path": "targets.storylets.card.fields[1].to", "code": "reference-to" },
153
+ { "path": "targets.patter.line.fields[0].to", "code": "reference-to" }
154
+ ]
155
+ },
156
+ {
157
+ "name": "structured types need their parts",
158
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
159
+ "targets": { "storylets": { "card": { "fields": [
160
+ { "name": "r", "type": "record", "fields": [] },
161
+ { "name": "m", "type": "map", "key": { "type": "number" }, "of": { "type": "text" } },
162
+ { "name": "m2", "type": "map", "key": { "type": "text" } },
163
+ { "name": "l", "type": "list", "id": "id", "of": { "type": "record", "fields": [{ "name": "at", "type": "number" }] } },
164
+ { "name": "l2", "type": "list", "id": "id", "of": { "type": "text" } },
165
+ { "name": "l3", "type": "list" }
166
+ ] } } } },
167
+ "problems": [
168
+ { "path": "targets.storylets.card.fields[0].fields", "code": "record-empty" },
169
+ { "path": "targets.storylets.card.fields[1].key", "code": "map-key" },
170
+ { "path": "targets.storylets.card.fields[2].of", "code": "missing-of" },
171
+ { "path": "targets.storylets.card.fields[3].id", "code": "list-id" },
172
+ { "path": "targets.storylets.card.fields[4].id", "code": "list-id" },
173
+ { "path": "targets.storylets.card.fields[5].of", "code": "missing-of" }
174
+ ]
175
+ },
176
+ {
177
+ "name": "problems inside a structured type are found where they are",
178
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
179
+ "targets": { "storylets": { "card": { "fields": [
180
+ { "name": "m", "type": "map", "key": { "type": "text" }, "of": { "type": "record", "fields": [
181
+ { "name": "a", "type": "enum", "values": [] }, { "name": "a", "type": "text", "editorOnly": true }
182
+ ] } }
183
+ ] } } } },
184
+ "problems": [
185
+ { "path": "targets.storylets.card.fields[0].of.fields[0].values", "code": "values-empty" },
186
+ { "path": "targets.storylets.card.fields[0].of.fields[1].name", "code": "duplicate-field" },
187
+ { "path": "targets.storylets.card.fields[0].of.fields[1].editorOnly", "code": "editor-only-nested" }
188
+ ]
189
+ },
190
+ {
191
+ "name": "a given default must fit, in every type",
192
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
193
+ "targets": { "storylets": { "card": { "fields": [
194
+ { "name": "a", "type": "number", "default": "1" },
195
+ { "name": "b", "type": "enum", "values": ["x"], "default": "y" },
196
+ { "name": "c", "type": "enum", "values": ["x"], "default": "" },
197
+ { "name": "d", "type": "flags", "values": ["x"], "default": ["x", "z"] },
198
+ { "name": "e", "type": "number", "min": 1, "default": 0 },
199
+ { "name": "f", "type": "record", "fields": [{ "name": "g", "type": "boolean" }], "default": { "g": 1 } },
200
+ { "name": "h", "type": "list", "of": { "type": "number" }, "default": [1, "2"] }
201
+ ] } } } },
202
+ "problems": [
203
+ { "path": "targets.storylets.card.fields[0].default", "code": "default-misfit" },
204
+ { "path": "targets.storylets.card.fields[1].default", "code": "default-misfit" },
205
+ { "path": "targets.storylets.card.fields[3].default", "code": "default-misfit" },
206
+ { "path": "targets.storylets.card.fields[4].default", "code": "default-misfit" },
207
+ { "path": "targets.storylets.card.fields[5].default", "code": "default-misfit" },
208
+ { "path": "targets.storylets.card.fields[6].default", "code": "default-misfit" }
209
+ ]
210
+ },
211
+ {
212
+ "name": "ranges apply to numbers and run upwards",
213
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
214
+ "targets": { "storylets": { "card": { "fields": [
215
+ { "name": "a", "type": "number", "min": 5, "max": 1 },
216
+ { "name": "b", "type": "text", "max": 3 }
217
+ ] } } } },
218
+ "problems": [
219
+ { "path": "targets.storylets.card.fields[0].max", "code": "range" },
220
+ { "path": "targets.storylets.card.fields[1].max", "code": "range" }
221
+ ]
222
+ },
223
+ {
224
+ "name": "a misspelt attribute is a warning, not silence",
225
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
226
+ "targets": { "storylets": { "card": { "fields": [{ "name": "a", "type": "text", "requird": true }] } } } },
227
+ "problems": [{ "path": "targets.storylets.card.fields[0].requird", "code": "unknown-key", "severity": "warning" }]
228
+ },
229
+ {
230
+ "name": "claims: a claimable kind, a declared setting, a scalar, once per setting",
231
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
232
+ "settings": [{ "name": "moodField", "default": "mood" }],
233
+ "targets": {
234
+ "storylets": {
235
+ "deck": { "claims": [{ "setting": "moodField", "type": "text" }] },
236
+ "card": { "claims": [
237
+ { "setting": "moodField", "type": "text" },
238
+ { "setting": "moodField", "type": "text" },
239
+ { "setting": "nope", "type": "text" },
240
+ { "setting": "moodField", "type": "record", "fields": [{ "name": "a", "type": "text" }] }
241
+ ] }
242
+ }
243
+ } },
244
+ "problems": [
245
+ { "path": "targets.storylets.deck.claims[0]", "code": "claim-kind" },
246
+ { "path": "targets.storylets.card.claims[1].setting", "code": "claim-duplicate" },
247
+ { "path": "targets.storylets.card.claims[2].setting", "code": "claim-setting" },
248
+ { "path": "targets.storylets.card.claims[3].type", "code": "claim-type" },
249
+ { "path": "targets.storylets.card.claims[3].setting", "code": "claim-duplicate" }
250
+ ]
251
+ },
252
+ {
253
+ "name": "settings: named once each, defaulting to a field name",
254
+ "manifest": { "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
255
+ "settings": [{ "name": "a", "default": "x" }, { "name": "a", "default": "y" }, { "name": "b" }, { "name": "c", "default": "not a name" }],
256
+ "targets": { "storylets": { "card": {} } } },
257
+ "problems": [
258
+ { "path": "settings[1].name", "code": "setting-duplicate" },
259
+ { "path": "settings[2].default", "code": "setting-default" },
260
+ { "path": "settings[3].default", "code": "setting-default" }
261
+ ]
262
+ }
263
+ ]
264
+ }
@@ -0,0 +1,154 @@
1
+ {
2
+ "about": "The merge. `tree` cases run mergeTree with a strategy given as data, expecting the merged value and each clash's path and kind. `data` cases run mergeExtensionData over extension shards, with the manifest (or null for none), expecting the merged shard, `unresolved` mark included. A base of {} is a shard that did not exist at the base.",
3
+ "tree": [
4
+ { "name": "atomic: one side's change wins", "strategy": { "kind": "atomic" },
5
+ "base": 1, "ours": 1, "theirs": 2, "merged": 2, "conflicts": [] },
6
+ { "name": "atomic: the same change on both sides is no clash", "strategy": { "kind": "atomic" },
7
+ "base": 1, "ours": 3, "theirs": 3, "merged": 3, "conflicts": [] },
8
+ { "name": "atomic: two different changes clash and keep ours", "strategy": { "kind": "atomic" },
9
+ "base": 1, "ours": 2, "theirs": 3, "merged": 2, "conflicts": [{ "path": "", "kind": "both-changed" }] },
10
+ { "name": "atomic: objects compare by content, not key order", "strategy": { "kind": "atomic" },
11
+ "base": { "a": 1, "b": 2 }, "ours": { "b": 2, "a": 1 }, "theirs": { "a": 1, "b": 3 }, "merged": { "a": 1, "b": 3 }, "conflicts": [] },
12
+ { "name": "map: different keys merge", "strategy": { "kind": "map", "of": { "kind": "atomic" } },
13
+ "base": { "a": 1 }, "ours": { "a": 1, "b": 2 }, "theirs": { "a": 5, "c": 3 }, "merged": { "a": 5, "b": 2, "c": 3 }, "conflicts": [] },
14
+ { "name": "map: a delete against an edit clashes", "strategy": { "kind": "map", "of": { "kind": "atomic" } },
15
+ "base": { "a": 1 }, "ours": {}, "theirs": { "a": 2 }, "merged": {}, "conflicts": [{ "path": ".a", "kind": "delete-vs-edit" }] },
16
+ { "name": "set: additions union, deletions respected", "strategy": { "kind": "set" },
17
+ "base": ["a", "b"], "ours": ["a", "b", "c"], "theirs": ["b", "d"], "merged": ["b", "c", "d"], "conflicts": [] },
18
+ { "name": "keyed, sorted: union by key, each item merged", "strategy": { "kind": "keyed", "key": "id", "of": { "kind": "object", "fields": {} } },
19
+ "base": [{ "id": "b", "n": 1, "m": 1 }],
20
+ "ours": [{ "id": "c" }, { "id": "b", "n": 2, "m": 1 }],
21
+ "theirs": [{ "id": "b", "n": 1, "m": 2 }, { "id": "a" }],
22
+ "merged": [{ "id": "a" }, { "id": "b", "n": 2, "m": 2 }, { "id": "c" }], "conflicts": [] },
23
+ { "name": "keyed: the same key added twice differently clashes", "strategy": { "kind": "keyed", "key": "id", "of": { "kind": "atomic" } },
24
+ "base": [], "ours": [{ "id": "a", "n": 1 }], "theirs": [{ "id": "a", "n": 2 }],
25
+ "merged": [{ "id": "a", "n": 1 }], "conflicts": [{ "path": "[a]", "kind": "added-both" }] },
26
+ { "name": "keyed: a delete against an edit clashes and keeps ours", "strategy": { "kind": "keyed", "key": "id", "of": { "kind": "atomic" } },
27
+ "base": [{ "id": "a", "n": 1 }], "ours": [], "theirs": [{ "id": "a", "n": 2 }],
28
+ "merged": [], "conflicts": [{ "path": "[a]", "kind": "delete-vs-edit" }] },
29
+ { "name": "keyed, preserved: theirs reordered, ours added; the order is theirs with ours's addition after its neighbour",
30
+ "strategy": { "kind": "keyed", "key": "id", "order": "preserve", "of": { "kind": "atomic" } },
31
+ "base": [{ "id": "a" }, { "id": "b" }, { "id": "c" }],
32
+ "ours": [{ "id": "a" }, { "id": "x" }, { "id": "b" }, { "id": "c" }],
33
+ "theirs": [{ "id": "c" }, { "id": "a" }, { "id": "b" }],
34
+ "merged": [{ "id": "c" }, { "id": "a" }, { "id": "x" }, { "id": "b" }], "conflicts": [] },
35
+ { "name": "keyed, preserved: an addition at the front stays at the front",
36
+ "strategy": { "kind": "keyed", "key": "id", "order": "preserve", "of": { "kind": "atomic" } },
37
+ "base": [{ "id": "a" }], "ours": [{ "id": "a" }], "theirs": [{ "id": "z" }, { "id": "a" }],
38
+ "merged": [{ "id": "z" }, { "id": "a" }], "conflicts": [] },
39
+ { "name": "keyed, preserved: both reordered differently clashes and keeps ours",
40
+ "strategy": { "kind": "keyed", "key": "id", "order": "preserve", "of": { "kind": "atomic" } },
41
+ "base": [{ "id": "a" }, { "id": "b" }, { "id": "c" }],
42
+ "ours": [{ "id": "b" }, { "id": "a" }, { "id": "c" }],
43
+ "theirs": [{ "id": "a" }, { "id": "c" }, { "id": "b" }],
44
+ "merged": [{ "id": "b" }, { "id": "a" }, { "id": "c" }], "conflicts": [{ "path": ":order", "kind": "both-changed" }] },
45
+ { "name": "object: named keys by their strategy, others atomic", "strategy": { "kind": "object", "fields": { "tags": { "kind": "set" } } },
46
+ "base": { "tags": ["a"], "n": 1 }, "ours": { "tags": ["a", "b"], "n": 2 }, "theirs": { "tags": ["c"], "n": 1 },
47
+ "merged": { "n": 2, "tags": ["b", "c"] }, "conflicts": [] },
48
+ { "name": "object: other keys take `other` when it is given", "strategy": { "kind": "object", "fields": {}, "other": { "kind": "map", "of": { "kind": "atomic" } } },
49
+ "base": { "k": { "a": 1 } }, "ours": { "k": { "a": 2 } }, "theirs": { "k": { "a": 1, "b": 1 } },
50
+ "merged": { "k": { "a": 2, "b": 1 } }, "conflicts": [] },
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" }] }
53
+ ],
54
+ "manifest": {
55
+ "schema": "wildwinter/extension@0", "id": "probe", "name": "Probe", "version": "1.0.0",
56
+ "targets": { "storylets": {
57
+ "card": { "fields": [
58
+ { "name": "note", "type": "text" },
59
+ { "name": "loud", "type": "boolean" },
60
+ { "name": "marks", "type": "flags", "values": ["a", "b", "c"] },
61
+ { "name": "cue", "type": "record", "fields": [{ "name": "sound", "type": "text" }, { "name": "gain", "type": "number" }] },
62
+ { "name": "perf", "type": "map", "key": { "type": "reference", "to": "tag" }, "of": { "type": "record", "fields": [{ "name": "look", "type": "text" }, { "name": "gesture", "type": "text" }] } },
63
+ { "name": "beats", "type": "list", "id": "id", "of": { "type": "record", "fields": [{ "name": "id", "type": "text" }, { "name": "at", "type": "number" }] } },
64
+ { "name": "words", "type": "list", "of": { "type": "text" } }
65
+ ] }
66
+ } }
67
+ },
68
+ "data": [
69
+ { "name": "different nodes never clash",
70
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } } },
71
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" } } } },
72
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" }, "c_2": { "note": "z" } } } },
73
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" }, "c_2": { "note": "z" } } } } },
74
+ { "name": "different fields of one node merge",
75
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } } },
76
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" } } } },
77
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a", "loud": true } } } },
78
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "loud": true, "note": "b" } } } } },
79
+ { "name": "flags merge value by value",
80
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "marks": ["a"] } } } },
81
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "marks": ["a", "b"] } } } },
82
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "marks": ["c"] } } } },
83
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "marks": ["b", "c"] } } } } },
84
+ { "name": "a record merges field by field",
85
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "cue": { "sound": "bell", "gain": 1 } } } } },
86
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "cue": { "sound": "gong", "gain": 1 } } } } },
87
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "cue": { "sound": "bell", "gain": 2 } } } } },
88
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "cue": { "gain": 2, "sound": "gong" } } } } } },
89
+ { "name": "a map merges key by key, then by the value's type: different characters merge, one character's two looks clash",
90
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "perf": { "v_ann": { "look": "down" }, "v_bo": { "look": "up" } } } } } },
91
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "perf": { "v_ann": { "look": "left" }, "v_bo": { "look": "up", "gesture": "shrug" } } } } } },
92
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "perf": { "v_ann": { "look": "right" }, "v_bo": { "look": "away" } } } } } },
93
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "perf": { "v_ann": { "look": "left" }, "v_bo": { "gesture": "shrug", "look": "away" } } } } },
94
+ "unresolved": [{ "path": "entries.card.c_1.perf.v_ann.look", "kind": "both-changed", "base": "down", "ours": "left", "theirs": "right" }] } },
95
+ { "name": "a list with ids merges by id and keeps the authored order",
96
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "beats": [{ "id": "b2", "at": 1 }, { "id": "b1", "at": 2 }] } } } },
97
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "beats": [{ "id": "b2", "at": 5 }, { "id": "b1", "at": 2 }] } } } },
98
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "beats": [{ "id": "b2", "at": 1 }, { "id": "b1", "at": 2 }, { "id": "b0", "at": 9 }] } } } },
99
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "beats": [{ "id": "b2", "at": 5 }, { "id": "b1", "at": 2 }, { "id": "b0", "at": 9 }] } } } } },
100
+ { "name": "a list without ids merges whole",
101
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "words": ["a"] } } } },
102
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "words": ["a", "b"] } } } },
103
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "words": ["c", "a"] } } } },
104
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "words": ["a", "b"] } } },
105
+ "unresolved": [{ "path": "entries.card.c_1.words", "kind": "both-changed", "base": ["a"], "ours": ["a", "b"], "theirs": ["c", "a"] }] } },
106
+ { "name": "a node's entry deleted on one side while edited on the other clashes, keeps ours, and the mark holds theirs",
107
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } } },
108
+ "ours": { "extension": "probe", "entries": { "card": {} } },
109
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" } } } },
110
+ "merged": { "extension": "probe", "entries": { "card": {} },
111
+ "unresolved": [{ "path": "entries.card.c_1", "kind": "delete-vs-edit", "base": { "note": "a" }, "theirs": { "note": "b" } }] } },
112
+ { "name": "without the manifest, a node's whole entry merges as one value",
113
+ "manifest": null,
114
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" }, "c_2": { "note": "a" } } } },
115
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" }, "c_2": { "note": "a" } } } },
116
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a", "loud": true }, "c_2": { "note": "z" } } } },
117
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" }, "c_2": { "note": "z" } } },
118
+ "unresolved": [{ "path": "entries.card.c_1", "kind": "both-changed", "base": { "note": "a" }, "ours": { "note": "b" }, "theirs": { "loud": true, "note": "a" } }] } },
119
+ { "name": "a kind the manifest does not target merges per node as one value",
120
+ "base": { "extension": "probe", "entries": { "deck": { "k_1": { "x": 1 } } } },
121
+ "ours": { "extension": "probe", "entries": { "deck": { "k_1": { "x": 2 } } } },
122
+ "theirs": { "extension": "probe", "entries": { "deck": { "k_1": { "x": 1 }, "k_2": { "x": 3 } } } },
123
+ "merged": { "extension": "probe", "entries": { "deck": { "k_1": { "x": 2 }, "k_2": { "x": 3 } } } } },
124
+ { "name": "a shard added on both sides merges two-sided",
125
+ "base": {},
126
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } } },
127
+ "theirs": { "extension": "probe", "entries": { "card": { "c_2": { "note": "b" } } } },
128
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" }, "c_2": { "note": "b" } } } } },
129
+ { "name": "a mark already on one side is carried until it is resolved",
130
+ "base": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } } },
131
+ "ours": { "extension": "probe", "entries": { "card": { "c_1": { "note": "a" } } },
132
+ "unresolved": [{ "path": "entries.card.c_9.note", "kind": "both-changed", "ours": "x", "theirs": "y" }] },
133
+ "theirs": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" } } } },
134
+ "merged": { "extension": "probe", "entries": { "card": { "c_1": { "note": "b" } } },
135
+ "unresolved": [{ "path": "entries.card.c_9.note", "kind": "both-changed", "ours": "x", "theirs": "y" }] } },
136
+ { "name": "a mark one side resolved stays resolved",
137
+ "base": { "extension": "probe", "entries": { "card": {} },
138
+ "unresolved": [{ "path": "entries.card.c_9.note", "kind": "both-changed", "ours": "x", "theirs": "y" }] },
139
+ "ours": { "extension": "probe", "entries": { "card": { "c_9": { "note": "x" } } } },
140
+ "theirs": { "extension": "probe", "entries": { "card": {} },
141
+ "unresolved": [{ "path": "entries.card.c_9.note", "kind": "both-changed", "ours": "x", "theirs": "y" }] },
142
+ "merged": { "extension": "probe", "entries": { "card": { "c_9": { "note": "x" } } } } }
143
+ ],
144
+ "unresolved": [
145
+ { "name": "a shard with no mark passes", "shard": { "extension": "probe", "entries": {} }, "problems": [] },
146
+ { "name": "a shard carrying the mark is refused, once per clash",
147
+ "shard": { "extension": "probe", "entries": {}, "unresolved": [
148
+ { "path": "entries.card.c_1.note", "kind": "both-changed", "ours": "a", "theirs": "b" },
149
+ { "path": "entries.card.c_2", "kind": "delete-vs-edit", "theirs": {} }
150
+ ] },
151
+ "problems": [{ "path": "entries.card.c_1.note", "code": "unresolved" }, { "path": "entries.card.c_2", "code": "unresolved" }] },
152
+ { "name": "an empty mark is still a mark", "shard": { "unresolved": [] }, "problems": [{ "path": "unresolved", "code": "unresolved" }] }
153
+ ]
154
+ }