@grampro/kit 2.7.1 → 2.7.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.
Files changed (80) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +233 -207
  2. package/.gbs/skills/gbs-components/references/data-grid.md +9 -0
  3. package/.gbs/skills/gbs-components/references/forms.md +12 -0
  4. package/.gbs/skills/gbs-components/references/install.md +42 -5
  5. package/CHANGELOG.md +963 -798
  6. package/README.md +13 -4
  7. package/index.cjs +250 -138
  8. package/package.json +1 -1
  9. package/source/components/accordion/passport.json +1 -1
  10. package/source/components/alert/passport.json +1 -1
  11. package/source/components/avatar/passport.json +1 -1
  12. package/source/components/badge/passport.json +1 -1
  13. package/source/components/breadcrumb/passport.json +1 -1
  14. package/source/components/button/passport.json +1 -1
  15. package/source/components/card/passport.json +1 -1
  16. package/source/components/checkbox/passport.json +2 -2
  17. package/source/components/checkbox/passport.manual.json +1 -1
  18. package/source/components/checkbox/react/Checkbox.tsx +14 -0
  19. package/source/components/combobox/passport.json +3 -2
  20. package/source/components/combobox/passport.manual.json +1 -1
  21. package/source/components/combobox/react/MultiSelect.tsx +13 -0
  22. package/source/components/combobox/react/Select.tsx +13 -0
  23. package/source/components/combobox/react/surface.ts +70 -0
  24. package/source/components/data-grid/passport.json +6 -6
  25. package/source/components/data-grid/react/DataGrid.tsx +353 -351
  26. package/source/components/date-picker/passport.json +3 -2
  27. package/source/components/date-picker/passport.manual.json +1 -1
  28. package/source/components/date-picker/react/DatePicker.tsx +16 -2
  29. package/source/components/date-picker/react/DateRangePicker.tsx +16 -2
  30. package/source/components/date-picker/react/surface.ts +68 -0
  31. package/source/components/dialog/passport.json +1 -1
  32. package/source/components/file-uploader/passport.json +1 -1
  33. package/source/components/input/passport.json +2 -2
  34. package/source/components/input/passport.manual.json +1 -1
  35. package/source/components/input/react/Input.tsx +21 -0
  36. package/source/components/menu/passport.json +1 -1
  37. package/source/components/modal/passport.json +1 -1
  38. package/source/components/number-input/passport.json +2 -2
  39. package/source/components/number-input/passport.manual.json +1 -1
  40. package/source/components/number-input/react/NumberInput.tsx +30 -0
  41. package/source/components/popover/passport.json +1 -1
  42. package/source/components/progress/passport.json +1 -1
  43. package/source/components/radio-group/passport.json +2 -2
  44. package/source/components/radio-group/passport.manual.json +1 -1
  45. package/source/components/radio-group/react/RadioGroup.tsx +33 -0
  46. package/source/components/shared/core/agent/field-executors.ts +173 -137
  47. package/source/components/shared/core/agent/index.ts +21 -0
  48. package/source/components/shared/core/agent/surface.ts +437 -0
  49. package/source/components/shared/core/agent/webmcp.ts +332 -328
  50. package/source/components/shared/core/agent/write.ts +62 -58
  51. package/source/components/shared/index.ts +7 -0
  52. package/source/components/shared/react/AgentSurface.tsx +145 -0
  53. package/source/components/shared/react/agentSurfaceContext.ts +92 -0
  54. package/source/components/shared/react/useFieldAgent.ts +62 -88
  55. package/source/components/shared/version.json +4 -4
  56. package/source/components/shared/version.ts +6 -6
  57. package/source/components/skeleton/passport.json +1 -1
  58. package/source/components/spinner/passport.json +1 -1
  59. package/source/components/switch/passport.json +2 -2
  60. package/source/components/switch/passport.manual.json +1 -1
  61. package/source/components/switch/react/Switch.tsx +16 -0
  62. package/source/components/tabs/passport.json +1 -1
  63. package/source/components/textarea/passport.json +2 -2
  64. package/source/components/textarea/passport.manual.json +1 -1
  65. package/source/components/textarea/react/Textarea.tsx +16 -0
  66. package/source/components/toaster/passport.json +1 -1
  67. package/source/components/tooltip/passport.json +1 -1
  68. package/tools/doctor/baseline.cjs +228 -0
  69. package/tools/doctor/contract.cjs +252 -0
  70. package/tools/doctor/format.cjs +160 -0
  71. package/tools/doctor/index.cjs +246 -0
  72. package/tools/doctor/usage.cjs +95 -0
  73. package/tools/jsx-tags.cjs +120 -0
  74. package/tools/mcp/server.cjs +60 -41
  75. package/tools/passport/extract.cjs +22 -40
  76. package/tools/passport/hash.cjs +56 -0
  77. package/tools/passport/index.cjs +4 -2
  78. package/tools/passport/typescript.cjs +220 -0
  79. package/tools/prompt.cjs +205 -0
  80. package/tools/ui.cjs +67 -0
