@grampro/kit 2.7.0 → 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 (43) 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 +87 -0
  4. package/README.md +9 -2
  5. package/index.cjs +148 -21
  6. package/package.json +2 -2
  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/README.md +23 -0
  41. package/tools/mcp/server.cjs +63 -40
  42. package/tools/passport/extract.cjs +47 -20
  43. package/tools/passport/hash.cjs +39 -0
@@ -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");
@@ -197,7 +198,23 @@ function getGuidance(topic) {
197
198
  ? fs.readdirSync(REFERENCES).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
198
199
  : [];
199
200
  if (!topic) return { topics: available };
201
+
202
+ /*
203
+ * `topic` is caller-controlled, and `path.join(REFERENCES, topic + ".md")`
204
+ * followed a `../` out of the references folder: a prompt-injected agent
205
+ * could turn this into "read any .md file on disk". The list is already an
206
+ * allowlist of what actually ships, so a topic that is not one of those
207
+ * names is refused before any path is built. The resolved-prefix check is
208
+ * the belt to that braces — it also stops a name that readdir would never
209
+ * produce, such as one reaching through a symlink.
210
+ */
211
+ if (!available.includes(topic)) {
212
+ return { error: `No guidance for "${topic}".`, topics: available };
213
+ }
200
214
  const file = path.join(REFERENCES, `${topic}.md`);
215
+ if (!path.resolve(file).startsWith(path.resolve(REFERENCES) + path.sep)) {
216
+ return { error: `No guidance for "${topic}".`, topics: available };
217
+ }
201
218
  if (!fs.existsSync(file)) return { error: `No guidance for "${topic}".`, topics: available };
202
219
  return { topic, text: fs.readFileSync(file, "utf8") };
203
220
  }
@@ -225,43 +242,10 @@ function validateUsage(code) {
225
242
 
226
243
  const issues = [];
227
244
  const used = [];
228
- const opening = /<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
229
- let match;
230
-
231
- while ((match = opening.exec(source))) {
232
- const [, name] = match;
245
+ // Shared with `doctor`, which scans whole projects with the same rules.
246
+ for (const { name, attrs } of jsxOpenings(source)) {
233
247
  const entry = known.get(name);
234
248
  if (!entry) continue; // Not ours; the agent may compose with its own components.
235
-
236
- /*
237
- * Scan to the tag's own `>` rather than matching one. An attribute can
238
- * hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
239
- * usually inside a brace, and a regex that stops there finds nothing.
240
- */
241
- let depth = 0;
242
- let quote = null;
243
- let end = -1;
244
- for (let i = match.index + match[0].length; i < source.length; i += 1) {
245
- const ch = source[i];
246
- if (quote) {
247
- if (ch === quote) quote = null;
248
- } else if (ch === '"' || ch === "'" || ch === "`") {
249
- quote = ch;
250
- } else if (ch === "{") {
251
- depth += 1;
252
- } else if (ch === "}") {
253
- depth -= 1;
254
- } else if (ch === ">" && depth === 0) {
255
- end = i;
256
- break;
257
- }
258
- }
259
- if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
260
-
261
- const rawAttrs = source.slice(match.index + match[0].length, end);
262
- const attrs = [...rawAttrs.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s*=|\s|$)/g)].map(
263
- (m) => m[1],
264
- );
265
249
  used.push({ component: name, props: attrs });
266
250
 
267
251
  for (const attr of attrs) {
@@ -324,18 +308,57 @@ function installComponents(names) {
324
308
  }
325
309
 
326
310
  const cli = path.join(__dirname, "..", "..", "index.cjs");
311
+ let stdout = "";
312
+ let stderr = "";
313
+ let failed = false;
327
314
  try {
328
- const output = execFileSync(process.execPath, [cli, "add", ...wanted], {
315
+ stdout = execFileSync(process.execPath, [cli, "add", ...wanted], {
329
316
  cwd: process.cwd(),
330
317
  encoding: "utf8",
331
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"],
332
322
  });
333
- // The installer prints the one manual step; the agent needs to see it.
334
- const importLine = (output.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
335
- return { installed: wanted, stylesheet: importLine, output: output.trim().slice(-1200) };
336
323
  } catch (error) {
337
- 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}` };
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
+ };
338
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) };
339
362
  }
340
363
 
341
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 */
@@ -0,0 +1,39 @@
1
+ /*
2
+ * The content hash a passport records as `source.sourceHash`.
3
+ *
4
+ * Kept on its own, with no TypeScript and no extractor behind it, because two
5
+ * very different callers need the identical function: the passport generator,
6
+ * which writes the hash, and the installer, which has to recompute it inside a
7
+ * user's project to tell an untouched component from an edited one. If those
8
+ * two ever disagreed by a byte, every installed component would read as
9
+ * edited, and `add` would refuse to update any of them.
10
+ *
11
+ * Entries are hashed by label, not by where the file sits on disk. The
12
+ * generator labels a file by its path inside this repository
13
+ * (`source/components/button/react/Button.tsx`); the installer reads the same
14
+ * file from `component-lib/button/react/Button.tsx` and labels it the same
15
+ * way. Sorting by that label — rather than by absolute path, as this used to —
16
+ * also makes the order independent of whether the OS separator is `/` or `\`.
17
+ */
18
+ const fs = require("fs");
19
+ const crypto = require("crypto");
20
+
21
+ /**
22
+ * @param {{ label: string, file: string }[]} entries
23
+ * @returns {string} "sha256:<hex>"
24
+ */
25
+ function hashEntries(entries) {
26
+ const h = crypto.createHash("sha256");
27
+ const sorted = [...entries].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
28
+ for (const { label, file } of sorted) {
29
+ h.update(label);
30
+ h.update("\0");
31
+ // Line endings normalised: the same file is CRLF on a Windows checkout
32
+ // and LF on CI, and a content hash must not depend on how it was checked out.
33
+ h.update(fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n"));
34
+ h.update("\0");
35
+ }
36
+ return `sha256:${h.digest("hex")}`;
37
+ }
38
+
39
+ module.exports = { hashEntries };