@grampro/kit 2.7.1 → 2.7.2

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 (42) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +4 -2
  2. package/.gbs/skills/gbs-components/references/install.md +28 -2
  3. package/CHANGELOG.md +63 -0
  4. package/README.md +2 -1
  5. package/index.cjs +148 -21
  6. package/package.json +1 -1
  7. package/source/components/accordion/passport.json +1 -1
  8. package/source/components/alert/passport.json +1 -1
  9. package/source/components/avatar/passport.json +1 -1
  10. package/source/components/badge/passport.json +1 -1
  11. package/source/components/breadcrumb/passport.json +1 -1
  12. package/source/components/button/passport.json +1 -1
  13. package/source/components/card/passport.json +1 -1
  14. package/source/components/checkbox/passport.json +1 -1
  15. package/source/components/combobox/passport.json +1 -1
  16. package/source/components/data-grid/passport.json +1 -1
  17. package/source/components/date-picker/passport.json +1 -1
  18. package/source/components/dialog/passport.json +1 -1
  19. package/source/components/file-uploader/passport.json +1 -1
  20. package/source/components/input/passport.json +1 -1
  21. package/source/components/menu/passport.json +1 -1
  22. package/source/components/modal/passport.json +1 -1
  23. package/source/components/number-input/passport.json +1 -1
  24. package/source/components/popover/passport.json +1 -1
  25. package/source/components/progress/passport.json +1 -1
  26. package/source/components/radio-group/passport.json +1 -1
  27. package/source/components/skeleton/passport.json +1 -1
  28. package/source/components/spinner/passport.json +1 -1
  29. package/source/components/switch/passport.json +1 -1
  30. package/source/components/tabs/passport.json +1 -1
  31. package/source/components/textarea/passport.json +1 -1
  32. package/source/components/toaster/passport.json +1 -1
  33. package/source/components/tooltip/passport.json +1 -1
  34. package/tools/doctor/baseline.cjs +227 -0
  35. package/tools/doctor/contract.cjs +252 -0
  36. package/tools/doctor/format.cjs +160 -0
  37. package/tools/doctor/index.cjs +246 -0
  38. package/tools/doctor/usage.cjs +95 -0
  39. package/tools/jsx-tags.cjs +120 -0
  40. package/tools/mcp/server.cjs +47 -40
  41. package/tools/passport/extract.cjs +47 -20
  42. package/tools/passport/hash.cjs +39 -0
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "source": {
15
- "libraryVersion": "2.7.1",
15
+ "libraryVersion": "2.7.2",
16
16
  "files": [
17
17
  "core/index.ts",
18
18
  "core/navigation.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.7.1",
11
+ "libraryVersion": "2.7.2",
12
12
  "files": [
13
13
  "core/agent.ts",
14
14
  "core/count.ts",
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  "source": {
14
- "libraryVersion": "2.7.1",
14
+ "libraryVersion": "2.7.2",
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.2",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -0,0 +1,227 @@
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 { hashEntries } = 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({ label: `source/components/${folder}/${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({ label: `source/components/${folder}/styles.css`, file: styles });
108
+ return hashEntries(entries);
109
+ }
110
+
111
+ /**
112
+ * Where an installed component stands against what was installed.
113
+ *
114
+ * `state` is one of:
115
+ * pristine every file is as the kit wrote it
116
+ * edited something changed; `changed`/`missing`/`added` say what, when known
117
+ * unknown installed before 2.7.2 and no longer matches what any record
118
+ * describes — it may hold edits, so it is treated as edited
119
+ *
120
+ * @param {string} dir the installed component folder
121
+ * @param {object} options
122
+ * @param {string} options.folder its name, e.g. "date-picker"
123
+ * @param {string} [options.kitDir] the same component in the running CLI, for
124
+ * the case where versions match and the files
125
+ * can be compared directly
126
+ * @param {string} [options.kitVersion]
127
+ */
128
+ function inspect(dir, { folder, kitDir, kitVersion }) {
129
+ const manifest = readJson(path.join(dir, MANIFEST));
130
+
131
+ if (manifest && manifest.files && typeof manifest.files === "object") {
132
+ const present = new Set(listFiles(dir));
133
+ const changed = [];
134
+ const missing = [];
135
+ for (const [file, hash] of Object.entries(manifest.files)) {
136
+ if (DERIVED.has(file)) continue;
137
+ if (!present.has(file)) missing.push(file);
138
+ else if (hashFile(path.join(dir, file)) !== hash) changed.push(file);
139
+ }
140
+ const added = [...present].filter(
141
+ (file) => !(file in manifest.files) && !DERIVED.has(file) && !OWNED.has(file),
142
+ );
143
+ return {
144
+ baseline: "manifest",
145
+ installedVersion: manifest.version || null,
146
+ state: changed.length || missing.length || added.length ? "edited" : "pristine",
147
+ changed,
148
+ missing,
149
+ added,
150
+ // Updating copies over the folder: it never deletes an added file, but it
151
+ // does replace a changed one and restore a deleted one.
152
+ destructive: changed.length > 0 || missing.length > 0,
153
+ baseContract: manifest.contract || null,
154
+ };
155
+ }
156
+
157
+ // Before 2.7.2: no manifest. Is the passport still the one the kit wrote?
158
+ const passport = readJson(path.join(dir, PASSPORT));
159
+ const recorded = passport?.source?.sourceHash;
160
+ const recomputed = recorded ? kitRelativeHash(dir, folder, passport) : null;
161
+ if (recorded && recomputed === recorded) {
162
+ return {
163
+ baseline: "passport",
164
+ installedVersion: passport.source.libraryVersion || null,
165
+ state: "pristine",
166
+ changed: [],
167
+ missing: [],
168
+ added: [],
169
+ destructive: false,
170
+ baseContract: compactContract(passport),
171
+ };
172
+ }
173
+
174
+ /*
175
+ * No record that still applies. Compare with the running CLI's own copy:
176
+ * identical files are pristine at this version. Any difference could be an
177
+ * edit or simply an older version, and there is no telling which — so it is
178
+ * reported as unknown, and `add` will not overwrite it without --force.
179
+ * Refusing once too often costs a flag; overwriting once too often costs
180
+ * someone's work.
181
+ */
182
+ if (kitDir && fs.existsSync(kitDir)) {
183
+ const ours = new Set(listFiles(kitDir).filter((file) => !DERIVED.has(file)));
184
+ const theirs = new Set(listFiles(dir).filter((file) => !DERIVED.has(file) && !OWNED.has(file)));
185
+ const changed = [...ours].filter(
186
+ (file) => theirs.has(file) && hashFile(path.join(dir, file)) !== hashFile(path.join(kitDir, file)),
187
+ );
188
+ const missing = [...ours].filter((file) => !theirs.has(file));
189
+ const added = [...theirs].filter((file) => !ours.has(file));
190
+ if (!changed.length && !missing.length && !added.length) {
191
+ return {
192
+ baseline: "kit",
193
+ installedVersion: kitVersion || null,
194
+ state: "pristine",
195
+ changed,
196
+ missing,
197
+ added,
198
+ destructive: false,
199
+ baseContract: compactContract(readJson(path.join(kitDir, PASSPORT))),
200
+ };
201
+ }
202
+ return {
203
+ baseline: "none",
204
+ installedVersion: passport?.source?.libraryVersion || null,
205
+ state: "unknown",
206
+ // Differences from the running CLI's copy — not necessarily your edits.
207
+ changed,
208
+ missing,
209
+ added,
210
+ destructive: true,
211
+ baseContract: null,
212
+ };
213
+ }
214
+
215
+ return {
216
+ baseline: "none",
217
+ installedVersion: null,
218
+ state: "unknown",
219
+ changed: [],
220
+ missing: [],
221
+ added: [],
222
+ destructive: true,
223
+ baseContract: null,
224
+ };
225
+ }
226
+
227
+ 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 };
@@ -0,0 +1,160 @@
1
+ /*
2
+ * The doctor report, for a person. Agents get the same data as --json.
3
+ *
4
+ * Ordered by what someone has to act on: conflicts and broken call sites
5
+ * first, then edits, then updates available, then everything that is fine —
6
+ * summarised in a single line, because a list of twenty-four "up to date"
7
+ * rows buries the three that matter.
8
+ */
9
+
10
+ const SIGN = { breaking: "!", changed: "~", added: "+" };
11
+ const RANK = { conflict: 0, "update-breaks-callers": 1, unverified: 2, edited: 3, "update-available": 4, "up-to-date": 5 };
12
+
13
+ function describeChange(change) {
14
+ const sign = SIGN[change.severity] || " ";
15
+ // "+ prop tone "info" | "warn"" reads as what it is; "added" repeated would not.
16
+ if (change.change === "added") {
17
+ return `${sign} ${change.kind} ${change.name}${change.type ? ` ${short(change.type)}` : ""}${change.required ? " (required)" : ""}`;
18
+ }
19
+ let text = `${sign} ${change.kind} ${change.name}: ${change.change}`;
20
+ if (change.values) text += ` ${change.values.map((v) => JSON.stringify(v)).join(", ")}`;
21
+ if (change.from !== undefined && change.to !== undefined) {
22
+ text += ` (${short(change.from)} → ${short(change.to)})`;
23
+ }
24
+ return text;
25
+ }
26
+
27
+ const short = (value) => {
28
+ const text = value === null ? "none" : String(value);
29
+ return text.length > 48 ? `${text.slice(0, 45)}...` : text;
30
+ };
31
+
32
+ function headline(row) {
33
+ switch (row.verdict.status) {
34
+ case "up-to-date":
35
+ return `up to date · ${row.kitVersion}`;
36
+ case "update-available":
37
+ return `untouched · ${row.installedVersion || "older version"} → ${row.kitVersion} available, safe to update`;
38
+ case "update-breaks-callers":
39
+ if (row.state === "pristine") return `untouched · ${row.kitVersion} available, but it breaks call sites below`;
40
+ return row.behind
41
+ ? `your changes · taking ${row.kitVersion} would break call sites below`
42
+ : `your changes · reverting to ${row.kitVersion} would break call sites below`;
43
+ case "conflict":
44
+ return `your changes · conflict with ${row.kitVersion}`;
45
+ case "unverified":
46
+ return "may contain your changes · installed before 2.7.2, so there is no record to compare against";
47
+ default:
48
+ return row.behind
49
+ ? `your changes · installed ${row.installedVersion || "?"}, ${row.kitVersion} available`
50
+ : `your changes · on ${row.installedVersion || row.kitVersion}`;
51
+ }
52
+ }
53
+
54
+ function formatReport(report) {
55
+ const lines = [];
56
+ const pad = (label) => label.padEnd(12);
57
+ lines.push(`Doctor · ${report.dest} · kit ${report.kitVersion}`);
58
+ lines.push("");
59
+
60
+ const rows = [...report.rows].sort(
61
+ (a, b) => RANK[a.verdict.status] - RANK[b.verdict.status] || a.component.localeCompare(b.component),
62
+ );
63
+ const quiet = rows.filter((row) => row.verdict.status === "up-to-date");
64
+ const loud = rows.filter((row) => row.verdict.status !== "up-to-date");
65
+
66
+ for (const row of loud) {
67
+ lines.push(` ${row.component.padEnd(14)}${headline(row)}`);
68
+
69
+ const files = [];
70
+ if (row.changed.length) files.push(`changed ${row.changed.join(", ")}`);
71
+ if (row.missing.length) files.push(`deleted ${row.missing.join(", ")}`);
72
+ if (row.added.length) files.push(`added ${row.added.join(", ")}`);
73
+ if (files.length) {
74
+ const label = row.state === "unknown" ? "vs current" : "files";
75
+ lines.push(` ${pad(label)}${files.join(" · ")}`);
76
+ }
77
+
78
+ const cmp = row.comparison;
79
+ if (cmp?.mode === "three-way") {
80
+ if (row.state !== "pristine") {
81
+ lines.push(` ${pad("yours")}${cmp.yours.length ? describeChange(cmp.yours[0]) : "no contract changes (implementation only)"}`);
82
+ for (const change of cmp.yours.slice(1)) lines.push(` ${pad("")}${describeChange(change)}`);
83
+ }
84
+ if (row.behind) {
85
+ lines.push(` ${pad("upstream")}${cmp.upstream.length ? describeChange(cmp.upstream[0]) : "no contract changes"}`);
86
+ for (const change of cmp.upstream.slice(1)) lines.push(` ${pad("")}${describeChange(change)}`);
87
+ }
88
+ for (const [i, c] of cmp.conflicts.entries()) {
89
+ lines.push(` ${pad(i === 0 ? "conflict" : "")}${c.kind} ${c.name}: you ${c.yours}; upstream ${c.upstream}`);
90
+ }
91
+ // Both sides changed it, compatibly: say what the combination is.
92
+ for (const [i, m] of (cmp.merges || []).entries()) {
93
+ const values = m.values ? m.values.map((v) => JSON.stringify(v)).join(" | ") : "";
94
+ lines.push(` ${pad(i === 0 ? "merges" : "")}${m.kind} ${m.name} → ${values} (both edits combine)`);
95
+ }
96
+ } else if (cmp?.mode === "two-way") {
97
+ lines.push(` ${pad("contract")}${cmp.differences.length ? describeChange(cmp.differences[0]) : "same as current"}`);
98
+ for (const change of cmp.differences.slice(1)) lines.push(` ${pad("")}${describeChange(change)}`);
99
+ }
100
+
101
+ if (row.breaks?.length) {
102
+ for (const [i, use] of row.breaks.slice(0, 8).entries()) {
103
+ const what = use.lostExport ? `<${use.component}> (no longer exported)` : `<${use.component} ${use.props.join(" ")}>`;
104
+ lines.push(` ${pad(i === 0 ? "would break" : "")}${use.file}:${use.line} ${what}`);
105
+ }
106
+ if (row.breaks.length > 8) lines.push(` ${pad("")}…and ${row.breaks.length - 8} more`);
107
+ }
108
+
109
+ if (row.verdict.action) {
110
+ lines.push(` ${pad("update")}${row.verdict.action}`);
111
+ } else if (row.verdict.force && row.behind) {
112
+ lines.push(` ${pad("choices")}keep your copy (nothing to do), or replace it with ${row.kitVersion}`);
113
+ lines.push(` ${pad("")}and lose your changes: ${row.verdict.force}`);
114
+ }
115
+ lines.push("");
116
+ }
117
+
118
+ if (quiet.length) {
119
+ lines.push(` Up to date: ${quiet.map((row) => row.component).join(", ")}`);
120
+ lines.push("");
121
+ }
122
+
123
+ for (const name of report.notInstalled) lines.push(` ${name}: not installed here`);
124
+ if (report.contractNote) lines.push(` Note: ${report.contractNote}`);
125
+ if (!report.reactTypes) {
126
+ lines.push(" Note: @types/react was not found, so inherited DOM props were left out of the comparison.");
127
+ }
128
+ if (report.truncated) lines.push(" Note: stopped scanning after 20,000 source files; call sites past that were not checked.");
129
+
130
+ const edited = rows.filter((row) => row.state !== "pristine").length;
131
+ const conflicts = rows.reduce((n, row) => n + (row.comparison?.conflicts?.length || 0), 0);
132
+ const breaks = rows.reduce((n, row) => n + (row.breaks?.length || 0), 0);
133
+ lines.push(
134
+ `${rows.length} component(s) · ${edited} with your changes · ${conflicts} conflict(s) · ${breaks} call site(s) at risk`,
135
+ );
136
+ return lines.join("\n");
137
+ }
138
+
139
+ /* JSON for agents and CI: the report minus Sets and the bulky contracts. */
140
+ function toJson(report) {
141
+ return {
142
+ kitVersion: report.kitVersion,
143
+ notInstalled: report.notInstalled,
144
+ note: report.contractNote,
145
+ components: report.rows.map((row) => ({
146
+ component: row.component,
147
+ folder: row.folder,
148
+ state: row.state,
149
+ baseline: row.baseline,
150
+ installedVersion: row.installedVersion,
151
+ behind: row.behind,
152
+ files: { changed: row.changed, deleted: row.missing, added: row.added },
153
+ comparison: row.comparison || null,
154
+ breaks: row.breaks || [],
155
+ verdict: row.verdict,
156
+ })),
157
+ };
158
+ }
159
+
160
+ module.exports = { formatReport, toJson };