@@ -6,7 +6,7 @@
6
6
  "ActionIntent",
7
7
  "Summarization"
8
8
  ],
9
- "$operationsNote": "Mirrors shared/core/agent/field.ts, which is what the runtime dispatches on. source/components/shared/core/agent/__tests__/field-passport.test.ts fails if the two drift. No `apiMethod`: a field is written through its ref — the element for most of these, a handle for Combobox and DatePicker — and that is a normal case, not a gap.",
9
+ "$operationsNote": "Mirrors shared/core/agent/field.ts, which is what the runtime dispatches on. source/components/shared/core/agent/__tests__/field-passport.test.ts fails if the two drift. No `apiMethod`: a field is written through its ref — the element for most of these, a handle for Combobox and DatePicker — and that is a normal case, not a gap. Inside an <AgentSurface> the component registers itself with these operations; NumberInput and RadioGroup are then written through their own commit path rather than the element.",
10
10
  "operations": {
11
11
  "setValue": {
12
12
  "summary": "Write a value into the field, as though a person had entered it.",
@@ -16,6 +16,7 @@ import {
16
16
  } from "react";
17
17
  import { useControllableState } from "../../shared/react/useControllableState";
18
18
  import { describeField } from "../../shared/core/field";
19
+ import { labelText, useAgentSurfaceField } from "../../shared/react/agentSurfaceContext";
19
20
  import { countCharacters } from "../core/count";
20
21
  import { fitHeight } from "../core/size";
21
22
  import type { FieldSize, TextareaLocaleText, TextareaResize } from "../core/types";
@@ -99,6 +100,21 @@ export function Textarea(props: TextareaProps) {
99
100
 
100
101
  // Mirrors the text even when uncontrolled, for the counter and auto-resize.
101
102
  const [current, setCurrent] = useControllableState(valueProp, defaultValue ?? "");
103
+
104
+ // Inside an <AgentSurface>, offered to an agent with what it accepts now.
105
+ useAgentSurfaceField(() => ({
106
+ contract: {
107
+ component: "Textarea",
108
+ kind: "text",
109
+ label: labelText(label, textareaProps["aria-label"], textareaProps.name, textareaProps.placeholder),
110
+ disabled,
111
+ readOnly,
112
+ required,
113
+ maxLength,
114
+ operations: ["setValue", "clear", "focus"],
115
+ },
116
+ target: () => textareaRef.current,
117
+ }));
102
118
  const floor = Math.max(1, minRows ?? rows ?? 3);
103
119
 
104
120
  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  "source": {
14
- "libraryVersion": "2.7.1",
14
+ "libraryVersion": "2.7.3",
15
15
  "files": [
16
16
  "core/api.ts",
17
17
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.7.1",
11
+ "libraryVersion": "2.7.3",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -0,0 +1,228 @@
1
+ /*
2
+ * What a component looked like when it was installed, and whether it still does.
3
+ *
4
+ * Components are copied into a project to be edited — that is the point of
5
+ * copy-in. Until 2.7.2 nothing recorded what was copied, so `add` could not
6
+ * tell a pristine Button from one somebody had spent a week on, and it
7
+ * overwrote both. This is the record.
8
+ *
9
+ * Each installed component gets `.install-manifest.json` beside its files:
10
+ * the kit version it came from, a hash of every file as written, and a
11
+ * compact copy of its contract. The contract is stored here, not just in
12
+ * passport.json, because the `passport` command regenerates passport.json
13
+ * from the project's own source — after which it describes the edits, not the
14
+ * baseline. Nothing rewrites the manifest except installing the component.
15
+ *
16
+ * Components installed before 2.7.2 have no manifest. For those the
17
+ * passport's own `sourceHash` is still usable: it was computed over the
18
+ * kit's files with the kit's path labels, and recomputing it over the
19
+ * project's copy with those same labels says whether anything changed.
20
+ */
21
+ const fs = require("fs");
22
+ const path = require("path");
23
+ const crypto = require("crypto");
24
+ const { componentHash } = require("../passport/hash.cjs");
25
+ const { compactContract } = require("./contract.cjs");
26
+
27
+ const MANIFEST = ".install-manifest.json";
28
+ const PASSPORT = "passport.json";
29
+ const TESTS_DIR = "__tests__";
30
+ const MANIFEST_VERSION = 1;
31
+
32
+ /*
33
+ * passport.json is derived, and the `passport` command regenerates it on
34
+ * purpose. Counting that as an edit would flag every project that ever ran
35
+ * it, so it is left out of change detection; the manifest carries the
36
+ * baseline contract instead.
37
+ */
38
+ const DERIVED = new Set([PASSPORT]);
39
+
40
+ /* The consumer's own overlay, which the CLI never writes. Adding it is not an edit. */
41
+ const OWNED = new Set(["passport.local.json"]);
42
+
43
+ const TEXT = /\.(tsx?|jsx?|mjs|cjs|css|md|json|svg|html?)$/i;
44
+
45
+ /** A file's hash, ignoring line endings for text — autocrlf is not an edit. */
46
+ function hashFile(file) {
47
+ const bytes = fs.readFileSync(file);
48
+ const content = TEXT.test(file) ? bytes.toString("utf8").replace(/\r\n/g, "\n") : bytes;
49
+ return crypto.createHash("sha256").update(content).digest("hex");
50
+ }
51
+
52
+ /** Every file in a component folder, relative and "/"-separated, minus the manifest and tests. */
53
+ function listFiles(dir, prefix = "") {
54
+ if (!fs.existsSync(path.join(dir, prefix))) return [];
55
+ return fs
56
+ .readdirSync(path.join(dir, prefix), { withFileTypes: true })
57
+ .flatMap((entry) => {
58
+ if (entry.name === MANIFEST || entry.name === TESTS_DIR) return [];
59
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
60
+ if (entry.isSymbolicLink()) return []; // never follow a link out of the folder
61
+ return entry.isDirectory() ? listFiles(dir, relative) : [relative];
62
+ })
63
+ .sort();
64
+ }
65
+
66
+ const readJson = (file) => {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(file, "utf8"));
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Record a component as just installed. Hashes are taken from the source the
76
+ * kit copied, so the record describes what the kit wrote, whatever happened
77
+ * to the copy between the copy and this call.
78
+ */
79
+ function writeManifest(destDir, { component, version, sourceDir }) {
80
+ const files = listFiles(sourceDir);
81
+ const manifest = {
82
+ manifestVersion: MANIFEST_VERSION,
83
+ component,
84
+ version,
85
+ installedAt: new Date().toISOString(),
86
+ files: Object.fromEntries(files.map((file) => [file, hashFile(path.join(sourceDir, file))])),
87
+ contract: compactContract(readJson(path.join(sourceDir, PASSPORT))),
88
+ };
89
+ fs.writeFileSync(path.join(destDir, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
90
+ return manifest;
91
+ }
92
+
93
+ /** The passport's sourceHash, recomputed over the project's copy with the kit's labels. */
94
+ function kitRelativeHash(dir, folder, passport) {
95
+ const files = passport?.source?.files;
96
+ if (!Array.isArray(files)) return null;
97
+ const entries = [];
98
+ for (const relative of files) {
99
+ // A path that climbs out of the folder is not one the kit ever wrote.
100
+ if (typeof relative !== "string" || relative.split(/[\\/]/).includes("..")) return null;
101
+ const file = path.join(dir, relative);
102
+ if (!fs.existsSync(file)) return null; // a file the passport lists is gone: not pristine
103
+ entries.push({ rel: relative, file });
104
+ }
105
+ // The generator hashes styles.css alongside the source when there is one.
106
+ const styles = path.join(dir, "styles.css");
107
+ if (fs.existsSync(styles)) entries.push({ rel: "styles.css", file: styles });
108
+ // The generator's own labelling rule, not a copy of it.
109
+ return componentHash(folder, entries);
110
+ }
111
+
112
+ /**
113
+ * Where an installed component stands against what was installed.
114
+ *
115
+ * `state` is one of:
116
+ * pristine every file is as the kit wrote it
117
+ * edited something changed; `changed`/`missing`/`added` say what, when known
118
+ * unknown installed before 2.7.2 and no longer matches what any record
119
+ * describes — it may hold edits, so it is treated as edited
120
+ *
121
+ * @param {string} dir the installed component folder
122
+ * @param {object} options
123
+ * @param {string} options.folder its name, e.g. "date-picker"
124
+ * @param {string} [options.kitDir] the same component in the running CLI, for
125
+ * the case where versions match and the files
126
+ * can be compared directly
127
+ * @param {string} [options.kitVersion]
128
+ */
129
+ function inspect(dir, { folder, kitDir, kitVersion }) {
130
+ const manifest = readJson(path.join(dir, MANIFEST));
131
+
132
+ if (manifest && manifest.files && typeof manifest.files === "object") {
133
+ const present = new Set(listFiles(dir));
134
+ const changed = [];
135
+ const missing = [];
136
+ for (const [file, hash] of Object.entries(manifest.files)) {
137
+ if (DERIVED.has(file)) continue;
138
+ if (!present.has(file)) missing.push(file);
139
+ else if (hashFile(path.join(dir, file)) !== hash) changed.push(file);
140
+ }
141
+ const added = [...present].filter(
142
+ (file) => !(file in manifest.files) && !DERIVED.has(file) && !OWNED.has(file),
143
+ );
144
+ return {
145
+ baseline: "manifest",
146
+ installedVersion: manifest.version || null,
147
+ state: changed.length || missing.length || added.length ? "edited" : "pristine",
148
+ changed,
149
+ missing,
150
+ added,
151
+ // Updating copies over the folder: it never deletes an added file, but it
152
+ // does replace a changed one and restore a deleted one.
153
+ destructive: changed.length > 0 || missing.length > 0,
154
+ baseContract: manifest.contract || null,
155
+ };
156
+ }
157
+
158
+ // Before 2.7.2: no manifest. Is the passport still the one the kit wrote?
159
+ const passport = readJson(path.join(dir, PASSPORT));
160
+ const recorded = passport?.source?.sourceHash;
161
+ const recomputed = recorded ? kitRelativeHash(dir, folder, passport) : null;
162
+ if (recorded && recomputed === recorded) {
163
+ return {
164
+ baseline: "passport",
165
+ installedVersion: passport.source.libraryVersion || null,
166
+ state: "pristine",
167
+ changed: [],
168
+ missing: [],
169
+ added: [],
170
+ destructive: false,
171
+ baseContract: compactContract(passport),
172
+ };
173
+ }
174
+
175
+ /*
176
+ * No record that still applies. Compare with the running CLI's own copy:
177
+ * identical files are pristine at this version. Any difference could be an
178
+ * edit or simply an older version, and there is no telling which — so it is
179
+ * reported as unknown, and `add` will not overwrite it without --force.
180
+ * Refusing once too often costs a flag; overwriting once too often costs
181
+ * someone's work.
182
+ */
183
+ if (kitDir && fs.existsSync(kitDir)) {
184
+ const ours = new Set(listFiles(kitDir).filter((file) => !DERIVED.has(file)));
185
+ const theirs = new Set(listFiles(dir).filter((file) => !DERIVED.has(file) && !OWNED.has(file)));
186
+ const changed = [...ours].filter(
187
+ (file) => theirs.has(file) && hashFile(path.join(dir, file)) !== hashFile(path.join(kitDir, file)),
188
+ );
189
+ const missing = [...ours].filter((file) => !theirs.has(file));
190
+ const added = [...theirs].filter((file) => !ours.has(file));
191
+ if (!changed.length && !missing.length && !added.length) {
192
+ return {
193
+ baseline: "kit",
194
+ installedVersion: kitVersion || null,
195
+ state: "pristine",
196
+ changed,
197
+ missing,
198
+ added,
199
+ destructive: false,
200
+ baseContract: compactContract(readJson(path.join(kitDir, PASSPORT))),
201
+ };
202
+ }
203
+ return {
204
+ baseline: "none",
205
+ installedVersion: passport?.source?.libraryVersion || null,
206
+ state: "unknown",
207
+ // Differences from the running CLI's copy — not necessarily your edits.
208
+ changed,
209
+ missing,
210
+ added,
211
+ destructive: true,
212
+ baseContract: null,
213
+ };
214
+ }
215
+
216
+ return {
217
+ baseline: "none",
218
+ installedVersion: null,
219
+ state: "unknown",
220
+ changed: [],
221
+ missing: [],
222
+ added: [],
223
+ destructive: true,
224
+ baseContract: null,
225
+ };
226
+ }
227
+
228
+ module.exports = { MANIFEST, inspect, writeManifest, listFiles, hashFile, kitRelativeHash };
@@ -0,0 +1,252 @@
1
+ /*
2
+ * What a component promises, reduced to what a caller depends on, and how two
3
+ * of those promises differ.
4
+ *
5
+ * A text diff of two versions of Button.tsx says which lines moved. It cannot
6
+ * say that `variant` stopped accepting "ghost", or that a prop became
7
+ * required, or that the agent runtime lost an operation — and those are the
8
+ * only differences that break someone. The passport already records each of
9
+ * them, so the comparison is done there.
10
+ *
11
+ * Pure: no filesystem, no TypeScript. Everything here takes passports, or the
12
+ * compact contracts made from them, and returns plain data.
13
+ */
14
+
15
+ /** The parts of a passport a caller can depend on. */
16
+ function compactContract(passport) {
17
+ if (!passport || typeof passport !== "object") return null;
18
+ const props = {};
19
+ for (const prop of passport.props || []) {
20
+ if (!prop || typeof prop.name !== "string") continue;
21
+ props[prop.name] = {
22
+ type: prop.type || "",
23
+ typeText: normaliseType(prop.typeText || ""),
24
+ required: Boolean(prop.required),
25
+ origin: prop.origin || "derived",
26
+ ...(prop.default !== undefined ? { default: String(prop.default) } : {}),
27
+ ...(Array.isArray(prop.values) && prop.values.length
28
+ ? { values: prop.values.map(String).sort() }
29
+ : {}),
30
+ };
31
+ }
32
+ const events = {};
33
+ for (const event of passport.events || []) {
34
+ if (event && typeof event.name === "string") events[event.name] = normaliseType(event.signature || "");
35
+ }
36
+ return {
37
+ component: passport.identity?.component || null,
38
+ version: passport.source?.libraryVersion || null,
39
+ exports: [...(passport.identity?.exports || [])].map(String).sort(),
40
+ props,
41
+ events,
42
+ operations: Object.keys(passport.operations || {}).sort(),
43
+ };
44
+ }
45
+
46
+ /** Whitespace is not a type change. */
47
+ const normaliseType = (text) => String(text).replace(/\s+/g, " ").trim();
48
+
49
+ /*
50
+ * Inherited props come from React's DOM types, and whether they appear at all
51
+ * depends on whether @types/react was found when the passport was made. A
52
+ * project without it would report every one of them as "removed" — so when
53
+ * one side has inherited props and the other has none, only the component's
54
+ * own props are compared, and the report says so.
55
+ */
56
+ function comparableProps(before, after) {
57
+ const has = (c) => Object.values(c.props).some((p) => p.origin === "inherited");
58
+ const ownOnly = has(before) !== has(after);
59
+ const pick = (c) =>
60
+ Object.fromEntries(Object.entries(c.props).filter(([, p]) => !ownOnly || p.origin !== "inherited"));
61
+ return { before: pick(before), after: pick(after), ownOnly };
62
+ }
63
+
64
+ /**
65
+ * Every difference between two contracts, oldest first.
66
+ *
67
+ * `severity` is what a caller should take from it:
68
+ * breaking code that worked against `before` may not compile or behave against `after`
69
+ * changed the promise moved, but existing callers keep working
70
+ * added something new, which nobody can be depending on yet
71
+ */
72
+ function diffContracts(before, after) {
73
+ if (!before || !after) return { changes: [], ownPropsOnly: false };
74
+ const changes = [];
75
+ const add = (kind, name, change, severity, detail = {}) =>
76
+ changes.push({ kind, name, change, severity, ...detail });
77
+
78
+ const { before: bp, after: ap, ownOnly } = comparableProps(before, after);
79
+
80
+ for (const [name, was] of Object.entries(bp)) {
81
+ const now = ap[name];
82
+ if (!now) {
83
+ add("prop", name, "removed", "breaking", { from: was.typeText });
84
+ continue;
85
+ }
86
+ if (!was.required && now.required) add("prop", name, "now required", "breaking");
87
+ if (was.required && !now.required) add("prop", name, "now optional", "changed");
88
+
89
+ if (was.values || now.values) {
90
+ const gone = (was.values || []).filter((v) => !(now.values || []).includes(v));
91
+ const fresh = (now.values || []).filter((v) => !(was.values || []).includes(v));
92
+ if (gone.length) add("prop", name, "values removed", "breaking", { values: gone });
93
+ if (fresh.length) add("prop", name, "values added", "added", { values: fresh });
94
+ } else if (was.typeText !== now.typeText) {
95
+ // Without enumerated values there is no telling a widening from a
96
+ // narrowing, so it is reported without claiming either.
97
+ add("prop", name, "type changed", "changed", { from: was.typeText, to: now.typeText });
98
+ }
99
+
100
+ if ((was.default ?? null) !== (now.default ?? null)) {
101
+ add("prop", name, "default changed", "changed", { from: was.default ?? null, to: now.default ?? null });
102
+ }
103
+ }
104
+ for (const [name, now] of Object.entries(ap)) {
105
+ if (bp[name]) continue;
106
+ const type = now.values ? now.values.map((v) => JSON.stringify(v)).join(" | ") : now.typeText.replace(/ \| undefined$/, "");
107
+ add("prop", name, "added", now.required ? "breaking" : "added", { type, ...(now.required ? { required: true } : {}) });
108
+ }
109
+
110
+ for (const [name, signature] of Object.entries(before.events)) {
111
+ if (!(name in after.events)) add("event", name, "removed", "breaking");
112
+ else if (after.events[name] !== signature) {
113
+ add("event", name, "signature changed", "changed", { from: signature, to: after.events[name] });
114
+ }
115
+ }
116
+ for (const name of Object.keys(after.events)) {
117
+ if (!(name in before.events)) add("event", name, "added", "added");
118
+ }
119
+
120
+ for (const name of before.exports) if (!after.exports.includes(name)) add("export", name, "removed", "breaking");
121
+ for (const name of after.exports) if (!before.exports.includes(name)) add("export", name, "added", "added");
122
+
123
+ // An agent that drove this operation yesterday is refused it today.
124
+ for (const name of before.operations) {
125
+ if (!after.operations.includes(name)) add("operation", name, "removed", "breaking");
126
+ }
127
+ for (const name of after.operations) if (!before.operations.includes(name)) add("operation", name, "added", "added");
128
+
129
+ return { changes, ownPropsOnly: ownOnly };
130
+ }
131
+
132
+ const keyOf = (change) => `${change.kind}:${change.name}`;
133
+
134
+ /**
135
+ * Your edits and upstream's, measured from the version you installed, and the
136
+ * places both of you changed the same thing.
137
+ *
138
+ * Without a base, only the difference between your copy and upstream can be
139
+ * reported, with no telling who moved — so it is reported as that, not
140
+ * dressed up as a three-way result.
141
+ */
142
+ function threeWay(base, mine, theirs) {
143
+ if (!base) {
144
+ const differences = diffContracts(theirs, mine);
145
+ return { mode: "two-way", differences: differences.changes, ownPropsOnly: differences.ownPropsOnly };
146
+ }
147
+ const yours = diffContracts(base, mine);
148
+ const upstream = diffContracts(base, theirs);
149
+
150
+ const group = (changes) => {
151
+ const by = new Map();
152
+ for (const change of changes) by.set(keyOf(change), [...(by.get(keyOf(change)) || []), change]);
153
+ return by;
154
+ };
155
+ const ours = group(yours.changes);
156
+ const conflicts = [];
157
+ const merges = [];
158
+
159
+ for (const [key, upstreamChanges] of group(upstream.changes)) {
160
+ const ownChanges = ours.get(key);
161
+ if (!ownChanges) continue;
162
+ const { kind, name } = upstreamChanges[0];
163
+ const verdict = reconcile(kind, name, ownChanges, upstreamChanges, { base, mine, theirs });
164
+ if (verdict.conflict) conflicts.push({ kind, name, yours: verdict.yours, upstream: verdict.upstream });
165
+ else if (verdict.merged) merges.push({ kind, name, ...verdict.merged });
166
+ }
167
+
168
+ return {
169
+ mode: "three-way",
170
+ yours: yours.changes,
171
+ upstream: upstream.changes,
172
+ conflicts,
173
+ merges,
174
+ ownPropsOnly: yours.ownPropsOnly || upstream.ownPropsOnly,
175
+ };
176
+ }
177
+
178
+ const describeAll = (changes) => [...new Set(changes.map((c) => c.change))].join(", ");
179
+
180
+ /*
181
+ * Two sides touched the same entry. Whether that is a conflict depends on
182
+ * what each did to it, not merely on both having done something:
183
+ *
184
+ * - you added "ghost"-less values while upstream added "link": value sets
185
+ * combine, so that merges, and the merged set is reported;
186
+ * - one side removed the prop while the other changed it: a conflict, since
187
+ * no combination keeps both intentions;
188
+ * - both changed the type, the default, or the required-ness, to different
189
+ * results: a conflict;
190
+ * - both made the identical change: agreement, not reported at all.
191
+ */
192
+ function reconcile(kind, name, ownChanges, upstreamChanges, { base, mine, theirs }) {
193
+ const kinds = (changes) => new Set(changes.map((c) => c.change));
194
+ const own = kinds(ownChanges);
195
+ const up = kinds(upstreamChanges);
196
+ const conflict = () => ({ conflict: true, yours: describeAll(ownChanges), upstream: describeAll(upstreamChanges) });
197
+
198
+ if (kind !== "prop") {
199
+ // Exports and operations either exist or do not, so both sides changing one
200
+ // means both made the same change. Events can diverge in signature.
201
+ if (kind === "event" && own.has("signature changed") && up.has("signature changed")) {
202
+ return mine.events[name] === theirs.events[name] ? {} : conflict();
203
+ }
204
+ if (own.has("removed") !== up.has("removed")) return conflict();
205
+ return {};
206
+ }
207
+
208
+ const minePropNow = mine.props[name];
209
+ const theirsPropNow = theirs.props[name];
210
+
211
+ // One side deleted it, the other kept and changed it.
212
+ if (own.has("removed") !== up.has("removed")) return conflict();
213
+ if (own.has("removed") && up.has("removed")) return {};
214
+
215
+ // Both added a prop of the same name: fine only if they agree on what it is.
216
+ if (own.has("added") && up.has("added")) {
217
+ return JSON.stringify(minePropNow) === JSON.stringify(theirsPropNow) ? {} : conflict();
218
+ }
219
+
220
+ if (minePropNow?.typeText !== theirsPropNow?.typeText && own.has("type changed") && up.has("type changed")) {
221
+ return conflict();
222
+ }
223
+ if (own.has("default changed") && up.has("default changed") && minePropNow?.default !== theirsPropNow?.default) {
224
+ return conflict();
225
+ }
226
+ if ((own.has("now required") && up.has("now optional")) || (own.has("now optional") && up.has("now required"))) {
227
+ return conflict();
228
+ }
229
+
230
+ // Value sets merge: start from the base, apply both sides' removals and additions.
231
+ const valueEdits = (s) => s.has("values added") || s.has("values removed");
232
+ if (valueEdits(own) && valueEdits(up)) {
233
+ const was = new Set(base.props[name]?.values || []);
234
+ const mineValues = new Set(minePropNow?.values || []);
235
+ const theirValues = new Set(theirsPropNow?.values || []);
236
+ const merged = new Set(was);
237
+ for (const v of was) if (!mineValues.has(v) || !theirValues.has(v)) merged.delete(v);
238
+ for (const v of mineValues) if (!was.has(v)) merged.add(v);
239
+ for (const v of theirValues) if (!was.has(v)) merged.add(v);
240
+ return { merged: { values: [...merged].sort() } };
241
+ }
242
+ return {};
243
+ }
244
+
245
+ /**
246
+ * Names a caller can write on this component's JSX: props and handlers. What
247
+ * your copy accepts and upstream does not is exactly what replacing yours with
248
+ * upstream would take away from your call sites.
249
+ */
250
+ const acceptedNames = (contract) => new Set([...Object.keys(contract.props), ...Object.keys(contract.events)]);
251
+
252
+ module.exports = { compactContract, diffContracts, threeWay, acceptedNames };