@grampro/kit 2.4.3 → 2.5.0

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 (73) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +12 -0
  3. package/index.cjs +44 -2
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +11 -7
  6. package/source/components/accordion/react/Accordion.tsx +5 -0
  7. package/source/components/alert/passport.json +3 -3
  8. package/source/components/avatar/passport.json +8 -6
  9. package/source/components/avatar/react/Avatar.tsx +4 -0
  10. package/source/components/badge/passport.json +8 -6
  11. package/source/components/badge/react/Badge.tsx +1 -0
  12. package/source/components/badge/react/Tag.tsx +1 -0
  13. package/source/components/breadcrumb/passport.json +8 -6
  14. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  15. package/source/components/button/passport.json +8 -6
  16. package/source/components/button/react/Button.tsx +1 -0
  17. package/source/components/card/passport.json +17 -9
  18. package/source/components/card/react/Card.tsx +5 -0
  19. package/source/components/card/react/Stat.tsx +5 -0
  20. package/source/components/checkbox/passport.json +32 -14
  21. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  22. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  23. package/source/components/combobox/passport.json +36 -16
  24. package/source/components/combobox/react/Select.tsx +6 -1
  25. package/source/components/combobox/react/props.ts +23 -0
  26. package/source/components/data-grid/core/types.ts +53 -0
  27. package/source/components/data-grid/passport.json +82 -30
  28. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  29. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  30. package/source/components/date-picker/passport.json +30 -14
  31. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  32. package/source/components/date-picker/react/props.ts +13 -1
  33. package/source/components/dialog/core/types.ts +14 -0
  34. package/source/components/dialog/passport.json +26 -12
  35. package/source/components/dialog/react/Dialog.tsx +4 -0
  36. package/source/components/file-uploader/passport.json +44 -18
  37. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  38. package/source/components/input/passport.json +14 -8
  39. package/source/components/input/react/Input.tsx +3 -0
  40. package/source/components/menu/passport.json +29 -13
  41. package/source/components/menu/react/Menu.tsx +7 -0
  42. package/source/components/menu/react/items.tsx +4 -0
  43. package/source/components/modal/passport.json +14 -8
  44. package/source/components/modal/react/Modal.tsx +6 -0
  45. package/source/components/number-input/passport.json +23 -11
  46. package/source/components/number-input/react/NumberInput.tsx +13 -0
  47. package/source/components/popover/passport.json +17 -9
  48. package/source/components/popover/react/Popover.tsx +7 -0
  49. package/source/components/progress/passport.json +3 -3
  50. package/source/components/radio-group/passport.json +32 -14
  51. package/source/components/radio-group/react/Radio.tsx +17 -0
  52. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  53. package/source/components/skeleton/passport.json +8 -6
  54. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  55. package/source/components/spinner/passport.json +3 -3
  56. package/source/components/switch/passport.json +23 -11
  57. package/source/components/switch/react/Switch.tsx +14 -0
  58. package/source/components/tabs/passport.json +11 -7
  59. package/source/components/tabs/react/Tabs.tsx +2 -0
  60. package/source/components/textarea/passport.json +14 -8
  61. package/source/components/textarea/react/Textarea.tsx +3 -0
  62. package/source/components/toaster/passport.json +8 -6
  63. package/source/components/toaster/react/Toaster.tsx +4 -0
  64. package/source/components/tooltip/passport.json +3 -3
  65. package/tools/mcp/README.md +224 -0
  66. package/tools/mcp/figma.cjs +445 -0
  67. package/tools/mcp/install.cjs +270 -0
  68. package/tools/mcp/secret-cli.cjs +174 -0
  69. package/tools/mcp/secret.cjs +289 -0
  70. package/tools/mcp/server.cjs +497 -0
  71. package/tools/passport/cli.cjs +8 -1
  72. package/tools/passport/extract.cjs +7 -3
  73. package/tools/passport/merge.cjs +24 -4
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * An MCP server over the component catalogue.
4
+ *
5
+ * A coding agent asked to build a page has two ways to learn an API: read the
6
+ * source, which costs a lot of context and invites invention, or be told. This
7
+ * tells it — one component at a time, from the passport that was generated
8
+ * from the source and cannot drift from it.
9
+ *
10
+ * It also marks its own homework. `validate_usage` checks written JSX against
11
+ * the passports, which catches the failure that matters: a prop the model
12
+ * remembered from somewhere rather than read here. That was the only mistake
13
+ * in the experiment that justified building this — every type, union value and
14
+ * handler signature the passport carried came out right, and the one error was
15
+ * an invented prop.
16
+ *
17
+ * No dependencies on purpose. MCP is newline-delimited JSON-RPC over stdio,
18
+ * which is a few hundred lines, and a server that ships inside a library every
19
+ * consumer installs should not drag an SDK along for the ride.
20
+ */
21
+ "use strict";
22
+
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const { execFileSync } = require("child_process");
26
+ const { figmaFrame } = require("./figma.cjs");
27
+
28
+ const CATALOGUE = path.join(__dirname, "..", "..", "source", "components");
29
+ const REFERENCES = path.join(__dirname, "..", "..", ".gbs", "skills", "gbs-components", "references");
30
+
31
+ /** The newest protocol revision this server knows. Older clients get theirs back. */
32
+ const PROTOCOL_VERSION = "2025-06-18";
33
+ const SUPPORTED_PROTOCOLS = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]);
34
+
35
+ // --------------------------------------------------------------- catalogue
36
+
37
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
38
+
39
+ /*
40
+ * The catalogue, read once.
41
+ *
42
+ * Every lookup used to re-read and re-parse passports off disk: naming one
43
+ * component scanned until it matched, so `get_component("DataGrid")` cost ten
44
+ * file reads, and `validate_usage` parsed all twenty-seven on every call. The
45
+ * catalogue ships inside the package and cannot change while the server runs,
46
+ * so it is built once on first use and kept.
47
+ */
48
+ let index = null;
49
+
50
+ function catalogue() {
51
+ if (index) return index;
52
+
53
+ const folders = fs
54
+ .readdirSync(CATALOGUE, { withFileTypes: true })
55
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(CATALOGUE, e.name, "passport.json")))
56
+ .map((e) => e.name)
57
+ .sort();
58
+
59
+ const byFolder = new Map();
60
+ const byName = new Map();
61
+
62
+ for (const folder of folders) {
63
+ // The one place that still touches disk — this is what builds the index.
64
+ const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
65
+ const entry = { folder, passport };
66
+ byFolder.set(folder, entry);
67
+
68
+ // Every spelling an agent might reasonably use.
69
+ const spellings = [folder, passport.identity.component, ...(passport.identity.exports || [])];
70
+ for (const spelling of spellings) {
71
+ byName.set(String(spelling).toLowerCase().replace(/[^a-z0-9]/g, ""), entry);
72
+ }
73
+ }
74
+
75
+ index = { folders, byFolder, byName };
76
+ return index;
77
+ }
78
+
79
+ const componentFolders = () => catalogue().folders;
80
+
81
+ /** Resolve a name the way an agent would write it: "DataGrid", "data-grid", "datagrid". */
82
+ function findFolder(name) {
83
+ const entry = catalogue().byName.get(
84
+ String(name || "").toLowerCase().replace(/[^a-z0-9]/g, ""),
85
+ );
86
+ return entry ? entry.folder : null;
87
+ }
88
+
89
+ /** The parsed passport for a folder, from the index rather than from disk. */
90
+ const passportOf = (folder) => catalogue().byFolder.get(folder).passport;
91
+
92
+ /*
93
+ * Most passports carry no `purpose`, so the README's opening line stands in.
94
+ * It is the sentence a human wrote about what the component is for, which is
95
+ * exactly what an agent needs to pick one.
96
+ */
97
+ const purposeCache = new Map();
98
+
99
+ function purposeOf(folder, passport) {
100
+ if (passport.purpose && passport.purpose.trim()) return passport.purpose.trim();
101
+ if (purposeCache.has(folder)) return purposeCache.get(folder);
102
+ const readme = path.join(CATALOGUE, folder, "README.md");
103
+ if (!fs.existsSync(readme)) return "";
104
+ const line = fs
105
+ .readFileSync(readme, "utf8")
106
+ .split("\n")
107
+ .map((l) => l.trim())
108
+ .find((l) => l && !l.startsWith("#") && !l.startsWith("<!--"));
109
+ const purpose = line ? line.replace(/\s+/g, " ") : "";
110
+ purposeCache.set(folder, purpose);
111
+ return purpose;
112
+ }
113
+
114
+ // -------------------------------------------------------------------- tools
115
+
116
+ function listComponents() {
117
+ return componentFolders().map((folder) => {
118
+ const passport = passportOf(folder);
119
+ return {
120
+ component: passport.identity.component,
121
+ folder,
122
+ exports: passport.identity.exports || [],
123
+ purpose: purposeOf(folder, passport),
124
+ };
125
+ });
126
+ }
127
+
128
+ /**
129
+ * The passport, trimmed to what writing code needs.
130
+ *
131
+ * The whole file averages 8.6 KB and runs to 26 KB; most of that is provenance
132
+ * an agent cannot act on. What it can act on is the shape: names, types, the
133
+ * union values for enum props, which are required, and the handler signatures.
134
+ */
135
+ function getComponent(name) {
136
+ const folder = findFolder(name);
137
+ if (!folder) {
138
+ return { error: `No component named "${name}". Call list_components for the catalogue.` };
139
+ }
140
+ const p = passportOf(folder);
141
+ return {
142
+ component: p.identity.component,
143
+ folder,
144
+ exports: p.identity.exports || [],
145
+ importFrom: `@/component-lib/${folder}`,
146
+ purpose: purposeOf(folder, p),
147
+ props: (p.props || []).map((prop) => ({
148
+ name: prop.name,
149
+ type: prop.typeText || prop.type,
150
+ values: prop.values,
151
+ required: prop.required || undefined,
152
+ default: prop.default,
153
+ description: prop.description,
154
+ })),
155
+ events: (p.events || []).map((e) => ({
156
+ name: e.name,
157
+ signature: e.signature,
158
+ description: e.description,
159
+ })),
160
+ slots: p.slots || [],
161
+ states: p.states || [],
162
+ composition: p.composition || {},
163
+ accessibility: p.accessibility || {},
164
+ };
165
+ }
166
+
167
+ /** The README's code fences: how the component is meant to be used. */
168
+ function getExample(name) {
169
+ const folder = findFolder(name);
170
+ if (!folder) return { error: `No component named "${name}".` };
171
+ const readme = path.join(CATALOGUE, folder, "README.md");
172
+ if (!fs.existsSync(readme)) return { error: `No README for ${folder}.` };
173
+
174
+ const blocks = [];
175
+ const text = fs.readFileSync(readme, "utf8");
176
+ const fence = /```(\w*)\n([\s\S]*?)```/g;
177
+ let match;
178
+ while ((match = fence.exec(text))) blocks.push({ language: match[1] || "txt", code: match[2].trim() });
179
+ return { component: folder, examples: blocks };
180
+ }
181
+
182
+ /** The authored guidance that ships with the agent skill. */
183
+ function getGuidance(topic) {
184
+ const available = fs.existsSync(REFERENCES)
185
+ ? fs.readdirSync(REFERENCES).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
186
+ : [];
187
+ if (!topic) return { topics: available };
188
+ const file = path.join(REFERENCES, `${topic}.md`);
189
+ if (!fs.existsSync(file)) return { error: `No guidance for "${topic}".`, topics: available };
190
+ return { topic, text: fs.readFileSync(file, "utf8") };
191
+ }
192
+
193
+ /*
194
+ * Check written JSX against the passports.
195
+ *
196
+ * Deliberately tolerant parsing rather than a real one: the agent hands over a
197
+ * fragment, not a file, and the failure worth catching is a prop that does not
198
+ * exist. Spread props and expressions are skipped rather than guessed at — a
199
+ * validator that invents errors is worse than none.
200
+ */
201
+ function validateUsage(code) {
202
+ const source = String(code || "");
203
+ const known = new Map();
204
+ for (const folder of componentFolders()) {
205
+ const p = passportOf(folder);
206
+ const props = new Set((p.props || []).map((x) => x.name));
207
+ for (const e of p.events || []) props.add(e.name);
208
+ const required = (p.props || []).filter((x) => x.required).map((x) => x.name);
209
+ for (const exported of [p.identity.component, ...(p.identity.exports || [])]) {
210
+ known.set(exported, { folder, props, required, values: p.props || [] });
211
+ }
212
+ }
213
+
214
+ const issues = [];
215
+ const used = [];
216
+ const opening = /<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
217
+ let match;
218
+
219
+ while ((match = opening.exec(source))) {
220
+ const [, name] = match;
221
+ const entry = known.get(name);
222
+ if (!entry) continue; // Not ours; the agent may compose with its own components.
223
+
224
+ /*
225
+ * Scan to the tag's own `>` rather than matching one. An attribute can
226
+ * hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
227
+ * usually inside a brace, and a regex that stops there finds nothing.
228
+ */
229
+ let depth = 0;
230
+ let quote = null;
231
+ let end = -1;
232
+ for (let i = match.index + match[0].length; i < source.length; i += 1) {
233
+ const ch = source[i];
234
+ if (quote) {
235
+ if (ch === quote) quote = null;
236
+ } else if (ch === '"' || ch === "'" || ch === "`") {
237
+ quote = ch;
238
+ } else if (ch === "{") {
239
+ depth += 1;
240
+ } else if (ch === "}") {
241
+ depth -= 1;
242
+ } else if (ch === ">" && depth === 0) {
243
+ end = i;
244
+ break;
245
+ }
246
+ }
247
+ if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
248
+
249
+ const rawAttrs = source.slice(match.index + match[0].length, end);
250
+ const attrs = [...rawAttrs.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s*=|\s|$)/g)].map(
251
+ (m) => m[1],
252
+ );
253
+ used.push({ component: name, props: attrs });
254
+
255
+ for (const attr of attrs) {
256
+ if (entry.props.has(attr)) continue;
257
+ if (attr.startsWith("data-") || attr.startsWith("aria-") || attr === "key") continue;
258
+ /*
259
+ * A wrong prop is usually a near miss for a real one — `dismissible`
260
+ * for `onDismiss`, `onChange` for `onValueChange`. Matching on a shared
261
+ * stem in either direction finds those; matching on a prefix does not,
262
+ * because the handler's name starts with `on`.
263
+ */
264
+ const low = attr.toLowerCase();
265
+ const stem = low.slice(0, Math.max(4, Math.round(low.length * 0.6)));
266
+ const near = [...entry.props].filter((p) => {
267
+ const other = p.toLowerCase();
268
+ return other.includes(stem) || low.includes(other.replace(/^on/, "").slice(0, 5));
269
+ });
270
+ issues.push({
271
+ component: name,
272
+ prop: attr,
273
+ problem: "not a prop of this component",
274
+ didYouMean: near.slice(0, 4),
275
+ });
276
+ }
277
+
278
+ for (const req of entry.required) {
279
+ if (!attrs.includes(req) && !/\{\s*\.\.\./.test(rawAttrs)) {
280
+ issues.push({ component: name, prop: req, problem: "required prop is missing" });
281
+ }
282
+ }
283
+ }
284
+
285
+ return {
286
+ checked: used,
287
+ issues,
288
+ ok: issues.length === 0,
289
+ summary: issues.length
290
+ ? `${issues.length} problem(s) across ${used.length} element(s).`
291
+ : `${used.length} element(s) check out against their passports.`,
292
+ };
293
+ }
294
+
295
+ /*
296
+ * Put components into the project the agent is working in.
297
+ *
298
+ * The CLI already knows where they belong — it finds an existing install or
299
+ * follows the project's own layout — so this runs it rather than
300
+ * reimplementing that judgement.
301
+ */
302
+ function installComponents(names) {
303
+ const list = Array.isArray(names) ? names : String(names || "").split(/[\s,]+/);
304
+ const wanted = list.map((n) => String(n).trim()).filter(Boolean);
305
+ if (wanted.length === 0) return { error: "Name at least one component." };
306
+
307
+ const unknown = wanted.filter((n) => !findFolder(n));
308
+ if (unknown.length) {
309
+ return {
310
+ error: `Not in the catalogue: ${unknown.join(", ")}. Call list_components.`,
311
+ };
312
+ }
313
+
314
+ const cli = path.join(__dirname, "..", "..", "index.cjs");
315
+ try {
316
+ const output = execFileSync(process.execPath, [cli, "add", ...wanted], {
317
+ cwd: process.cwd(),
318
+ encoding: "utf8",
319
+ timeout: 120000,
320
+ });
321
+ // The installer prints the one manual step; the agent needs to see it.
322
+ const importLine = (output.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
323
+ return { installed: wanted, stylesheet: importLine, output: output.trim().slice(-1200) };
324
+ } catch (error) {
325
+ return { error: `Install failed: ${(error.stdout || error.message || "").toString().slice(-600)}` };
326
+ }
327
+ }
328
+
329
+ const TOOLS = [
330
+ {
331
+ name: "list_components",
332
+ description:
333
+ "Every component in the catalogue with its exports and what it is for. Call this first when deciding which component a design calls for.",
334
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
335
+ run: () => listComponents(),
336
+ },
337
+ {
338
+ name: "get_component",
339
+ description:
340
+ "A component's full contract: props with types and allowed values, which are required, event handler signatures, slots, states and composition rules. Read this before writing any JSX that uses it, rather than recalling the API.",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: { name: { type: "string", description: 'Component or folder name, e.g. "DataGrid".' } },
344
+ required: ["name"],
345
+ additionalProperties: false,
346
+ },
347
+ run: (args) => getComponent(args.name),
348
+ },
349
+ {
350
+ name: "get_example",
351
+ description: "Working usage taken from the component's own README.",
352
+ inputSchema: {
353
+ type: "object",
354
+ properties: { name: { type: "string" } },
355
+ required: ["name"],
356
+ additionalProperties: false,
357
+ },
358
+ run: (args) => getExample(args.name),
359
+ },
360
+ {
361
+ name: "get_guidance",
362
+ description:
363
+ "Authored guidance on a topic: forms, overlays, data-grid, pickers, styling, accessibility, data-display, toaster, install. Omit the topic to list them.",
364
+ inputSchema: {
365
+ type: "object",
366
+ properties: { topic: { type: "string" } },
367
+ additionalProperties: false,
368
+ },
369
+ run: (args) => getGuidance(args.topic),
370
+ },
371
+ {
372
+ name: "figma_frame",
373
+ description:
374
+ "BETA. Read a Figma design, from a URL (needs FIGMA_TOKEN) or from the plugin export (no token). Called with just a URL it lists the frames in the file; called with `frame` it returns that screen's structure, text, the repeated regions that indicate lists and tables, and the theme variables its colours and radii map onto. Needs FIGMA_TOKEN.",
375
+ inputSchema: {
376
+ type: "object",
377
+ properties: {
378
+ url: { type: "string", description: "A figma.com/design/... URL. Needs FIGMA_TOKEN." },
379
+ design: {
380
+ type: "string",
381
+ description:
382
+ "Alternative to url, needing no token: JSON from the kit's Figma plugin, 'Export selection for an agent'.",
383
+ },
384
+ frame: { type: "string", description: "Which frame to read, from the listing." },
385
+ depth: { type: "number", description: "How deep to descend. Lower it if the payload is large." },
386
+ },
387
+ required: [],
388
+ additionalProperties: false,
389
+ },
390
+ run: (args) => figmaFrame(args),
391
+ },
392
+ {
393
+ name: "install_component",
394
+ description:
395
+ "Copy components into the project being worked in, with their shared folder and stylesheet. Run this before importing them.",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ names: {
400
+ type: "array",
401
+ items: { type: "string" },
402
+ description: 'Component names, e.g. ["DataGrid", "Button"].',
403
+ },
404
+ },
405
+ required: ["names"],
406
+ additionalProperties: false,
407
+ },
408
+ run: (args) => installComponents(args.names),
409
+ },
410
+ {
411
+ name: "validate_usage",
412
+ description:
413
+ "Check JSX against the passports and report props that do not exist or required props left out. Run this on code you have written before telling the user it is done.",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: { code: { type: "string", description: "JSX source to check." } },
417
+ required: ["code"],
418
+ additionalProperties: false,
419
+ },
420
+ run: (args) => validateUsage(args.code),
421
+ },
422
+ ];
423
+
424
+ // ------------------------------------------------------------- the protocol
425
+
426
+ const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
427
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
428
+ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
429
+
430
+ function handle(request) {
431
+ const { id, method, params } = request;
432
+
433
+ if (method === "initialize") {
434
+ const asked = params && params.protocolVersion;
435
+ return reply(id, {
436
+ protocolVersion: SUPPORTED_PROTOCOLS.has(asked) ? asked : PROTOCOL_VERSION,
437
+ capabilities: { tools: {} },
438
+ serverInfo: { name: "grampro-kit", version: require("../../package.json").version },
439
+ });
440
+ }
441
+
442
+ if (method === "notifications/initialized") return; // A notification; no reply.
443
+ if (method === "ping") return reply(id, {});
444
+
445
+ if (method === "tools/list") {
446
+ return reply(id, {
447
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
448
+ });
449
+ }
450
+
451
+ if (method === "tools/call") {
452
+ const tool = TOOLS.find((t) => t.name === (params && params.name));
453
+ if (!tool) return fail(id, -32602, `Unknown tool: ${params && params.name}`);
454
+ try {
455
+ // Some tools reach the network, so a result may be a promise.
456
+ const result = tool.run((params && params.arguments) || {});
457
+ return Promise.resolve(result).then(
458
+ (value) => reply(id, { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }),
459
+ (error) =>
460
+ reply(id, { content: [{ type: "text", text: `Tool failed: ${error.message}` }], isError: true }),
461
+ );
462
+ } catch (error) {
463
+ // Reported as a result, not a protocol error: the model should see it and retry.
464
+ return reply(id, {
465
+ content: [{ type: "text", text: `Tool failed: ${error.message}` }],
466
+ isError: true,
467
+ });
468
+ }
469
+ }
470
+
471
+ if (id !== undefined) fail(id, -32601, `Method not found: ${method}`);
472
+ }
473
+
474
+ function main() {
475
+ let buffer = "";
476
+ process.stdin.setEncoding("utf8");
477
+ process.stdin.on("data", (chunk) => {
478
+ buffer += chunk;
479
+ // Newline-delimited JSON: a message may arrive split across chunks.
480
+ let newline;
481
+ while ((newline = buffer.indexOf("\n")) !== -1) {
482
+ const line = buffer.slice(0, newline).trim();
483
+ buffer = buffer.slice(newline + 1);
484
+ if (!line) continue;
485
+ try {
486
+ handle(JSON.parse(line));
487
+ } catch {
488
+ fail(null, -32700, "Parse error");
489
+ }
490
+ }
491
+ });
492
+ process.stdin.on("end", () => process.exit(0));
493
+ }
494
+
495
+ if (require.main === module) main();
496
+
497
+ module.exports = { main, listComponents, getComponent, getExample, getGuidance, validateUsage, installComponents, TOOLS };
@@ -15,7 +15,14 @@ const { generate, check } = require("./index.cjs");
15
15
 
