@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
|
@@ -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 };
|
package/tools/mcp/README.md
CHANGED
|
@@ -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.
|