@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
@@ -0,0 +1,246 @@
1
+ /*
2
+ * `grampro-kit doctor` — what has changed in the components you installed,
3
+ * what has changed upstream, and what an update would break.
4
+ *
5
+ * Copy-in means you own the code, which means you will edit it, which means
6
+ * you leave the upgrade path the moment you do. A text diff against upstream
7
+ * shows which lines moved but not what breaks. This compares contracts — the
8
+ * props, values, handlers, exports and agent operations each component
9
+ * promises — between the version you installed, your copy, and the version
10
+ * this CLI carries, and then finds the call sites in your code that depend on
11
+ * the difference.
12
+ *
13
+ * Three levels, each needing more than the last:
14
+ * files always — from the install manifest, no TypeScript needed
15
+ * contracts needs TypeScript in the project, to read your edited copy
16
+ * impact scans your source for the components' JSX
17
+ *
18
+ * Read-only. It never writes a file.
19
+ */
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const { inspect } = require("./baseline.cjs");
23
+ const { compactContract, threeWay, acceptedNames } = require("./contract.cjs");
24
+ const { scanUsage } = require("./usage.cjs");
25
+
26
+ const PASSPORT = "passport.json";
27
+ const SHARED = "shared";
28
+
29
+ const readJson = (file) => {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(file, "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const normalise = (name) => String(name).toLowerCase().replace(/[^a-z0-9]/g, "");
38
+
39
+ /** -1, 0 or 1 for dotted versions; unknown sorts as oldest. */
40
+ function compareVersions(a, b) {
41
+ if (!a) return -1;
42
+ const left = String(a).split(/[.-]/).map((n) => parseInt(n, 10) || 0);
43
+ const right = String(b).split(/[.-]/).map((n) => parseInt(n, 10) || 0);
44
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
45
+ const diff = (left[i] || 0) - (right[i] || 0);
46
+ if (diff !== 0) return diff > 0 ? 1 : -1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ /** Component folders installed in `dest` that this CLI also knows. */
52
+ function installedFolders(dest, kitRoot) {
53
+ if (!fs.existsSync(dest)) return [];
54
+ const known = new Set(
55
+ fs.readdirSync(kitRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name),
56
+ );
57
+ return fs
58
+ .readdirSync(dest, { withFileTypes: true })
59
+ .filter((e) => e.isDirectory() && e.name !== SHARED && known.has(e.name))
60
+ .map((e) => e.name)
61
+ .sort();
62
+ }
63
+
64
+ /** "Button", "datepicker", "date-picker" or "MultiSelect" → the folder that holds it. */
65
+ function folderFor(name, kitRoot, folders) {
66
+ const wanted = normalise(name);
67
+ for (const folder of folders) {
68
+ if (normalise(folder) === wanted) return folder;
69
+ const passport = readJson(path.join(kitRoot, folder, PASSPORT));
70
+ const names = [passport?.identity?.component, ...(passport?.identity?.exports || [])].filter(Boolean);
71
+ if (names.some((n) => normalise(n) === wanted)) return folder;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Read the project's own copies as contracts.
78
+ *
79
+ * Uses the same generator the kit uses on itself, pointed at the project, so
80
+ * "your contract" means exactly what the kit would have written had it shipped
81
+ * your edits. Needs TypeScript resolvable from the project; when it is not,
82
+ * the report says so and stays at the file level instead of guessing.
83
+ */
84
+ function readProjectContracts(dest, cwd) {
85
+ let passport;
86
+ try {
87
+ passport = require("../passport/index.cjs");
88
+ } catch (error) {
89
+ return { error: `Passport tooling unavailable: ${error.message}` };
90
+ }
91
+ try {
92
+ const { results, reactTypes } = passport.buildAll({
93
+ libRoot: dest,
94
+ repoRoot: cwd,
95
+ libraryVersion: "local",
96
+ includeLocal: false,
97
+ });
98
+ const contracts = new Map();
99
+ for (const result of results) contracts.set(result.name, compactContract(result.effective));
100
+ return { contracts, reactTypes: Boolean(reactTypes) };
101
+ } catch (error) {
102
+ if (error && (error.code === "ETYPESCRIPT_MISSING" || error.code === "ETYPESCRIPT_API")) {
103
+ // The loader's own message says which case this is and the exact fix.
104
+ return {
105
+ error: `Contracts not compared — file-level changes only. ${error.message.replace(/\n\s*/g, " ")}`,
106
+ };
107
+ }
108
+ return { error: `Could not read your components as contracts: ${error.message}` };
109
+ }
110
+ }
111
+
112
+ /** Props and handlers an attribute list uses that `lost` takes away. */
113
+ const brokenProps = (props, lost) =>
114
+ props.filter((p) => lost.has(p) && !p.startsWith("data-") && !p.startsWith("aria-") && p !== "key");
115
+
116
+ /**
117
+ * @param {object} options
118
+ * @param {string} options.cwd project root
119
+ * @param {string} options.dest the installed component library, e.g. <root>/component-lib
120
+ * @param {string} options.kitRoot this CLI's source/components
121
+ * @param {string} options.kitVersion this CLI's version
122
+ * @param {string[]} [options.only] component names to limit the report to
123
+ * @param {boolean} [options.contracts=true] read edited copies as contracts (needs TypeScript)
124
+ * @param {boolean} [options.impact=true] scan the project for call sites
125
+ */
126
+ function runDoctor(options) {
127
+ const { cwd, dest, kitRoot, kitVersion, only, contracts = true, impact = true } = options;
128
+ const installed = installedFolders(dest, kitRoot);
129
+
130
+ let folders = installed;
131
+ const notInstalled = [];
132
+ if (only && only.length) {
133
+ folders = [];
134
+ for (const name of only) {
135
+ const folder = folderFor(name, kitRoot, installed);
136
+ if (folder) {
137
+ if (!folders.includes(folder)) folders.push(folder);
138
+ } else notInstalled.push(name);
139
+ }
140
+ }
141
+
142
+ const rows = folders.map((folder) => {
143
+ const dir = path.join(dest, folder);
144
+ const kitDir = path.join(kitRoot, folder);
145
+ const kitPassport = readJson(path.join(kitDir, PASSPORT));
146
+ const theirs = compactContract(kitPassport);
147
+ const status = inspect(dir, { folder, kitDir, kitVersion });
148
+ const behind = compareVersions(status.installedVersion, kitVersion) < 0;
149
+ return {
150
+ folder,
151
+ component: theirs?.component || folder,
152
+ names: [theirs?.component, ...(theirs?.exports || [])].filter(Boolean),
153
+ ...status,
154
+ kitVersion,
155
+ behind,
156
+ theirs,
157
+ };
158
+ });
159
+
160
+ // Contracts for edited copies only: a pristine copy's contract is its baseline.
161
+ let contractNote = null;
162
+ let reactTypes = true;
163
+ const needContracts = contracts && rows.some((row) => row.state !== "pristine");
164
+ if (needContracts) {
165
+ const read = readProjectContracts(dest, cwd);
166
+ if (read.error) contractNote = read.error;
167
+ else {
168
+ reactTypes = read.reactTypes;
169
+ for (const row of rows) {
170
+ if (row.state !== "pristine") row.mine = read.contracts.get(row.folder) || null;
171
+ }
172
+ }
173
+ }
174
+
175
+ for (const row of rows) {
176
+ const mine = row.state === "pristine" ? row.baseContract : row.mine;
177
+ if (row.state !== "pristine" && mine && row.theirs) {
178
+ row.comparison = threeWay(row.baseContract, mine, row.theirs);
179
+ } else if (row.state === "pristine" && row.behind && row.baseContract && row.theirs) {
180
+ // Untouched but behind: what upstream changed since you installed.
181
+ row.comparison = threeWay(row.baseContract, row.baseContract, row.theirs);
182
+ }
183
+ // Everything your copy accepts that upstream would not: what an update takes away.
184
+ if (mine && row.theirs) {
185
+ const keep = acceptedNames(row.theirs);
186
+ row.lost = new Set([...acceptedNames(mine)].filter((name) => !keep.has(name)));
187
+ row.lostExports = mine.exports.filter((name) => !row.theirs.exports.includes(name));
188
+ }
189
+ }
190
+
191
+ // One walk of the project for every component that has something to lose.
192
+ let truncated = false;
193
+ const relevant = rows.filter((row) => (row.lost && row.lost.size) || (row.lostExports && row.lostExports.length));
194
+ if (impact && relevant.length) {
195
+ const names = [...new Set(relevant.flatMap((row) => [...row.names, ...(row.lostExports || [])]))];
196
+ const scan = scanUsage(cwd, names, { skip: [dest] });
197
+ truncated = scan.truncated;
198
+ for (const row of relevant) {
199
+ const mine = new Set([...row.names, ...(row.lostExports || [])]);
200
+ row.breaks = scan.usages
201
+ .filter((use) => mine.has(use.component))
202
+ .map((use) => ({
203
+ ...use,
204
+ props: (row.lostExports || []).includes(use.component)
205
+ ? use.props
206
+ : brokenProps(use.props, row.lost || new Set()),
207
+ lostExport: (row.lostExports || []).includes(use.component),
208
+ }))
209
+ .filter((use) => use.lostExport || use.props.length > 0);
210
+ }
211
+ }
212
+
213
+ for (const row of rows) row.verdict = verdictOf(row);
214
+
215
+ return {
216
+ kitVersion,
217
+ dest,
218
+ rows,
219
+ notInstalled,
220
+ contractNote,
221
+ reactTypes,
222
+ truncated,
223
+ };
224
+ }
225
+
226
+ /** One line on what to do, for a person and for an agent reading the JSON. */
227
+ function verdictOf(row) {
228
+ const breaks = row.breaks?.length || 0;
229
+ const conflicts = row.comparison?.conflicts?.length || 0;
230
+ if (row.state === "pristine") {
231
+ if (!row.behind) return { status: "up-to-date", action: null };
232
+ return {
233
+ status: breaks ? "update-breaks-callers" : "update-available",
234
+ action: `npx @grampro/kit add ${row.component}`,
235
+ };
236
+ }
237
+ return {
238
+ status: conflicts ? "conflict" : breaks ? "update-breaks-callers" : row.state === "unknown" ? "unverified" : "edited",
239
+ // Never suggested as the fix: it discards the user's work, so it is only
240
+ // ever presented as the choice it is.
241
+ action: null,
242
+ force: `npx @grampro/kit add ${row.component} --force`,
243
+ };
244
+ }
245
+
246
+ module.exports = { runDoctor, compareVersions, installedFolders, folderFor };
@@ -0,0 +1,95 @@
1
+ /*
2
+ * Where a project uses the components, and with which props.
3
+ *
4
+ * Answers the one question a contract diff cannot answer alone: "does this
5
+ * change break *me*?" A removed prop nobody passes is a footnote; the same
6
+ * prop passed in forty places is the whole upgrade.
7
+ *
8
+ * Bounded on purpose. It walks the user's tree, so it skips everything that
9
+ * is not their source (dependencies, build output, the component library
10
+ * itself), refuses to follow symlinks, and stops at a file count and a file
11
+ * size rather than trusting the tree to be reasonable.
12
+ */
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { jsxOpenings } = require("../jsx-tags.cjs");
16
+
17
+ const SOURCE = /\.(tsx|jsx|ts|js|mdx)$/i;
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules",
20
+ ".git",
21
+ ".next",
22
+ ".nuxt",
23
+ ".svelte-kit",
24
+ ".turbo",
25
+ ".vercel",
26
+ ".cache",
27
+ "dist",
28
+ "build",
29
+ "out",
30
+ "coverage",
31
+ "storybook-static",
32
+ ]);
33
+ const MAX_FILES = 20000;
34
+ const MAX_BYTES = 1024 * 1024;
35
+
36
+ /**
37
+ * @param {string} root the project root
38
+ * @param {string[]} names component names to look for, e.g. ["Button", "Select"]
39
+ * @param {{ skip?: string[] }} [options] extra absolute directories to skip (the component library)
40
+ * @returns {{ usages: { component: string, file: string, line: number, props: string[] }[], truncated: boolean }}
41
+ */
42
+ function scanUsage(root, names, options = {}) {
43
+ const wanted = new Set(names);
44
+ const skip = new Set((options.skip || []).map((dir) => path.resolve(dir)));
45
+ const usages = [];
46
+ let files = 0;
47
+ let truncated = false;
48
+
49
+ const walk = (dir) => {
50
+ if (truncated) return;
51
+ let entries;
52
+ try {
53
+ entries = fs.readdirSync(dir, { withFileTypes: true });
54
+ } catch {
55
+ return; // unreadable: not ours to report on
56
+ }
57
+ for (const entry of entries) {
58
+ if (truncated) return;
59
+ if (entry.isSymbolicLink()) continue;
60
+ const full = path.join(dir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ if (SKIP_DIRS.has(entry.name) || skip.has(path.resolve(full))) continue;
63
+ walk(full);
64
+ continue;
65
+ }
66
+ if (!SOURCE.test(entry.name) || /\.d\.ts$/.test(entry.name)) continue;
67
+ if (++files > MAX_FILES) {
68
+ truncated = true;
69
+ return;
70
+ }
71
+ let text;
72
+ try {
73
+ if (fs.statSync(full).size > MAX_BYTES) continue;
74
+ text = fs.readFileSync(full, "utf8");
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (!text.includes("<")) continue;
79
+ for (const tag of jsxOpenings(text)) {
80
+ if (!wanted.has(tag.name)) continue;
81
+ usages.push({
82
+ component: tag.name,
83
+ file: path.relative(root, full).split(path.sep).join("/"),
84
+ line: tag.line,
85
+ props: tag.attrs,
86
+ });
87
+ }
88
+ }
89
+ };
90
+
91
+ walk(path.resolve(root));
92
+ return { usages, truncated };
93
+ }
94
+
95
+ module.exports = { scanUsage };
@@ -0,0 +1,120 @@
1
+ /*
2
+ * Find the opening tags of capitalised JSX elements, and the attribute names
3
+ * on each — without a parser.
4
+ *
5
+ * Used by `validate_usage`, which checks a fragment an agent just wrote, and by
6
+ * `doctor`, which walks a whole project to find the call sites an upgrade
7
+ * would break. Tolerant on purpose: both are given code that may not compile,
8
+ * and the question is only "which props does this element receive".
9
+ *
10
+ * Linear in the input. Each character is visited a bounded number of times
11
+ * and no regular expression here can backtrack, which matters because
12
+ * `validate_usage` takes arbitrary text from a model (this was checked under
13
+ * load in a VAPT pass: a megabyte of pathological input returns in ~70ms).
14
+ */
15
+
16
+ // A fresh RegExp per call: a shared global one carries `lastIndex` between
17
+ // callers, which is a bug waiting for the first time two scans interleave.
18
+ const OPENING_SOURCE = "<([A-Z][A-Za-z0-9]*)(?=[\\s/>])";
19
+
20
+ /**
21
+ * Attribute names at the top level of a tag's attribute text.
22
+ *
23
+ * Words inside `{...}` and inside quotes are not attributes. The previous
24
+ * version matched names anywhere in the tag, so `title={ok ? a : b}` reported
25
+ * `a` and `b` as attributes — a false "not a prop" from validate_usage, and,
26
+ * worse, a false "your code uses a removed prop" from doctor.
27
+ */
28
+ function attributeNames(raw) {
29
+ const names = [];
30
+ let depth = 0;
31
+ let quote = null;
32
+ let i = 0;
33
+ while (i < raw.length) {
34
+ const ch = raw[i];
35
+ if (quote) {
36
+ if (ch === quote) quote = null;
37
+ i += 1;
38
+ continue;
39
+ }
40
+ if (ch === '"' || ch === "'" || ch === "`") {
41
+ quote = ch;
42
+ i += 1;
43
+ continue;
44
+ }
45
+ if (ch === "{") {
46
+ depth += 1;
47
+ i += 1;
48
+ continue;
49
+ }
50
+ if (ch === "}") {
51
+ depth = Math.max(0, depth - 1);
52
+ i += 1;
53
+ continue;
54
+ }
55
+ // A name starts at depth 0, after whitespace or the start of the text.
56
+ if (depth === 0 && /[a-zA-Z]/.test(ch) && (i === 0 || /\s/.test(raw[i - 1]))) {
57
+ let j = i + 1;
58
+ while (j < raw.length && /[a-zA-Z0-9:_-]/.test(raw[j])) j += 1;
59
+ // Followed by `=`, whitespace or the end: an attribute, or a boolean one.
60
+ let k = j;
61
+ while (k < raw.length && /\s/.test(raw[k])) k += 1;
62
+ if (k >= raw.length || raw[k] === "=" || k > j || raw[k] === "/") names.push(raw.slice(i, j));
63
+ i = j;
64
+ continue;
65
+ }
66
+ i += 1;
67
+ }
68
+ return names;
69
+ }
70
+
71
+ /**
72
+ * Every capitalised opening tag in `source`.
73
+ *
74
+ * @returns {{ name: string, index: number, line: number, attrs: string[] }[]}
75
+ */
76
+ function jsxOpenings(source) {
77
+ const text = String(source || "");
78
+ const found = [];
79
+ // Matches arrive in order, so the line count is carried forward rather than
80
+ // recounted from the top for each tag — recounting is quadratic, and this
81
+ // runs on untrusted input.
82
+ let lineAt = 0;
83
+ let line = 1;
84
+ const OPENING = new RegExp(OPENING_SOURCE, "g");
85
+ let match;
86
+ while ((match = OPENING.exec(text))) {
87
+ for (; lineAt < match.index; lineAt += 1) if (text.charCodeAt(lineAt) === 10) line += 1;
88
+ /*
89
+ * Scan to the tag's own `>` rather than matching one. An attribute can
90
+ * hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
91
+ * usually inside a brace, and a regex that stops there finds nothing.
92
+ */
93
+ let depth = 0;
94
+ let quote = null;
95
+ let end = -1;
96
+ for (let i = match.index + match[0].length; i < text.length; i += 1) {
97
+ const ch = text[i];
98
+ if (quote) {
99
+ if (ch === quote) quote = null;
100
+ } else if (ch === '"' || ch === "'" || ch === "`") {
101
+ quote = ch;
102
+ } else if (ch === "{") {
103
+ depth += 1;
104
+ } else if (ch === "}") {
105
+ depth -= 1;
106
+ } else if (ch === ">" && depth === 0) {
107
+ end = i;
108
+ break;
109
+ }
110
+ }
111
+ if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
112
+
113
+ let raw = text.slice(match.index + match[0].length, end).trimEnd();
114
+ if (raw.endsWith("/")) raw = raw.slice(0, -1); // self-closing
115
+ found.push({ name: match[1], index: match.index, line, attrs: attributeNames(raw) });
116
+ }
117
+ return found;
118
+ }
119
+
120
+ module.exports = { jsxOpenings, attributeNames };
@@ -24,6 +24,7 @@ const fs = require("fs");
24
24
  const path = require("path");
25
25
  const { execFileSync } = require("child_process");
26
26
  const { figmaFrame } = require("./figma.cjs");
27
+ const { jsxOpenings } = require("../jsx-tags.cjs");
27
28
 
28
29
  const CATALOGUE = path.join(__dirname, "..", "..", "source", "components");
29
30
  const REFERENCES = path.join(__dirname, "..", "..", ".gbs", "skills", "gbs-components", "references");
@@ -241,43 +242,10 @@ function validateUsage(code) {
241
242
 
242
243
  const issues = [];
243
244
  const used = [];
244
- const opening = /<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
245
- let match;
246
-
247
- while ((match = opening.exec(source))) {
248
- const [, name] = match;
245
+ // Shared with `doctor`, which scans whole projects with the same rules.
246
+ for (const { name, attrs } of jsxOpenings(source)) {
249
247
  const entry = known.get(name);
250
248
  if (!entry) continue; // Not ours; the agent may compose with its own components.
251
-
252
- /*
253
- * Scan to the tag's own `>` rather than matching one. An attribute can
254
- * hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
255
- * usually inside a brace, and a regex that stops there finds nothing.
256
- */
257
- let depth = 0;
258
- let quote = null;
259
- let end = -1;
260
- for (let i = match.index + match[0].length; i < source.length; i += 1) {
261
- const ch = source[i];
262
- if (quote) {
263
- if (ch === quote) quote = null;
264
- } else if (ch === '"' || ch === "'" || ch === "`") {
265
- quote = ch;
266
- } else if (ch === "{") {
267
- depth += 1;
268
- } else if (ch === "}") {
269
- depth -= 1;
270
- } else if (ch === ">" && depth === 0) {
271
- end = i;
272
- break;
273
- }
274
- }
275
- if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
276
-
277
- const rawAttrs = source.slice(match.index + match[0].length, end);
278
- const attrs = [...rawAttrs.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s*=|\s|$)/g)].map(
279
- (m) => m[1],
280
- );
281
249
  used.push({ component: name, props: attrs });
282
250
 
283
251
  for (const attr of attrs) {
@@ -340,18 +308,57 @@ function installComponents(names) {
340
308
  }
341
309
 
342
310
  const cli = path.join(__dirname, "..", "..", "index.cjs");
311
+ let stdout = "";
312
+ let stderr = "";
313
+ let failed = false;
343
314
  try {
344
- const output = execFileSync(process.execPath, [cli, "add", ...wanted], {
315
+ stdout = execFileSync(process.execPath, [cli, "add", ...wanted], {
345
316
  cwd: process.cwd(),
346
317
  encoding: "utf8",
347
318
  timeout: 120000,
319
+ // Captured, not forwarded: the installer explains a refusal on stderr,
320
+ // and that explanation is what the agent most needs to read.
321
+ stdio: ["ignore", "pipe", "pipe"],
348
322
  });
349
- // The installer prints the one manual step; the agent needs to see it.
350
- const importLine = (output.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
351
- return { installed: wanted, stylesheet: importLine, output: output.trim().slice(-1200) };
352
323
  } catch (error) {
353
- return { error: `Install failed: ${(error.stdout || error.message || "").toString().slice(-600)}` };
324
+ failed = true;
325
+ stdout = (error.stdout || "").toString();
326
+ stderr = (error.stderr || "").toString();
327
+ if (!stdout && !stderr) return { error: `Install failed: ${error.message}` };
354
328
  }
329
+
330
+ // The installer prints the one manual step; the agent needs to see it.
331
+ const importLine = (stdout.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
332
+ const done = [...stdout.matchAll(/✓ Component (\S+) /g)].map((m) => m[1]);
333
+
334
+ /*
335
+ * A component the project has edited is left alone by `add`, and this tool
336
+ * never passes --force. That is deliberate: replacing someone's edited
337
+ * component is their decision, and an agent asked to "install Button" has
338
+ * not been asked to discard the Button they already customised. So the
339
+ * agent is told what was kept, why, and how the user can see the changes —
340
+ * not handed a generic failure it might try to work around.
341
+ */
342
+ const kept = [...stderr.matchAll(/✗ (\S+) was not replaced/g)].map((m) => m[1]);
343
+ if (kept.length) {
344
+ return {
345
+ installed: done,
346
+ kept,
347
+ reason:
348
+ "These components have changes made in this project since they were installed, and " +
349
+ "install_component never overwrites them. They are still in place and still usable.",
350
+ advice:
351
+ "Do not try to replace them. Tell the user, and suggest " +
352
+ `\`npx @grampro/kit doctor ${kept.join(" ")}\`: it shows what was changed locally, what the ` +
353
+ "new version changes, and which call sites an update would break. Replacing them with " +
354
+ "--force discards the user's work, which is their decision to make.",
355
+ detail: stderr.trim().slice(-1500),
356
+ stylesheet: importLine,
357
+ };
358
+ }
359
+
360
+ if (failed) return { error: `Install failed: ${(stderr || stdout).trim().slice(-600)}` };
361
+ return { installed: wanted, stylesheet: importLine, output: stdout.trim().slice(-1200) };
355
362
  }
356
363
 
357
364
  const TOOLS = [
@@ -14,9 +14,9 @@
14
14
 
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
- const crypto = require("crypto");
18
17
  const { allowlistFor } = require("./policy.cjs");
19
18
  const { describable } = require("./merge.cjs");
19
+ const { hashEntries } = require("./hash.cjs");
20
20
 
21
21
  const PASSPORT_VERSION = "1.0.0";
22
22
 
@@ -27,23 +27,56 @@ const PASSPORT_VERSION = "1.0.0";
27
27
  * the runtime dependency story. The caller's project is searched first, so a
28
28
  * consumer regenerating a forked component uses their own TypeScript.
29
29
  */
30
+ /*
31
+ * TypeScript 7 is a native rewrite and its npm package does not expose the
32
+ * compiler API this generator is built on — `createProgram`, `JsxEmit` and the
33
+ * rest are simply absent. It is also what `npm i -D typescript` installs now,
34
+ * so a current project has a `typescript` that loads fine and then fails on
35
+ * the first enum it is asked for, with an error about `ReactJSX` that says
36
+ * nothing useful.
37
+ *
38
+ * So a candidate is only accepted if it actually has the API. After the
39
+ * project's own `typescript`, a TypeScript 6 installed under an alias is
40
+ * tried: it sits beside a TypeScript 7 build without replacing it.
41
+ */
42
+ const TS6_ALIASES = ["typescript-6", "typescript6"];
43
+ const hasCompilerApi = (ts) => Boolean(ts && typeof ts.createProgram === "function" && ts.JsxEmit);
44
+
30
45
  function loadTypeScript(searchFrom = []) {
31
46
  const paths = [...searchFrom, process.cwd(), __dirname].filter(Boolean);
32
- try {
33
- return require(require.resolve("typescript", { paths }));
34
- } catch {
47
+ const tryLoad = (name) => {
35
48
  try {
36
- return require("typescript");
49
+ return require(require.resolve(name, { paths }));
37
50
  } catch {
38
- const error = new Error(
39
- "Passport generation needs the TypeScript compiler API.\n" +
40
- " Install it as a dev dependency: npm i -D typescript\n" +
41
- " It is only needed to generate passports, never to run a component.",
42
- );
43
- error.code = "ETYPESCRIPT_MISSING";
44
- throw error;
51
+ return null;
45
52
  }
53
+ };
54
+
55
+ const primary = tryLoad("typescript");
56
+ if (hasCompilerApi(primary)) return primary;
57
+ for (const alias of TS6_ALIASES) {
58
+ const candidate = tryLoad(alias);
59
+ if (hasCompilerApi(candidate)) return candidate;
46
60
  }
61
+
62
+ if (primary) {
63
+ const error = new Error(
64
+ `This project's TypeScript (${primary.version || "unknown version"}) does not provide the compiler API that ` +
65
+ "passports are read with: TypeScript 7 is a native rewrite without it.\n" +
66
+ " Install TypeScript 6 alongside it, under another name, so your build keeps using 7:\n" +
67
+ " npm i -D typescript-6@npm:typescript@6\n" +
68
+ " It is only used to read component contracts, never to build or run a component.",
69
+ );
70
+ error.code = "ETYPESCRIPT_API";
71
+ throw error;
72
+ }
73
+ const error = new Error(
74
+ "Passport generation needs the TypeScript compiler API.\n" +
75
+ " Install it as a dev dependency: npm i -D typescript-6@npm:typescript@6\n" +
76
+ " It is only needed to read component contracts, never to run a component.",
77
+ );
78
+ error.code = "ETYPESCRIPT_MISSING";
79
+ throw error;
47
80
  }
48
81
 
49
82
  const posix = (p) => p.replace(/\\/g, "/");
@@ -74,14 +107,8 @@ function sourceFiles(dir) {
74
107
  * how the file was checked out.
75
108
  */
76
109
  function hashSources(files, root) {
77
- const h = crypto.createHash("sha256");
78
- for (const f of [...files].sort()) {
79
- h.update(posix(path.relative(root, f)));
80
- h.update("\0");
81
- h.update(fs.readFileSync(f, "utf8").replace(/\r\n/g, "\n"));
82
- h.update("\0");
83
- }
84
- return `sha256:${h.digest("hex")}`;
110
+ // One implementation, shared with the installer: see hash.cjs for why.
111
+ return hashEntries(files.map((file) => ({ label: posix(path.relative(root, file)), file })));
85
112
  }
86
113
 
87
114
  /* ------------------------------------------------------------------ program */