16
16
  const REPO = path.resolve(__dirname, "..", "..");
17
17
  const LIB = path.join(REPO, "source", "components");
18
- const COVERAGE_THRESHOLD = 40; // ratchet upward over time
18
+ /*
19
+ * Ratchet upward over time. 40 was set when coverage counted every inherited
20
+ * HTML attribute in the denominator, which capped some components near 35% and
21
+ * made the gate unreachable by doing the work it asked for. Now that it
22
+ * measures the props this library authors, the library sits at 87% and the
23
+ * weakest component at 75, so this is the floor rather than an aspiration.
24
+ */
25
+ const COVERAGE_THRESHOLD = 75;
19
26
 
20
27
  const args = process.argv.slice(2);
21
28
  const wantCheck = args.includes("--check");
@@ -16,6 +16,7 @@ const fs = require("fs");
16
16
  const path = require("path");
17
17
  const crypto = require("crypto");
18
18
  const { allowlistFor } = require("./policy.cjs");
19
+ const { describable } = require("./merge.cjs");
19
20
 
20
21
  const PASSPORT_VERSION = "1.0.0";
21
22
 
@@ -435,7 +436,10 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
435
436
  .filter((p) => /^on[A-Z]/.test(p.name))
436
437
  .map((p) => ({ name: p.name, ...(p.signature ? { signature: p.signature } : {}) }));
