@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.
- package/.gbs/skills/gbs-components/SKILL.md +4 -2
- package/.gbs/skills/gbs-components/references/install.md +28 -2
- package/CHANGELOG.md +87 -0
- package/README.md +9 -2
- package/index.cjs +148 -21
- package/package.json +2 -2
- package/source/components/accordion/passport.json +1 -1
- package/source/components/alert/passport.json +1 -1
- package/source/components/avatar/passport.json +1 -1
- package/source/components/badge/passport.json +1 -1
- package/source/components/breadcrumb/passport.json +1 -1
- package/source/components/button/passport.json +1 -1
- package/source/components/card/passport.json +1 -1
- package/source/components/checkbox/passport.json +1 -1
- package/source/components/combobox/passport.json +1 -1
- package/source/components/data-grid/passport.json +1 -1
- package/source/components/date-picker/passport.json +1 -1
- package/source/components/dialog/passport.json +1 -1
- package/source/components/file-uploader/passport.json +1 -1
- package/source/components/input/passport.json +1 -1
- package/source/components/menu/passport.json +1 -1
- package/source/components/modal/passport.json +1 -1
- package/source/components/number-input/passport.json +1 -1
- package/source/components/popover/passport.json +1 -1
- package/source/components/progress/passport.json +1 -1
- package/source/components/radio-group/passport.json +1 -1
- package/source/components/skeleton/passport.json +1 -1
- package/source/components/spinner/passport.json +1 -1
- package/source/components/switch/passport.json +1 -1
- package/source/components/tabs/passport.json +1 -1
- package/source/components/textarea/passport.json +1 -1
- package/source/components/toaster/passport.json +1 -1
- package/source/components/tooltip/passport.json +1 -1
- package/tools/doctor/baseline.cjs +227 -0
- package/tools/doctor/contract.cjs +252 -0
- package/tools/doctor/format.cjs +160 -0
- package/tools/doctor/index.cjs +246 -0
- package/tools/doctor/usage.cjs +95 -0
- package/tools/jsx-tags.cjs +120 -0
- package/tools/mcp/README.md +23 -0
- package/tools/mcp/server.cjs +63 -40
- package/tools/passport/extract.cjs +47 -20
- package/tools/passport/hash.cjs +39 -0
package/tools/mcp/server.cjs
CHANGED
|
@@ -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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
return require(require.resolve("typescript", { paths }));
|
|
34
|
-
} catch {
|
|
47
|
+
const tryLoad = (name) => {
|
|
35
48
|
try {
|
|
36
|
-
return require(
|
|
49
|
+
return require(require.resolve(name, { paths }));
|
|
37
50
|
} catch {
|
|
38
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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 };
|