@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
@@ -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 };
@@ -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 };
@@ -266,3 +266,26 @@ CLI's own dependencies, which are no use to a JSON-RPC loop and cost around
266
266
  The catalogue it serves is the one in this package, not the components
267
267
  copied into your project — so an agent can see a component before you
268
268
  install it, and `install_component` puts it in place.
269
+
270
+ ### What a caller can reach
271
+
272
+ Every tool argument is treated as untrusted. An agent that has read a hostile
273
+ web page or design file can be steered into calling these tools with
274
+ anything, so the boundary has to hold no matter who wrote the arguments.
275
+
276
+ - **`get_guidance`** serves only the guidance topics that ship in this
277
+ package. Any other topic is refused, including one containing `../`. Before
278
+ 2.7.1 it was not refused: a crafted topic could read any `.md` file the
279
+ server could open. See the changelog.
280
+ - **`get_component`, `get_example` and `install_component`** accept only names
281
+ in the catalogue. A name is reduced to letters and digits before anything
282
+ is looked up, and `install_component` runs the CLI with an argument list,
283
+ never through a shell.
284
+ - **`figma_frame` with `designFile`** reads the path it is given, with your
285
+ user's permissions. It only returns content from a file shaped like the
286
+ plugin's export, and refuses anything else without quoting it. A file that
287
+ *is* shaped like an export gets read as one, which is the tool's purpose.
288
+
289
+ The server runs as you, so none of this stops an agent that can already read
290
+ your files directly. What it prevents is a tool that advertises one narrow
291
+ job being turned into a general file reader.