437
438
 
438
- const described = propList.filter((p) => p.description).length;
439
+ // Same rule as the merge step, so the two never disagree: coverage is over
440
+ // the props this library declares, not the HTML attributes it inherits.
441
+ const countable = propList.filter(describable);
442
+ const described = countable.filter((p) => p.description).length;
439
443
 
440
444
  const passport = {
441
445
  passportVersion: PASSPORT_VERSION,
@@ -463,9 +467,9 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
463
467
  safeMutations: {},
464
468
  examples: [],
465
469
  coverage: {
466
- propsTotal: propList.length,
470
+ propsTotal: countable.length,
467
471
  propsDescribed: described,
468
- describedPct: propList.length ? Math.round((described / propList.length) * 100) : 0,
472
+ describedPct: countable.length ? Math.round((described / countable.length) * 100) : 0,
469
473
  authoredSections: [],
470
474
  },
471
475
  };
@@ -72,6 +72,25 @@ function mergeProps(derived, overlayProps, origin) {
72
72
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
73
73
  }
74
74
 
75
+ /*
76
+ * Props whose meaning is the same on every component in the library, and so
77
+ * belong in one page of conventions rather than in 27 passports. A one-line
78
+ * gloss on each would be filler, and filler in a passport is worse than
79
+ * silence: an agent reads it as information.
80
+ */
81
+ const PLUMBING = new Set(["className", "classNames", "style", "ref", "key", "localeText"]);
82
+
83
+ /**
84
+ * Whether a prop counts towards description coverage.
85
+ *
86
+ * Only what this library declares itself. An inherited prop is `aria-label`
87
+ * or `tabIndex` arriving through `extends ButtonHTMLAttributes` - documented
88
+ * by the HTML specification, and not ours to restate. Counting them put the
89
+ * reachable ceiling for some components near 35%, so the gate asked for work
90
+ * that could not move it.
91
+ */
92
+ const describable = (prop) => prop.origin === "derived" && !PLUMBING.has(prop.name);
93
+
75
94
  /**
76
95
  * Produce the effective passport written to passport.json.
77
96
  *
@@ -112,20 +131,21 @@ function mergePassport(derived, manual = {}, local = {}) {
112
131
  }
113
132
 
114
133
  // Coverage is recomputed after overlays, since manual descriptions count.
115
- const described = out.props.filter((p) => p.description).length;
134
+ const counted = out.props.filter(describable);
135
+ const described = counted.filter((p) => p.description).length;
116
136
  const authoredSections = [...AUTHORED].filter((k) => {
117
137
  const v = out[k];
118
138
  return Array.isArray(v) ? v.length > 0 : isObject(v) ? Object.keys(v).length > 0 : Boolean(v);
119
139
  }).sort();
120
140
 
121
141
  out.coverage = {
122
- propsTotal: out.props.length,
142
+ propsTotal: counted.length,
123
143
  propsDescribed: described,
124
- describedPct: out.props.length ? Math.round((described / out.props.length) * 100) : 0,
144
+ describedPct: counted.length ? Math.round((described / counted.length) * 100) : 0,
125
145
  authoredSections,
126
146
  };
127
147
 
128
148
  return out;
129
149
  }
130
150
 
131
- module.exports = { mergePassport, deepMerge, mergeProps, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };
151
+ module.exports = { mergePassport, deepMerge, mergeProps, describable, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };