@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.
- package/.gbs/skills/gbs-components/SKILL.md +4 -2
- package/.gbs/skills/gbs-components/references/install.md +28 -2
- package/CHANGELOG.md +63 -0
- package/README.md +2 -1
- package/index.cjs +148 -21
- package/package.json +1 -1
- 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/server.cjs +47 -40
- package/tools/passport/extract.cjs +47 -20
- 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 };
|
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");
|
|
@@ -241,43 +242,10 @@ function validateUsage(code) {
|
|
|
241
242
|
|
|
242
243
|
const issues = [];
|
|
243
244
|
const used = [];
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|