gbs-add-block 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gbs/skills/gbs-components/SKILL.md +190 -134
- package/.gbs/skills/gbs-components/references/install.md +25 -3
- package/.gbs/skills/gbs-components/references/styling.md +246 -207
- package/CHANGELOG.md +74 -0
- package/README.md +154 -23
- package/index.cjs +212 -3
- package/package.json +41 -10
- package/schema/passport-v1.schema.json +204 -0
- package/source/beta-components/accordion/passport.json +259 -0
- package/source/beta-components/accordion/styles.css +207 -208
- package/source/beta-components/alert/passport.json +250 -0
- package/source/beta-components/alert/styles.css +154 -155
- package/source/beta-components/avatar/passport.json +294 -0
- package/source/beta-components/avatar/styles.css +225 -226
- package/source/beta-components/badge/passport.json +332 -0
- package/source/beta-components/badge/styles.css +203 -204
- package/source/beta-components/breadcrumb/passport.json +243 -0
- package/source/beta-components/breadcrumb/styles.css +138 -140
- package/source/beta-components/button/passport.json +402 -0
- package/source/beta-components/button/passport.manual.json +31 -0
- package/source/beta-components/button/styles.css +232 -234
- package/source/beta-components/card/passport.json +337 -0
- package/source/beta-components/card/styles.css +230 -232
- package/source/beta-components/checkbox/passport.json +456 -0
- package/source/beta-components/checkbox/styles.css +211 -213
- package/source/beta-components/combobox/passport.json +456 -0
- package/source/beta-components/combobox/styles.css +419 -417
- package/source/beta-components/data-grid/agent/coerce.ts +368 -0
- package/source/beta-components/data-grid/agent/contract.ts +410 -0
- package/source/beta-components/data-grid/agent/dataset.ts +92 -0
- package/source/beta-components/data-grid/agent/engine.ts +470 -0
- package/source/beta-components/data-grid/agent/executors.ts +155 -0
- package/source/beta-components/data-grid/agent/index.ts +79 -0
- package/source/beta-components/data-grid/agent/intent.ts +324 -0
- package/source/beta-components/data-grid/agent/operations.ts +335 -0
- package/source/beta-components/data-grid/agent/validate.ts +630 -0
- package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
- package/source/beta-components/data-grid/index.ts +14 -7
- package/source/beta-components/data-grid/passport.json +1051 -0
- package/source/beta-components/data-grid/passport.manual.json +255 -0
- package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
- package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
- package/source/beta-components/data-grid/styles.css +874 -716
- package/source/beta-components/date-picker/passport.json +407 -0
- package/source/beta-components/date-picker/styles.css +445 -446
- package/source/beta-components/dialog/passport.json +344 -0
- package/source/beta-components/dialog/styles.css +280 -279
- package/source/beta-components/file-uploader/passport.json +518 -0
- package/source/beta-components/file-uploader/styles.css +394 -396
- package/source/beta-components/input/passport.json +536 -0
- package/source/beta-components/input/styles.css +295 -297
- package/source/beta-components/menu/passport.json +322 -0
- package/source/beta-components/menu/styles.css +224 -223
- package/source/beta-components/modal/passport.json +289 -0
- package/source/beta-components/modal/styles.css +241 -240
- package/source/beta-components/number-input/passport.json +541 -0
- package/source/beta-components/number-input/styles.css +230 -231
- package/source/beta-components/popover/passport.json +238 -0
- package/source/beta-components/popover/styles.css +148 -147
- package/source/beta-components/progress/passport.json +270 -0
- package/source/beta-components/progress/styles.css +200 -201
- package/source/beta-components/radio-group/passport.json +477 -0
- package/source/beta-components/radio-group/styles.css +269 -270
- package/source/beta-components/shared/core/agent/adapter.ts +65 -0
- package/source/beta-components/shared/core/agent/history.ts +120 -0
- package/source/beta-components/shared/core/agent/index.ts +46 -0
- package/source/beta-components/shared/core/agent/numbers.ts +217 -0
- package/source/beta-components/shared/core/agent/schema.ts +180 -0
- package/source/beta-components/shared/core/agent/types.ts +169 -0
- package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
- package/source/beta-components/shared/index.ts +9 -0
- package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
- package/source/beta-components/shared/react/useAskAgent.ts +217 -0
- package/source/beta-components/shared/styles.css +79 -0
- package/source/beta-components/shared/version.json +4 -4
- package/source/beta-components/shared/version.ts +6 -6
- package/source/beta-components/skeleton/passport.json +251 -0
- package/source/beta-components/skeleton/styles.css +185 -187
- package/source/beta-components/spinner/passport.json +245 -0
- package/source/beta-components/spinner/styles.css +173 -174
- package/source/beta-components/switch/passport.json +421 -0
- package/source/beta-components/switch/styles.css +227 -229
- package/source/beta-components/tabs/passport.json +315 -0
- package/source/beta-components/tabs/styles.css +263 -264
- package/source/beta-components/textarea/passport.json +382 -0
- package/source/beta-components/textarea/styles.css +158 -160
- package/source/beta-components/toaster/passport.json +221 -0
- package/source/beta-components/toaster/styles.css +282 -282
- package/source/beta-components/tooltip/passport.json +170 -0
- package/source/beta-components/tooltip/styles.css +71 -73
- package/tools/env.cjs +61 -0
- package/tools/passport/cli.cjs +79 -0
- package/tools/passport/extract.cjs +493 -0
- package/tools/passport/index.cjs +185 -0
- package/tools/passport/merge.cjs +131 -0
- package/tools/passport/policy.cjs +65 -0
- package/tools/passport/validate.cjs +277 -0
- package/tools/ts-require.cjs +79 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
- package/source/beta-components/alert/__tests__/core.test.ts +0 -17
- package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
- package/source/beta-components/badge/__tests__/core.test.ts +0 -46
- package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
- package/source/beta-components/button/__tests__/core.test.ts +0 -31
- package/source/beta-components/card/__tests__/core.test.ts +0 -57
- package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
- package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
- package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
- package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
- package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
- package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
- package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
- package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
- package/source/beta-components/input/__tests__/core.test.ts +0 -75
- package/source/beta-components/menu/__tests__/core.test.ts +0 -120
- package/source/beta-components/modal/__tests__/core.test.ts +0 -55
- package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
- package/source/beta-components/progress/__tests__/core.test.ts +0 -56
- package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
- package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
- package/source/beta-components/shared/__tests__/core.test.ts +0 -55
- package/source/beta-components/shared/__tests__/position.test.ts +0 -143
- package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
- package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
- package/source/beta-components/switch/__tests__/core.test.ts +0 -64
- package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
- package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
- package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
- package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Passport extraction: component source -> derived passport fields.
|
|
3
|
+
*
|
|
4
|
+
* Uses the TypeScript TypeChecker, not regex, so `extends` chains resolve. The
|
|
5
|
+
* central rule is ORIGIN FILTERING:
|
|
6
|
+
*
|
|
7
|
+
* own / shared source -> flatten and enumerate (GridOptions, ComboboxSharedProps…)
|
|
8
|
+
* library source -> keep as an `inherits` edge, plus a policy allowlist
|
|
9
|
+
*
|
|
10
|
+
* Measured on this repo: full flattening yields 6,232 props (Tabs alone 853);
|
|
11
|
+
* AST-only yields 633 and misses half the grid's API. Origin filtering gives
|
|
12
|
+
* the real public surface.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const crypto = require("crypto");
|
|
18
|
+
const { allowlistFor } = require("./policy.cjs");
|
|
19
|
+
|
|
20
|
+
const PASSPORT_VERSION = "1.0.0";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the TypeScript compiler API.
|
|
24
|
+
*
|
|
25
|
+
* Generation-time only — never needed to USE a component, so this stays out of
|
|
26
|
+
* the runtime dependency story. The caller's project is searched first, so a
|
|
27
|
+
* consumer regenerating a forked component uses their own TypeScript.
|
|
28
|
+
*/
|
|
29
|
+
function loadTypeScript(searchFrom = []) {
|
|
30
|
+
const paths = [...searchFrom, process.cwd(), __dirname].filter(Boolean);
|
|
31
|
+
try {
|
|
32
|
+
return require(require.resolve("typescript", { paths }));
|
|
33
|
+
} catch {
|
|
34
|
+
try {
|
|
35
|
+
return require("typescript");
|
|
36
|
+
} catch {
|
|
37
|
+
const error = new Error(
|
|
38
|
+
"Passport generation needs the TypeScript compiler API.\n" +
|
|
39
|
+
" Install it as a dev dependency: npm i -D typescript\n" +
|
|
40
|
+
" It is only needed to generate passports, never to run a component.",
|
|
41
|
+
);
|
|
42
|
+
error.code = "ETYPESCRIPT_MISSING";
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const posix = (p) => p.replace(/\\/g, "/");
|
|
49
|
+
|
|
50
|
+
/** Every .ts/.tsx under a directory, excluding tests. */
|
|
51
|
+
function sourceFiles(dir) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const walk = (d) => {
|
|
54
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
55
|
+
if (e.name === "__tests__") continue;
|
|
56
|
+
const p = path.join(d, e.name);
|
|
57
|
+
if (e.isDirectory()) walk(p);
|
|
58
|
+
else if (/\.tsx?$/.test(e.name)) out.push(p);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
walk(dir);
|
|
62
|
+
return out.sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Hash of the inputs the passport is derived from. Deterministic: sorted paths,
|
|
67
|
+
* file contents only, no timestamps.
|
|
68
|
+
*/
|
|
69
|
+
function hashSources(files, root) {
|
|
70
|
+
const h = crypto.createHash("sha256");
|
|
71
|
+
for (const f of [...files].sort()) {
|
|
72
|
+
h.update(posix(path.relative(root, f)));
|
|
73
|
+
h.update("\0");
|
|
74
|
+
h.update(fs.readFileSync(f));
|
|
75
|
+
h.update("\0");
|
|
76
|
+
}
|
|
77
|
+
return `sha256:${h.digest("hex")}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ------------------------------------------------------------------ program */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One program across every component, so cross-file project-local types
|
|
84
|
+
* (GridOptions, ComboboxSharedProps) resolve properly.
|
|
85
|
+
*/
|
|
86
|
+
function createProgram(ts, libRoot, components, reactTypesDir) {
|
|
87
|
+
const entries = components
|
|
88
|
+
.map((c) => path.join(libRoot, c, "index.ts"))
|
|
89
|
+
.filter((f) => fs.existsSync(f));
|
|
90
|
+
|
|
91
|
+
const options = {
|
|
92
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
93
|
+
target: ts.ScriptTarget.ES2022,
|
|
94
|
+
module: ts.ModuleKind.ESNext,
|
|
95
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
96
|
+
strict: true,
|
|
97
|
+
skipLibCheck: true,
|
|
98
|
+
noEmit: true,
|
|
99
|
+
};
|
|
100
|
+
if (reactTypesDir) {
|
|
101
|
+
options.baseUrl = path.dirname(path.dirname(reactTypesDir));
|
|
102
|
+
options.paths = {
|
|
103
|
+
react: [reactTypesDir],
|
|
104
|
+
"react/*": [`${reactTypesDir}/*`],
|
|
105
|
+
"react-dom": [reactTypesDir.replace(/react$/, "react-dom")],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return ts.createProgram(entries, options);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Where a declaration lives, which decides flatten-vs-edge. */
|
|
112
|
+
function originOfDeclaration(decl, libRoot) {
|
|
113
|
+
if (!decl) return "unknown";
|
|
114
|
+
const file = posix(decl.getSourceFile().fileName);
|
|
115
|
+
if (file.includes("/node_modules/") || /\/lib\.[a-z0-9.]+\.d\.ts$/.test(file)) return "inherited";
|
|
116
|
+
if (file.includes(`${posix(libRoot)}/shared/`)) return "shared";
|
|
117
|
+
if (file.startsWith(posix(libRoot))) return "own";
|
|
118
|
+
return "inherited";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* --------------------------------------------------------------- type shape */
|
|
122
|
+
|
|
123
|
+
/** A compact, stable description of a property's type. */
|
|
124
|
+
function describeType(ts, checker, type, node) {
|
|
125
|
+
const text = checker.typeToString(
|
|
126
|
+
type,
|
|
127
|
+
node,
|
|
128
|
+
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Optional props are `T | undefined`. Classify on T, or every optional
|
|
132
|
+
// callback reads as an anonymous union rather than a function.
|
|
133
|
+
const NULLISH = ts.TypeFlags.Undefined | ts.TypeFlags.Null;
|
|
134
|
+
if (type.isUnion()) {
|
|
135
|
+
const defined = type.types.filter((t) => !(t.flags & NULLISH));
|
|
136
|
+
if (defined.length === 1) type = defined[0];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (type.isUnion()) {
|
|
140
|
+
const parts = type.types;
|
|
141
|
+
const literals = parts.filter((t) => t.isStringLiteral());
|
|
142
|
+
const nonLiteral = parts.filter(
|
|
143
|
+
(t) => !t.isStringLiteral() && !(t.flags & ts.TypeFlags.Undefined),
|
|
144
|
+
);
|
|
145
|
+
// A pure union of string literals is an enum worth enumerating.
|
|
146
|
+
if (literals.length >= 2 && nonLiteral.length === 0) {
|
|
147
|
+
return { type: "enum", values: literals.map((t) => t.value).sort(), typeText: text };
|
|
148
|
+
}
|
|
149
|
+
const boolish = parts.every(
|
|
150
|
+
(t) => t.flags & (ts.TypeFlags.BooleanLike | ts.TypeFlags.Undefined),
|
|
151
|
+
);
|
|
152
|
+
if (boolish) return { type: "boolean", typeText: text };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) {
|
|
156
|
+
return { type: "function", signature: text.length > 200 ? `${text.slice(0, 197)}…` : text };
|
|
157
|
+
}
|
|
158
|
+
if (type.flags & ts.TypeFlags.StringLike) return { type: "string", typeText: text };
|
|
159
|
+
if (type.flags & ts.TypeFlags.NumberLike) return { type: "number", typeText: text };
|
|
160
|
+
if (type.flags & ts.TypeFlags.BooleanLike) return { type: "boolean", typeText: text };
|
|
161
|
+
|
|
162
|
+
return { type: "other", typeText: text.length > 200 ? `${text.slice(0, 197)}…` : text };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** First JSDoc sentence attached to a symbol, or undefined. Never invented. */
|
|
166
|
+
function jsDocOf(ts, sym) {
|
|
167
|
+
const parts = sym.getDocumentationComment(undefined);
|
|
168
|
+
if (!parts || parts.length === 0) return undefined;
|
|
169
|
+
const text = ts.displayPartsToString(parts).trim();
|
|
170
|
+
return text || undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* ------------------------------------------------------ defaults from source */
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Defaults are written as destructuring defaults in the component function
|
|
177
|
+
* (`size = "md"`), which is the only place they exist. Best-effort and marked
|
|
178
|
+
* as derived; absent when not found.
|
|
179
|
+
*/
|
|
180
|
+
/** `"md"` -> md, `false` -> false, `12` -> 12; anything long or complex is dropped. */
|
|
181
|
+
function normaliseDefault(text) {
|
|
182
|
+
if (text.length > 40) return undefined;
|
|
183
|
+
const quoted = text.match(/^["'`](.*)["'`]$/s);
|
|
184
|
+
if (quoted) return quoted[1];
|
|
185
|
+
if (text === "true") return true;
|
|
186
|
+
if (text === "false") return false;
|
|
187
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
|
188
|
+
if (text === "null") return null;
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function collectDefaults(ts, sourceFileList, program) {
|
|
193
|
+
const defaults = new Map(); // "Component.prop" -> literal text
|
|
194
|
+
for (const file of sourceFileList) {
|
|
195
|
+
const sf = program.getSourceFile(file);
|
|
196
|
+
if (!sf) continue;
|
|
197
|
+
sf.forEachChild((node) => {
|
|
198
|
+
if (!ts.isFunctionDeclaration(node) || !node.name) return;
|
|
199
|
+
const comp = node.name.text;
|
|
200
|
+
for (const param of node.parameters) {
|
|
201
|
+
if (!ts.isObjectBindingPattern(param.name)) continue;
|
|
202
|
+
for (const el of param.name.elements) {
|
|
203
|
+
if (!el.initializer || !ts.isIdentifier(el.name)) continue;
|
|
204
|
+
const init = normaliseDefault(el.initializer.getText(sf));
|
|
205
|
+
if (init !== undefined) defaults.set(`${comp}.${el.name.text}`, init);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// `const { a = 1 } = props` inside the body
|
|
209
|
+
if (node.body) {
|
|
210
|
+
for (const stmt of node.body.statements) {
|
|
211
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
212
|
+
for (const d of stmt.declarationList.declarations) {
|
|
213
|
+
if (!ts.isObjectBindingPattern(d.name)) continue;
|
|
214
|
+
for (const el of d.name.elements) {
|
|
215
|
+
if (!el.initializer || !ts.isIdentifier(el.name)) continue;
|
|
216
|
+
const init = normaliseDefault(el.initializer.getText(sf));
|
|
217
|
+
if (init !== undefined) defaults.set(`${comp}.${el.name.text}`, init);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return defaults;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ------------------------------------------------------------ slots & states */
|
|
228
|
+
|
|
229
|
+
function collectSlots(ts, files, program) {
|
|
230
|
+
const slots = [];
|
|
231
|
+
for (const file of files) {
|
|
232
|
+
const sf = program.getSourceFile(file);
|
|
233
|
+
if (!sf) continue;
|
|
234
|
+
sf.forEachChild((node) => {
|
|
235
|
+
if (!ts.isTypeAliasDeclaration(node)) return;
|
|
236
|
+
if (!/Slot$/.test(node.name.text)) return;
|
|
237
|
+
const text = node.type.getText(sf);
|
|
238
|
+
for (const m of text.matchAll(/"([a-zA-Z][\w-]*)"/g)) {
|
|
239
|
+
if (!slots.includes(m[1])) slots.push(m[1]); // declaration order is meaningful
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return slots;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `data-*` attributes, from the stylesheet's selectors and the JSX that sets them. */
|
|
247
|
+
function collectStates(dir) {
|
|
248
|
+
const found = new Map(); // attr -> Set(values)
|
|
249
|
+
const add = (attr, value) => {
|
|
250
|
+
if (!found.has(attr)) found.set(attr, new Set());
|
|
251
|
+
if (value) found.get(attr).add(value);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const css = path.join(dir, "styles.css");
|
|
255
|
+
if (fs.existsSync(css)) {
|
|
256
|
+
const text = fs.readFileSync(css, "utf8");
|
|
257
|
+
for (const m of text.matchAll(/\[(data-[a-z-]+)(?:=["']([^"']+)["'])?\]/g)) add(m[1], m[2]);
|
|
258
|
+
}
|
|
259
|
+
for (const f of sourceFiles(dir)) {
|
|
260
|
+
const text = fs.readFileSync(f, "utf8");
|
|
261
|
+
for (const m of text.matchAll(/["']?(data-[a-z-]+)["']?\s*[=:]\s*["']?([a-z-]+)?["']?/g))
|
|
262
|
+
add(m[1], /^[a-z-]+$/.test(m[2] ?? "") ? m[2] : undefined);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [...found.entries()]
|
|
266
|
+
.map(([attr, values]) => ({
|
|
267
|
+
attr,
|
|
268
|
+
...(values.size ? { values: [...values].sort() } : {}),
|
|
269
|
+
}))
|
|
270
|
+
.sort((a, b) => a.attr.localeCompare(b.attr));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ------------------------------------------------------------------ extract */
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Derive the generated half of one component's passport.
|
|
277
|
+
* @returns {{passport: object, stats: object}}
|
|
278
|
+
*/
|
|
279
|
+
/*
|
|
280
|
+
* The component's imperative surface: the members of any exported interface
|
|
281
|
+
* named `*Api` or `*Handle` (GridApi, DatePickerHandle, ComboboxHandle). That
|
|
282
|
+
* is the thing a `ref` hands back, and so the only set an operation's
|
|
283
|
+
* `apiMethod` may name.
|
|
284
|
+
*
|
|
285
|
+
* Collected but not published: v1's schema is out in the world, and an
|
|
286
|
+
* operation naming a method that does not exist is caught either way.
|
|
287
|
+
*/
|
|
288
|
+
function collectApi(ts, checker, exported, deAlias) {
|
|
289
|
+
const names = new Set();
|
|
290
|
+
for (const raw of exported) {
|
|
291
|
+
if (!/(?:Api|Handle)$/.test(raw.name)) continue;
|
|
292
|
+
const sym = deAlias(raw);
|
|
293
|
+
const decl = (sym.declarations ?? []).find(ts.isInterfaceDeclaration);
|
|
294
|
+
if (!decl) continue;
|
|
295
|
+
for (const member of checker.getPropertiesOfType(checker.getDeclaredTypeOfSymbol(sym))) {
|
|
296
|
+
names.add(member.name);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return [...names].sort();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVersion, repoRoot }) {
|
|
303
|
+
const files = sourceFiles(dir);
|
|
304
|
+
const indexFile = path.join(dir, "index.ts");
|
|
305
|
+
const sf = program.getSourceFile(indexFile);
|
|
306
|
+
if (!sf) throw new Error(`${name}: index.ts did not resolve in the program`);
|
|
307
|
+
|
|
308
|
+
const moduleSymbol = checker.getSymbolAtLocation(sf);
|
|
309
|
+
if (!moduleSymbol) throw new Error(`${name}: no module symbol for index.ts`);
|
|
310
|
+
|
|
311
|
+
const deAlias = (s) => (s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s);
|
|
312
|
+
const exported = checker.getExportsOfModule(moduleSymbol);
|
|
313
|
+
|
|
314
|
+
const defaults = collectDefaults(ts, files, program);
|
|
315
|
+
const api = collectApi(ts, checker, exported, deAlias);
|
|
316
|
+
|
|
317
|
+
// Pass 1: which exports are actually components?
|
|
318
|
+
const componentExports = [];
|
|
319
|
+
for (const raw of exported) {
|
|
320
|
+
const decls = deAlias(raw).declarations ?? [];
|
|
321
|
+
if (
|
|
322
|
+
/^[A-Z]/.test(raw.name) &&
|
|
323
|
+
decls.some((d) => ts.isFunctionDeclaration(d) || ts.isVariableDeclaration(d))
|
|
324
|
+
) {
|
|
325
|
+
componentExports.push(raw.name);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/*
|
|
330
|
+
* Only `<Component>Props` describes a component's public props. Other
|
|
331
|
+
* `*Props` exports are callback payloads — ButtonRenderProps is the argument
|
|
332
|
+
* to `render`, not something you pass to <Button> — and folding them in
|
|
333
|
+
* corrupts the prop list (it made `className` look required).
|
|
334
|
+
*/
|
|
335
|
+
const componentPropsNames = new Set(componentExports.map((c) => `${c}Props`));
|
|
336
|
+
|
|
337
|
+
const props = new Map(); // name -> entry (first declaration wins)
|
|
338
|
+
const externalBases = new Map(); // baseName -> { from, omitted }
|
|
339
|
+
let inheritedConfirmed = 0;
|
|
340
|
+
|
|
341
|
+
for (const raw of exported) {
|
|
342
|
+
const sym = deAlias(raw);
|
|
343
|
+
if (!componentPropsNames.has(raw.name)) continue;
|
|
344
|
+
const decl = (sym.declarations ?? []).find(ts.isInterfaceDeclaration);
|
|
345
|
+
if (!decl) continue;
|
|
346
|
+
|
|
347
|
+
// Record external base types as an inheritance edge rather than flattening.
|
|
348
|
+
for (const clause of decl.heritageClauses ?? []) {
|
|
349
|
+
for (const typeNode of clause.types) {
|
|
350
|
+
const text = typeNode.getText(decl.getSourceFile());
|
|
351
|
+
const omitMatch = text.match(/^Omit<\s*([^,]+),\s*(.+)>$/s);
|
|
352
|
+
const inner = omitMatch ? omitMatch[1].trim() : text;
|
|
353
|
+
const omitted = omitMatch
|
|
354
|
+
? [...omitMatch[2].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort()
|
|
355
|
+
: [];
|
|
356
|
+
const baseName = inner.replace(/<.*$/s, "").trim();
|
|
357
|
+
|
|
358
|
+
const baseType = checker.getTypeAtLocation(typeNode.expression ?? typeNode);
|
|
359
|
+
const baseDecl = baseType?.getSymbol?.()?.declarations?.[0];
|
|
360
|
+
const origin = originOfDeclaration(baseDecl, libRoot);
|
|
361
|
+
if (origin === "inherited") {
|
|
362
|
+
externalBases.set(baseName, { from: inner, omitted, passthrough: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const type = checker.getDeclaredTypeOfSymbol(sym);
|
|
368
|
+
for (const p of checker.getPropertiesOfType(type)) {
|
|
369
|
+
if (props.has(p.name)) continue;
|
|
370
|
+
const pDecl = p.declarations?.[0];
|
|
371
|
+
const origin = originOfDeclaration(pDecl, libRoot);
|
|
372
|
+
if (origin === "inherited") continue; // handled by the allowlist pass below
|
|
373
|
+
|
|
374
|
+
const pType = checker.getTypeOfSymbolAtLocation(p, pDecl ?? decl);
|
|
375
|
+
const shape = describeType(ts, checker, pType, pDecl ?? decl);
|
|
376
|
+
const description = jsDocOf(ts, p);
|
|
377
|
+
const required = !(p.flags & ts.SymbolFlags.Optional);
|
|
378
|
+
|
|
379
|
+
let def;
|
|
380
|
+
for (const comp of componentExports) {
|
|
381
|
+
if (defaults.has(`${comp}.${p.name}`)) { def = defaults.get(`${comp}.${p.name}`); break; }
|
|
382
|
+
}
|
|
383
|
+
if (def === undefined && defaults.has(`${name}.${p.name}`)) def = defaults.get(`${name}.${p.name}`);
|
|
384
|
+
|
|
385
|
+
props.set(p.name, {
|
|
386
|
+
name: p.name,
|
|
387
|
+
...shape,
|
|
388
|
+
required,
|
|
389
|
+
...(def !== undefined ? { default: def } : {}),
|
|
390
|
+
origin: origin === "shared" ? "derived" : "derived",
|
|
391
|
+
...(description ? { description, descriptionOrigin: "jsdoc" } : {}),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* --- inherited allowlist: only what the checker confirms actually exists --- */
|
|
397
|
+
const baseNames = [...externalBases.keys()];
|
|
398
|
+
if (baseNames.length) {
|
|
399
|
+
const wanted = allowlistFor(baseNames);
|
|
400
|
+
for (const raw of exported) {
|
|
401
|
+
if (!componentPropsNames.has(raw.name)) continue;
|
|
402
|
+
const sym = deAlias(raw);
|
|
403
|
+
const decl = (sym.declarations ?? []).find(ts.isInterfaceDeclaration);
|
|
404
|
+
if (!decl) continue;
|
|
405
|
+
const type = checker.getDeclaredTypeOfSymbol(sym);
|
|
406
|
+
|
|
407
|
+
for (const wantedName of wanted) {
|
|
408
|
+
if (props.has(wantedName)) continue;
|
|
409
|
+
const p = checker.getPropertyOfType(type, wantedName);
|
|
410
|
+
if (!p) continue; // not present -> never emitted
|
|
411
|
+
const pDecl = p.declarations?.[0];
|
|
412
|
+
if (originOfDeclaration(pDecl, libRoot) !== "inherited") continue;
|
|
413
|
+
|
|
414
|
+
const pType = checker.getTypeOfSymbolAtLocation(p, pDecl ?? decl);
|
|
415
|
+
const shape = describeType(ts, checker, pType, pDecl ?? decl);
|
|
416
|
+
props.set(wantedName, {
|
|
417
|
+
name: wantedName,
|
|
418
|
+
...shape,
|
|
419
|
+
required: !(p.flags & ts.SymbolFlags.Optional),
|
|
420
|
+
origin: "inherited",
|
|
421
|
+
});
|
|
422
|
+
inheritedConfirmed++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const propList = [...props.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
428
|
+
const events = propList
|
|
429
|
+
.filter((p) => /^on[A-Z]/.test(p.name))
|
|
430
|
+
.map((p) => ({ name: p.name, ...(p.signature ? { signature: p.signature } : {}) }));
|
|
431
|
+
|
|
432
|
+
const described = propList.filter((p) => p.description).length;
|
|
433
|
+
|
|
434
|
+
const passport = {
|
|
435
|
+
passportVersion: PASSPORT_VERSION,
|
|
436
|
+
identity: {
|
|
437
|
+
component: componentExports[0] ?? name,
|
|
438
|
+
folder: name,
|
|
439
|
+
exports: componentExports.sort(),
|
|
440
|
+
},
|
|
441
|
+
source: {
|
|
442
|
+
libraryVersion,
|
|
443
|
+
files: files.map((f) => posix(path.relative(dir, f))),
|
|
444
|
+
sourceHash: hashSources([...files, ...(fs.existsSync(path.join(dir, "styles.css")) ? [path.join(dir, "styles.css")] : [])], repoRoot),
|
|
445
|
+
},
|
|
446
|
+
props: propList,
|
|
447
|
+
...(externalBases.size
|
|
448
|
+
? { inherits: [...externalBases.values()].sort((a, b) => a.from.localeCompare(b.from)) }
|
|
449
|
+
: {}),
|
|
450
|
+
events,
|
|
451
|
+
slots: collectSlots(ts, files, program),
|
|
452
|
+
states: collectStates(dir),
|
|
453
|
+
accessibility: { supplied: [], required: [] },
|
|
454
|
+
composition: {},
|
|
455
|
+
operations: {},
|
|
456
|
+
intentDomains: [],
|
|
457
|
+
safeMutations: {},
|
|
458
|
+
examples: [],
|
|
459
|
+
coverage: {
|
|
460
|
+
propsTotal: propList.length,
|
|
461
|
+
propsDescribed: described,
|
|
462
|
+
describedPct: propList.length ? Math.round((described / propList.length) * 100) : 0,
|
|
463
|
+
authoredSections: [],
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
passport,
|
|
469
|
+
api,
|
|
470
|
+
stats: {
|
|
471
|
+
component: name,
|
|
472
|
+
props: propList.length,
|
|
473
|
+
own: propList.filter((p) => p.origin === "derived").length,
|
|
474
|
+
inherited: inheritedConfirmed,
|
|
475
|
+
inheritsEdges: externalBases.size,
|
|
476
|
+
events: events.length,
|
|
477
|
+
slots: passport.slots.length,
|
|
478
|
+
states: passport.states.length,
|
|
479
|
+
api: api.length,
|
|
480
|
+
described,
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = {
|
|
486
|
+
PASSPORT_VERSION,
|
|
487
|
+
loadTypeScript,
|
|
488
|
+
createProgram,
|
|
489
|
+
extractComponent,
|
|
490
|
+
sourceFiles,
|
|
491
|
+
hashSources,
|
|
492
|
+
posix,
|
|
493
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Passport orchestration: generate, check, regen.
|
|
3
|
+
*
|
|
4
|
+
* `generate` and `regen` write passport.json. `check` writes nothing and exits
|
|
5
|
+
* non-zero on drift — that is the CI gate.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
PASSPORT_VERSION, loadTypeScript, createProgram, extractComponent, posix,
|
|
13
|
+
} = require("./extract.cjs");
|
|
14
|
+
const { mergePassport } = require("./merge.cjs");
|
|
15
|
+
const {
|
|
16
|
+
validateStructure, validateReferences, coverageIssues, textIssues,
|
|
17
|
+
} = require("./validate.cjs");
|
|
18
|
+
|
|
19
|
+
const MANUAL_FILE = "passport.manual.json";
|
|
20
|
+
const LOCAL_FILE = "passport.local.json";
|
|
21
|
+
const PASSPORT_FILE = "passport.json";
|
|
22
|
+
|
|
23
|
+
const readJson = (file) => {
|
|
24
|
+
if (!fs.existsSync(file)) return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
const e = new Error(`${file} is not valid JSON: ${error.message}`);
|
|
29
|
+
e.code = "EBADJSON";
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Stable key order + trailing newline, so writes are byte-identical run to run. */
|
|
35
|
+
function serialise(passport) {
|
|
36
|
+
const ORDER = [
|
|
37
|
+
"passportVersion", "identity", "source", "purpose", "props", "inherits", "events",
|
|
38
|
+
"slots", "states", "accessibility", "composition", "operations", "intentDomains",
|
|
39
|
+
"safeMutations", "examples", "coverage",
|
|
40
|
+
];
|
|
41
|
+
const ordered = {};
|
|
42
|
+
for (const key of ORDER) if (passport[key] !== undefined) ordered[key] = passport[key];
|
|
43
|
+
for (const key of Object.keys(passport)) if (!(key in ordered)) ordered[key] = passport[key];
|
|
44
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Components in a beta-components root, excluding `shared`. */
|
|
48
|
+
function listComponents(libRoot) {
|
|
49
|
+
return fs
|
|
50
|
+
.readdirSync(libRoot, { withFileTypes: true })
|
|
51
|
+
.filter((e) => e.isDirectory() && e.name !== "shared")
|
|
52
|
+
.map((e) => e.name)
|
|
53
|
+
.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Best-effort locate React's types so the checker can resolve DOM attributes. */
|
|
57
|
+
function findReactTypes(startDir) {
|
|
58
|
+
let dir = startDir;
|
|
59
|
+
for (let i = 0; i < 6; i++) {
|
|
60
|
+
const candidate = path.join(dir, "node_modules", "@types", "react");
|
|
61
|
+
if (fs.existsSync(candidate)) return posix(candidate);
|
|
62
|
+
const nested = path.join(dir, "demo-showroom", "node_modules", "@types", "react");
|
|
63
|
+
if (fs.existsSync(nested)) return posix(nested);
|
|
64
|
+
const parent = path.dirname(dir);
|
|
65
|
+
if (parent === dir) break;
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the effective passport for every component.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.libRoot directory holding the component folders
|
|
76
|
+
* @param {string} opts.repoRoot for stable relative paths in the hash
|
|
77
|
+
* @param {string} opts.libraryVersion
|
|
78
|
+
* @param {boolean} [opts.includeLocal] read passport.local.json (consumer side)
|
|
79
|
+
* @param {number} [opts.coverageThreshold]
|
|
80
|
+
*/
|
|
81
|
+
function buildAll(opts) {
|
|
82
|
+
const { libRoot, repoRoot, libraryVersion, includeLocal = false, coverageThreshold } = opts;
|
|
83
|
+
const ts = loadTypeScript([repoRoot, path.join(repoRoot, "demo-showroom")]);
|
|
84
|
+
const components = listComponents(libRoot);
|
|
85
|
+
const reactTypes = findReactTypes(repoRoot) || findReactTypes(__dirname);
|
|
86
|
+
const program = createProgram(ts, libRoot, components, reactTypes);
|
|
87
|
+
const checker = program.getTypeChecker();
|
|
88
|
+
|
|
89
|
+
const results = [];
|
|
90
|
+
for (const name of components) {
|
|
91
|
+
const dir = path.join(libRoot, name);
|
|
92
|
+
const { passport: derived, api, stats } = extractComponent(ts, program, checker, {
|
|
93
|
+
name, dir, libRoot, libraryVersion, repoRoot,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const manual = readJson(path.join(dir, MANUAL_FILE));
|
|
97
|
+
const local = includeLocal ? readJson(path.join(dir, LOCAL_FILE)) : {};
|
|
98
|
+
const effective = mergePassport(derived, manual, local);
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Without React's type definitions the checker cannot resolve
|
|
102
|
+
* ButtonHTMLAttributes and friends, so every inherited prop silently
|
|
103
|
+
* disappears — and the reference check then blames the manual file for
|
|
104
|
+
* naming props that "no longer exist". Say what actually happened.
|
|
105
|
+
*/
|
|
106
|
+
const unresolvedInherits =
|
|
107
|
+
!reactTypes && Array.isArray(effective.inherits) && effective.inherits.length > 0;
|
|
108
|
+
|
|
109
|
+
const issues = [
|
|
110
|
+
...validateStructure(effective, name),
|
|
111
|
+
...(unresolvedInherits
|
|
112
|
+
? [{
|
|
113
|
+
level: "warn",
|
|
114
|
+
code: "react-types-unresolved",
|
|
115
|
+
component: name,
|
|
116
|
+
message:
|
|
117
|
+
"React type definitions were not found, so inherited props could not be " +
|
|
118
|
+
"resolved. Install @types/react to get the full prop surface.",
|
|
119
|
+
}]
|
|
120
|
+
: validateReferences(effective, { manual, local, component: name, api })),
|
|
121
|
+
...textIssues(effective, name),
|
|
122
|
+
...coverageIssues(effective, name, coverageThreshold),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
results.push({ name, dir, derived, manual, local, effective, issues, stats, api });
|
|
126
|
+
}
|
|
127
|
+
return { results, reactTypes, components };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write passport.json for each component. Returns the paths written. */
|
|
131
|
+
function generate(opts) {
|
|
132
|
+
const { results } = buildAll(opts);
|
|
133
|
+
const written = [];
|
|
134
|
+
for (const r of results) {
|
|
135
|
+
const file = path.join(r.dir, PASSPORT_FILE);
|
|
136
|
+
const next = serialise(r.effective);
|
|
137
|
+
const prev = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
138
|
+
if (prev !== next) {
|
|
139
|
+
fs.writeFileSync(file, next);
|
|
140
|
+
written.push(posix(path.relative(opts.repoRoot, file)));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { results, written };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* CI gate. Regenerates in memory and compares with what is committed.
|
|
148
|
+
* Never writes.
|
|
149
|
+
*/
|
|
150
|
+
function check(opts) {
|
|
151
|
+
const { results } = buildAll(opts);
|
|
152
|
+
const report = [];
|
|
153
|
+
let errors = 0;
|
|
154
|
+
let warnings = 0;
|
|
155
|
+
|
|
156
|
+
for (const r of results) {
|
|
157
|
+
const file = path.join(r.dir, PASSPORT_FILE);
|
|
158
|
+
const expected = serialise(r.effective);
|
|
159
|
+
const actual = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
160
|
+
|
|
161
|
+
const problems = [...r.issues];
|
|
162
|
+
if (actual === null) {
|
|
163
|
+
problems.unshift({ level: "error", code: "missing-passport", component: r.name,
|
|
164
|
+
message: "no passport.json committed; run `npm run passport`" });
|
|
165
|
+
} else if (actual !== expected) {
|
|
166
|
+
const reason =
|
|
167
|
+
JSON.parse(actual).source?.sourceHash !== r.effective.source.sourceHash
|
|
168
|
+
? "source changed since the passport was generated"
|
|
169
|
+
: "committed passport differs from generated output";
|
|
170
|
+
problems.unshift({ level: "error", code: "stale-passport", component: r.name,
|
|
171
|
+
message: `passport stale — ${reason}; run \`npm run passport\`` });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
errors += problems.filter((p) => p.level === "error").length;
|
|
175
|
+
warnings += problems.filter((p) => p.level === "warn").length;
|
|
176
|
+
report.push({ component: r.name, ok: problems.every((p) => p.level !== "error"), problems, stats: r.stats });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { report, errors, warnings, results };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
PASSPORT_VERSION, MANUAL_FILE, LOCAL_FILE, PASSPORT_FILE,
|
|
184
|
+
buildAll, generate, check, serialise, listComponents, readJson, findReactTypes,
|
|
185
|
+
};
|