@larose-ui/cli 0.1.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/LICENSE +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1180 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 laRose contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
|
|
7
|
+
// src/doctor.ts
|
|
8
|
+
import { readdir as readdir2, readFile as readFile2, stat, writeFile } from "fs/promises";
|
|
9
|
+
import { join as join2, relative } from "path";
|
|
10
|
+
|
|
11
|
+
// ../contracts/dist/index.js
|
|
12
|
+
function validateContract(ui, api) {
|
|
13
|
+
const mismatches = [];
|
|
14
|
+
const apiFields = new Map(api.fields.map((f) => [f.name, f]));
|
|
15
|
+
const uiFields = new Map(ui.fields.map((f) => [f.name, f]));
|
|
16
|
+
for (const uiField of ui.fields) {
|
|
17
|
+
const apiField = apiFields.get(uiField.name);
|
|
18
|
+
if (!apiField) {
|
|
19
|
+
mismatches.push({
|
|
20
|
+
field: uiField.name,
|
|
21
|
+
issue: "missing_in_api",
|
|
22
|
+
message: `UI expects "${uiField.name}" but API schema does not include it`,
|
|
23
|
+
severity: uiField.required ? "error" : "warning"
|
|
24
|
+
});
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (uiField.type && apiField.type && uiField.type !== apiField.type) {
|
|
28
|
+
mismatches.push({
|
|
29
|
+
field: uiField.name,
|
|
30
|
+
issue: "type_mismatch",
|
|
31
|
+
message: `Type mismatch for "${uiField.name}": UI=${uiField.type}, API=${apiField.type}`,
|
|
32
|
+
severity: "error"
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (uiField.required && !apiField.required) {
|
|
36
|
+
mismatches.push({
|
|
37
|
+
field: uiField.name,
|
|
38
|
+
issue: "required_mismatch",
|
|
39
|
+
message: `"${uiField.name}" is required in UI but optional in API`,
|
|
40
|
+
severity: "warning"
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const apiField of api.fields) {
|
|
45
|
+
if (!uiFields.has(apiField.name) && apiField.required) {
|
|
46
|
+
mismatches.push({
|
|
47
|
+
field: apiField.name,
|
|
48
|
+
issue: "missing_in_ui",
|
|
49
|
+
message: `API requires "${apiField.name}" but UI schema does not include it`,
|
|
50
|
+
severity: "error"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
valid: mismatches.filter((m) => m.severity === "error").length === 0,
|
|
56
|
+
mismatches
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ../accessibility/dist/index.js
|
|
61
|
+
var RULES = [
|
|
62
|
+
{
|
|
63
|
+
id: "dialog-label",
|
|
64
|
+
pattern: /role=["']dialog["']/,
|
|
65
|
+
missing: /aria-labelledby|aria-label/,
|
|
66
|
+
message: "Dialog/Modal may be missing accessible label",
|
|
67
|
+
fix: "Add title prop or aria-labelledby",
|
|
68
|
+
severity: "error"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "empty-button",
|
|
72
|
+
pattern: /<button[^>]*>\s*<\/button>/,
|
|
73
|
+
missing: /aria-label/,
|
|
74
|
+
message: "Empty button without aria-label",
|
|
75
|
+
fix: "Add aria-label or visible text",
|
|
76
|
+
severity: "warning"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "img-alt",
|
|
80
|
+
pattern: /<img(?![^>]*\balt=)/,
|
|
81
|
+
message: "Image missing alt attribute",
|
|
82
|
+
fix: 'Add alt="" for decorative or descriptive alt text',
|
|
83
|
+
severity: "error"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "input-label",
|
|
87
|
+
pattern: /<input[^>]*\bid=["']([^"']+)["']/,
|
|
88
|
+
requiresLabelFor: true,
|
|
89
|
+
message: "Input with id may be missing associated label",
|
|
90
|
+
fix: 'Use <label htmlFor="..."> or aria-label',
|
|
91
|
+
severity: "warning"
|
|
92
|
+
}
|
|
93
|
+
];
|
|
94
|
+
function scanComponentSource(source, filePath) {
|
|
95
|
+
const violations = [];
|
|
96
|
+
const lines = source.split("\n");
|
|
97
|
+
for (const rule of RULES) {
|
|
98
|
+
if (rule.id === "input-label") {
|
|
99
|
+
for (let i = 0; i < lines.length; i++) {
|
|
100
|
+
const line = lines[i];
|
|
101
|
+
const idMatch = line.match(/<input[^>]*\bid=["']([^"']+)["']/);
|
|
102
|
+
if (!idMatch) continue;
|
|
103
|
+
const id = idMatch[1];
|
|
104
|
+
const hasLabel = source.includes(`htmlFor="${id}"`) || source.includes(`htmlFor='${id}'`) || line.includes("aria-label");
|
|
105
|
+
if (!hasLabel) {
|
|
106
|
+
violations.push({
|
|
107
|
+
severity: rule.severity,
|
|
108
|
+
rule: rule.id,
|
|
109
|
+
message: rule.message,
|
|
110
|
+
file: filePath,
|
|
111
|
+
line: i + 1,
|
|
112
|
+
fix: rule.fix
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!rule.pattern.test(source)) continue;
|
|
119
|
+
if ("missing" in rule && rule.missing && rule.missing.test(source)) continue;
|
|
120
|
+
violations.push({
|
|
121
|
+
severity: rule.severity,
|
|
122
|
+
rule: rule.id,
|
|
123
|
+
message: rule.message,
|
|
124
|
+
file: filePath,
|
|
125
|
+
fix: rule.fix
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
passed: violations.filter((v) => v.severity === "error").length === 0,
|
|
130
|
+
violations
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
var RECOMMENDED_CSP = [
|
|
134
|
+
"default-src 'self'",
|
|
135
|
+
"script-src 'self'",
|
|
136
|
+
"style-src 'self' 'unsafe-inline'",
|
|
137
|
+
"img-src 'self' data: https:"
|
|
138
|
+
].join("; ");
|
|
139
|
+
|
|
140
|
+
// ../migration/dist/index.js
|
|
141
|
+
var TOKEN_RENAMES = {
|
|
142
|
+
"--ui-color-primary": "--lr-color-primary",
|
|
143
|
+
"--ui-color-secondary": "--lr-color-secondary",
|
|
144
|
+
"--ui-color-success": "--lr-color-success",
|
|
145
|
+
"--ui-color-warning": "--lr-color-warning",
|
|
146
|
+
"--ui-color-error": "--lr-color-error",
|
|
147
|
+
"--ui-color-background": "--lr-color-background",
|
|
148
|
+
"--ui-color-surface": "--lr-color-surface",
|
|
149
|
+
"--ui-color-border": "--lr-color-border",
|
|
150
|
+
"--ui-color-text": "--lr-color-text"
|
|
151
|
+
};
|
|
152
|
+
function renameTokens(source) {
|
|
153
|
+
let content = source;
|
|
154
|
+
const transforms = [];
|
|
155
|
+
for (const [from, to] of Object.entries(TOKEN_RENAMES)) {
|
|
156
|
+
if (content.includes(from)) {
|
|
157
|
+
content = content.replaceAll(from, to);
|
|
158
|
+
transforms.push(`token:${from}\u2192${to}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { content, changed: content !== source, transforms };
|
|
162
|
+
}
|
|
163
|
+
function fixLaRoseProviderImport(source) {
|
|
164
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"]@larose-ui\/react['"]\s*;?/g;
|
|
165
|
+
let content = source;
|
|
166
|
+
let changed = false;
|
|
167
|
+
const transforms = [];
|
|
168
|
+
content = content.replace(importRegex, (full, specifiers) => {
|
|
169
|
+
if (!specifiers.includes("LaRoseProvider")) return full;
|
|
170
|
+
const parts = specifiers.split(",").map((s) => s.trim()).filter(Boolean);
|
|
171
|
+
const reactParts = parts.filter((p) => p !== "LaRoseProvider");
|
|
172
|
+
changed = true;
|
|
173
|
+
transforms.push("import:LaRoseProvider\u2192@larose-ui/runtime");
|
|
174
|
+
const runtimeImport = "import { LaRoseProvider } from '@larose-ui/runtime';";
|
|
175
|
+
if (reactParts.length === 0) {
|
|
176
|
+
return runtimeImport;
|
|
177
|
+
}
|
|
178
|
+
return `${runtimeImport}
|
|
179
|
+
import { ${reactParts.join(", ")} } from '@larose-ui/react';`;
|
|
180
|
+
});
|
|
181
|
+
return { content, changed, transforms };
|
|
182
|
+
}
|
|
183
|
+
function fixToastImport(source) {
|
|
184
|
+
const regex = /import\s+\{([^}]*\buseToast\b[^}]*)\}\s+from\s+['"]@larose-ui\/runtime['"]\s*;?/g;
|
|
185
|
+
let content = source;
|
|
186
|
+
let changed = false;
|
|
187
|
+
const transforms = [];
|
|
188
|
+
content = content.replace(regex, (_full, specifiers) => {
|
|
189
|
+
changed = true;
|
|
190
|
+
transforms.push("import:useToast\u2192@larose-ui/runtime/toast");
|
|
191
|
+
const parts = specifiers.split(",").map((s) => s.trim()).filter(Boolean);
|
|
192
|
+
const toastParts = parts.filter((p) => p !== "useToast");
|
|
193
|
+
const toastImport = "import { useToast } from '@larose-ui/runtime/toast';";
|
|
194
|
+
if (toastParts.length === 0) return toastImport;
|
|
195
|
+
return `${toastImport}
|
|
196
|
+
import { ${toastParts.join(", ")} } from '@larose-ui/runtime';`;
|
|
197
|
+
});
|
|
198
|
+
return { content, changed, transforms };
|
|
199
|
+
}
|
|
200
|
+
function applyCodemods(source) {
|
|
201
|
+
const steps = [renameTokens, fixLaRoseProviderImport, fixToastImport];
|
|
202
|
+
let content = source;
|
|
203
|
+
const allTransforms = [];
|
|
204
|
+
let changed = false;
|
|
205
|
+
for (const step of steps) {
|
|
206
|
+
const result = step(content);
|
|
207
|
+
content = result.content;
|
|
208
|
+
if (result.changed) {
|
|
209
|
+
changed = true;
|
|
210
|
+
allTransforms.push(...result.transforms);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { content, changed, transforms: allTransforms };
|
|
214
|
+
}
|
|
215
|
+
function applyCodemodsToFiles(files) {
|
|
216
|
+
return files.map((file) => {
|
|
217
|
+
const result = applyCodemods(file.content);
|
|
218
|
+
return {
|
|
219
|
+
path: file.path,
|
|
220
|
+
changed: result.changed,
|
|
221
|
+
transforms: result.transforms,
|
|
222
|
+
content: result.content
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function formatCodemodReport(results) {
|
|
227
|
+
const changed = results.filter((r) => r.changed);
|
|
228
|
+
if (changed.length === 0) {
|
|
229
|
+
return "No files required codemod transforms.";
|
|
230
|
+
}
|
|
231
|
+
const lines = [`Applied codemods to ${changed.length} file(s):`, ""];
|
|
232
|
+
for (const file of changed) {
|
|
233
|
+
lines.push(`${file.path}`);
|
|
234
|
+
for (const t of file.transforms) {
|
|
235
|
+
lines.push(` - ${t}`);
|
|
236
|
+
}
|
|
237
|
+
lines.push("");
|
|
238
|
+
}
|
|
239
|
+
return lines.join("\n");
|
|
240
|
+
}
|
|
241
|
+
function slug(name) {
|
|
242
|
+
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
243
|
+
}
|
|
244
|
+
function generateForm(name, options = {}) {
|
|
245
|
+
const id = options.resource ?? slug(name);
|
|
246
|
+
return `import { Form } from '@larose-ui/forms';
|
|
247
|
+
import { LaRoseProvider } from '@larose-ui/runtime';
|
|
248
|
+
import { Can } from '@larose-ui/permissions';
|
|
249
|
+
|
|
250
|
+
const ${name}Schema = {
|
|
251
|
+
id: '${id}',
|
|
252
|
+
title: '${name}',
|
|
253
|
+
fields: [
|
|
254
|
+
{ name: 'name', type: 'text' as const, label: 'Name', required: true },
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export function ${name}Form() {
|
|
259
|
+
return (
|
|
260
|
+
<LaRoseProvider permissions={['${id}.write']}>
|
|
261
|
+
<Can permission="${id}.write">
|
|
262
|
+
<Form schema={${name}Schema} submitLabel="Save" />
|
|
263
|
+
</Can>
|
|
264
|
+
</LaRoseProvider>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
function generatePage(name, options = {}) {
|
|
270
|
+
const resource = options.resource ?? slug(name);
|
|
271
|
+
const permission = options.permissionPrefix ?? resource;
|
|
272
|
+
return `import { LaRoseProvider } from '@larose-ui/runtime';
|
|
273
|
+
import { DataView } from '@larose-ui/data';
|
|
274
|
+
import { useJourneyPage } from '@larose-ui/observability';
|
|
275
|
+
|
|
276
|
+
export function ${name}Page() {
|
|
277
|
+
useJourneyPage('${resource}');
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<LaRoseProvider permissions={['${permission}.read']} tenant={{ id: 'acme', name: 'ACME' }}>
|
|
281
|
+
<DataView url="/api/${resource}" permission="${permission}.read">
|
|
282
|
+
{(data) => <pre>{JSON.stringify(data, null, 2)}</pre>}
|
|
283
|
+
</DataView>
|
|
284
|
+
</LaRoseProvider>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
`;
|
|
288
|
+
}
|
|
289
|
+
function generateFeature(name, options = {}) {
|
|
290
|
+
const resource = options.resource ?? slug(name);
|
|
291
|
+
const permission = options.permissionPrefix ?? resource;
|
|
292
|
+
const pascal = name.replace(/(^\w|-\w)/g, (m) => m.replace("-", "").toUpperCase());
|
|
293
|
+
return `import { LaRoseProvider } from '@larose-ui/runtime';
|
|
294
|
+
import { DataView } from '@larose-ui/data';
|
|
295
|
+
import { SmartTable } from '@larose-ui/ai';
|
|
296
|
+
import { DevToolsProvider } from '@larose-ui/devtools';
|
|
297
|
+
import { useJourneyPage } from '@larose-ui/observability';
|
|
298
|
+
import { Can } from '@larose-ui/permissions';
|
|
299
|
+
import { Card } from '@larose-ui/react';
|
|
300
|
+
|
|
301
|
+
interface ${pascal}Row {
|
|
302
|
+
id: string;
|
|
303
|
+
name: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function ${name}Feature() {
|
|
307
|
+
useJourneyPage('${resource}');
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<LaRoseProvider
|
|
311
|
+
permissions={['${permission}.read']}
|
|
312
|
+
tenant={{ id: 'acme', name: 'ACME' }}
|
|
313
|
+
session="authenticated"
|
|
314
|
+
>
|
|
315
|
+
<DevToolsProvider>
|
|
316
|
+
<Card title="${name}" padding="md">
|
|
317
|
+
<Can permission="${permission}.read">
|
|
318
|
+
<DataView<${pascal}Row[]> url="/api/${resource}" permission="${permission}.read">
|
|
319
|
+
{(rows) => (
|
|
320
|
+
<SmartTable
|
|
321
|
+
readPermission="${permission}.read"
|
|
322
|
+
data={rows}
|
|
323
|
+
keyExtractor={(row) => row.id}
|
|
324
|
+
columns={[
|
|
325
|
+
{ key: 'id', header: 'ID', priority: 'low' },
|
|
326
|
+
{ key: 'name', header: 'Name', priority: 'high' },
|
|
327
|
+
]}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
</DataView>
|
|
331
|
+
</Can>
|
|
332
|
+
</Card>
|
|
333
|
+
</DevToolsProvider>
|
|
334
|
+
</LaRoseProvider>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
`;
|
|
338
|
+
}
|
|
339
|
+
function runGenerator(kind, name, options) {
|
|
340
|
+
switch (kind) {
|
|
341
|
+
case "form":
|
|
342
|
+
return generateForm(name, options);
|
|
343
|
+
case "page":
|
|
344
|
+
return generatePage(name, options);
|
|
345
|
+
case "feature":
|
|
346
|
+
return generateFeature(name, options);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function analyzeRelease(manifests) {
|
|
350
|
+
const packages = manifests.map((manifest) => {
|
|
351
|
+
const issues = [];
|
|
352
|
+
const isPrivate = manifest.private === true;
|
|
353
|
+
if (!manifest.version) issues.push("missing version");
|
|
354
|
+
if (!isPrivate && !manifest.license) issues.push("missing license");
|
|
355
|
+
if (!isPrivate && !manifest.publishConfig?.access) {
|
|
356
|
+
issues.push("missing publishConfig.access");
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
name: manifest.name,
|
|
360
|
+
version: manifest.version ?? "0.0.0",
|
|
361
|
+
directory: manifest.directory,
|
|
362
|
+
private: isPrivate,
|
|
363
|
+
publishReady: issues.length === 0,
|
|
364
|
+
issues
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
const publishable = packages.filter((pkg) => !pkg.private);
|
|
368
|
+
const versions = publishable.map((pkg) => pkg.version);
|
|
369
|
+
const versionCounts = /* @__PURE__ */ new Map();
|
|
370
|
+
for (const version of versions) {
|
|
371
|
+
versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
|
|
372
|
+
}
|
|
373
|
+
let canonicalVersion = null;
|
|
374
|
+
let maxCount = 0;
|
|
375
|
+
for (const [version, count] of versionCounts) {
|
|
376
|
+
if (count > maxCount) {
|
|
377
|
+
maxCount = count;
|
|
378
|
+
canonicalVersion = version;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const drift = canonicalVersion === null ? [] : publishable.filter((pkg) => pkg.version !== canonicalVersion).map((pkg) => ({
|
|
382
|
+
name: pkg.name,
|
|
383
|
+
version: pkg.version,
|
|
384
|
+
expected: canonicalVersion
|
|
385
|
+
}));
|
|
386
|
+
const recommendations = [];
|
|
387
|
+
if (drift.length > 0) {
|
|
388
|
+
recommendations.push(
|
|
389
|
+
`Align ${drift.length} package version(s) to ${canonicalVersion} before release`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (publishable.some((pkg) => !pkg.publishReady)) {
|
|
393
|
+
recommendations.push("Fix publish metadata on packages flagged not publish-ready");
|
|
394
|
+
}
|
|
395
|
+
if (publishable.length > 0 && drift.length === 0) {
|
|
396
|
+
recommendations.push("Versions aligned \u2014 run pnpm verify:publish and pnpm build");
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
400
|
+
packageCount: packages.length,
|
|
401
|
+
publishableCount: publishable.length,
|
|
402
|
+
aligned: drift.length === 0,
|
|
403
|
+
canonicalVersion,
|
|
404
|
+
packages,
|
|
405
|
+
drift,
|
|
406
|
+
recommendations
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function formatReleaseReport(report) {
|
|
410
|
+
const lines = [
|
|
411
|
+
"laRose Release Intelligence",
|
|
412
|
+
"",
|
|
413
|
+
`Packages: ${report.packageCount} (${report.publishableCount} publishable)`,
|
|
414
|
+
`Canonical version: ${report.canonicalVersion ?? "n/a"}`,
|
|
415
|
+
`Aligned: ${report.aligned ? "yes" : "no"}`,
|
|
416
|
+
""
|
|
417
|
+
];
|
|
418
|
+
if (report.drift.length > 0) {
|
|
419
|
+
lines.push("Version drift:");
|
|
420
|
+
for (const entry of report.drift) {
|
|
421
|
+
lines.push(` ${entry.name}: ${entry.version} (expected ${entry.expected})`);
|
|
422
|
+
}
|
|
423
|
+
lines.push("");
|
|
424
|
+
}
|
|
425
|
+
const blocked = report.packages.filter((pkg) => !pkg.private && !pkg.publishReady);
|
|
426
|
+
if (blocked.length > 0) {
|
|
427
|
+
lines.push("Publish blockers:");
|
|
428
|
+
for (const pkg of blocked) {
|
|
429
|
+
lines.push(` ${pkg.name}: ${pkg.issues.join(", ")}`);
|
|
430
|
+
}
|
|
431
|
+
lines.push("");
|
|
432
|
+
}
|
|
433
|
+
if (report.recommendations.length > 0) {
|
|
434
|
+
lines.push("Recommendations:");
|
|
435
|
+
for (const tip of report.recommendations) {
|
|
436
|
+
lines.push(` - ${tip}`);
|
|
437
|
+
}
|
|
438
|
+
lines.push("");
|
|
439
|
+
}
|
|
440
|
+
lines.push(report.aligned && blocked.length === 0 ? "Result: READY" : "Result: REVIEW");
|
|
441
|
+
return lines.join("\n");
|
|
442
|
+
}
|
|
443
|
+
function formatReleaseJson(report) {
|
|
444
|
+
return JSON.stringify(report, null, 2);
|
|
445
|
+
}
|
|
446
|
+
var DEPRECATIONS = [
|
|
447
|
+
{
|
|
448
|
+
id: "role-check",
|
|
449
|
+
pattern: /user\.role\s*===\s*['"]admin['"]/g,
|
|
450
|
+
message: "Inline role checks are deprecated",
|
|
451
|
+
replacement: 'Use <Can permission="..."> from @larose-ui/permissions',
|
|
452
|
+
removedIn: "1.0.0"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
id: "old-token",
|
|
456
|
+
pattern: /--ui-color-primary/g,
|
|
457
|
+
message: "Old token prefix --ui-color-* is deprecated",
|
|
458
|
+
replacement: "Use --lr-color-* tokens from @larose-ui/tokens",
|
|
459
|
+
removedIn: "1.0.0"
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "react-provider",
|
|
463
|
+
pattern: /from\s+['"]@larose-ui\/react['"].*LaRoseProvider/g,
|
|
464
|
+
message: "Import LaRoseProvider from @larose-ui/runtime instead",
|
|
465
|
+
replacement: "import { LaRoseProvider } from '@larose-ui/runtime'",
|
|
466
|
+
removedIn: "0.2.0"
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
id: "runtime-toast",
|
|
470
|
+
pattern: /import\s+\{([^}]*\buseToast\b[^}]*)\}\s+from\s+['"]@larose-ui\/runtime['"]/g,
|
|
471
|
+
message: "Import useToast from @larose-ui/runtime/toast",
|
|
472
|
+
replacement: "import { useToast } from '@larose-ui/runtime/toast'",
|
|
473
|
+
removedIn: "0.2.0"
|
|
474
|
+
}
|
|
475
|
+
];
|
|
476
|
+
function scanSource(source, filePath) {
|
|
477
|
+
const matches = [];
|
|
478
|
+
const lines = source.split("\n");
|
|
479
|
+
for (const dep of DEPRECATIONS) {
|
|
480
|
+
dep.pattern.lastIndex = 0;
|
|
481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
482
|
+
const line = lines[i];
|
|
483
|
+
const lineMatches = line.matchAll(dep.pattern);
|
|
484
|
+
for (const m of lineMatches) {
|
|
485
|
+
matches.push({
|
|
486
|
+
id: dep.id,
|
|
487
|
+
file: filePath,
|
|
488
|
+
line: i + 1,
|
|
489
|
+
column: (m.index ?? 0) + 1,
|
|
490
|
+
message: dep.message,
|
|
491
|
+
replacement: dep.replacement
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return matches;
|
|
497
|
+
}
|
|
498
|
+
function generateMigrationReport(files, targetVersion = "1.0.0") {
|
|
499
|
+
const deprecatedUsages = files.flatMap((f) => scanSource(f.content, f.path));
|
|
500
|
+
return {
|
|
501
|
+
targetVersion,
|
|
502
|
+
deprecatedUsages,
|
|
503
|
+
breakingChanges: deprecatedUsages.filter(
|
|
504
|
+
(d) => DEPRECATIONS.find((x) => x.id === d.id)?.removedIn === targetVersion
|
|
505
|
+
).length,
|
|
506
|
+
summary: `${deprecatedUsages.length} deprecated usages found`
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function formatMigrationReport(report) {
|
|
510
|
+
const lines = [
|
|
511
|
+
`laRose Migration Report (target: v${report.targetVersion})`,
|
|
512
|
+
"",
|
|
513
|
+
report.summary,
|
|
514
|
+
`Breaking changes: ${report.breakingChanges}`,
|
|
515
|
+
""
|
|
516
|
+
];
|
|
517
|
+
if (report.deprecatedUsages.length === 0) {
|
|
518
|
+
lines.push("No deprecated patterns detected.");
|
|
519
|
+
} else {
|
|
520
|
+
for (const u of report.deprecatedUsages) {
|
|
521
|
+
lines.push(`${u.file}:${u.line}:${u.column} [${u.id}]`);
|
|
522
|
+
lines.push(` ${u.message}`);
|
|
523
|
+
lines.push(` Fix: ${u.replacement}`);
|
|
524
|
+
lines.push("");
|
|
525
|
+
}
|
|
526
|
+
lines.push(`Run: larose migrate --to ${report.targetVersion}`);
|
|
527
|
+
}
|
|
528
|
+
return lines.join("\n");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// src/quality/browserMatrix.ts
|
|
532
|
+
var DEFAULT_BROWSER_MATRIX = {
|
|
533
|
+
version: 1,
|
|
534
|
+
browsers: [
|
|
535
|
+
{ name: "chrome", minVersion: 120, engine: "Blink" },
|
|
536
|
+
{ name: "firefox", minVersion: 121, engine: "Gecko" },
|
|
537
|
+
{ name: "safari", minVersion: 17, engine: "WebKit" },
|
|
538
|
+
{ name: "edge", minVersion: 120, engine: "Blink" }
|
|
539
|
+
],
|
|
540
|
+
engines: {
|
|
541
|
+
node: ">=20"
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
function validateBrowserMatrix(matrix, nodeEngine) {
|
|
545
|
+
const issues = [];
|
|
546
|
+
if (matrix.browsers.length === 0) {
|
|
547
|
+
issues.push("Browser matrix must define at least one browser target");
|
|
548
|
+
}
|
|
549
|
+
for (const browser of matrix.browsers) {
|
|
550
|
+
if (!browser.name.trim()) issues.push("Browser target missing name");
|
|
551
|
+
if (browser.minVersion <= 0) {
|
|
552
|
+
issues.push(`Browser ${browser.name} has invalid minVersion`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (nodeEngine && matrix.engines.node) {
|
|
556
|
+
const expected = normalizeEngine(matrix.engines.node);
|
|
557
|
+
const actual = normalizeEngine(nodeEngine);
|
|
558
|
+
if (expected && actual && expected !== actual) {
|
|
559
|
+
issues.push(
|
|
560
|
+
`Node engine mismatch: package.json specifies "${nodeEngine}", matrix requires "${matrix.engines.node}"`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
passed: issues.length === 0,
|
|
566
|
+
matrix,
|
|
567
|
+
nodeEngine,
|
|
568
|
+
issues
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function normalizeEngine(value) {
|
|
572
|
+
return value.replace(/\s+/g, "");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/quality/qualityScores.ts
|
|
576
|
+
var ERROR_PENALTY = 15;
|
|
577
|
+
var WARNING_PENALTY = 5;
|
|
578
|
+
function computeQualityScores(diagnostics, options = {}) {
|
|
579
|
+
const treatWarningsAsErrors = options.ci === true;
|
|
580
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
581
|
+
const packageMap = /* @__PURE__ */ new Map();
|
|
582
|
+
for (const diagnostic of diagnostics) {
|
|
583
|
+
const isError = diagnostic.severity === "error" || treatWarningsAsErrors && diagnostic.severity === "warning";
|
|
584
|
+
if (diagnostic.category === "bundle") {
|
|
585
|
+
const pkgMatch = diagnostic.message.match(/^(@larose-ui\/[^\s]+)/);
|
|
586
|
+
const pkg = pkgMatch?.[1] ?? "unknown";
|
|
587
|
+
const entry2 = packageMap.get(pkg) ?? {
|
|
588
|
+
package: pkg,
|
|
589
|
+
score: 100,
|
|
590
|
+
issues: 0
|
|
591
|
+
};
|
|
592
|
+
entry2.issues += 1;
|
|
593
|
+
entry2.score -= isError ? ERROR_PENALTY : WARNING_PENALTY;
|
|
594
|
+
const sizeMatch = diagnostic.message.match(/bundle\s+([\d.]+)KB/);
|
|
595
|
+
if (sizeMatch?.[1]) entry2.bundleKb = Number(sizeMatch[1]);
|
|
596
|
+
packageMap.set(pkg, entry2);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const componentId = diagnostic.file ?? "unknown";
|
|
600
|
+
const entry = componentMap.get(componentId) ?? {
|
|
601
|
+
id: componentId,
|
|
602
|
+
score: 100,
|
|
603
|
+
errors: 0,
|
|
604
|
+
warnings: 0,
|
|
605
|
+
categories: []
|
|
606
|
+
};
|
|
607
|
+
if (diagnostic.severity === "error") entry.errors += 1;
|
|
608
|
+
if (diagnostic.severity === "warning") entry.warnings += 1;
|
|
609
|
+
entry.score -= isError ? ERROR_PENALTY : WARNING_PENALTY;
|
|
610
|
+
if (!entry.categories.includes(diagnostic.category)) {
|
|
611
|
+
entry.categories.push(diagnostic.category);
|
|
612
|
+
}
|
|
613
|
+
componentMap.set(componentId, entry);
|
|
614
|
+
}
|
|
615
|
+
const components = [...componentMap.values()].map((entry) => ({ ...entry, score: clampScore(entry.score) })).sort((a, b) => a.score - b.score);
|
|
616
|
+
const packages = [...packageMap.values()].map((entry) => ({ ...entry, score: clampScore(entry.score) })).sort((a, b) => a.score - b.score);
|
|
617
|
+
const overall = clampScore(
|
|
618
|
+
average([
|
|
619
|
+
...components.map((entry) => entry.score),
|
|
620
|
+
...packages.map((entry) => entry.score)
|
|
621
|
+
]) ?? 100
|
|
622
|
+
);
|
|
623
|
+
return { overall, components, packages };
|
|
624
|
+
}
|
|
625
|
+
function average(values) {
|
|
626
|
+
if (values.length === 0) return void 0;
|
|
627
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
628
|
+
}
|
|
629
|
+
function clampScore(score) {
|
|
630
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
631
|
+
}
|
|
632
|
+
function qualityPassed(diagnostics, options = {}) {
|
|
633
|
+
const treatWarningsAsErrors = options.ci === true;
|
|
634
|
+
const hasBlockingDiagnostics = diagnostics.some(
|
|
635
|
+
(diagnostic) => treatWarningsAsErrors ? diagnostic.severity === "error" || diagnostic.severity === "warning" : diagnostic.severity === "error"
|
|
636
|
+
);
|
|
637
|
+
if (hasBlockingDiagnostics) return false;
|
|
638
|
+
if (options.browserMatrix && !options.browserMatrix.passed) return false;
|
|
639
|
+
if (options.visualRegression && !options.visualRegression.passed) return false;
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/quality/visualManifest.ts
|
|
644
|
+
import { readdir, readFile } from "fs/promises";
|
|
645
|
+
import { join } from "path";
|
|
646
|
+
var META_TITLE_PATTERN = /const\s+meta[\s\S]*?title:\s*['"]([^'"]+)['"]/;
|
|
647
|
+
async function scanStoryManifest(storiesDir) {
|
|
648
|
+
const entries = await readdir(storiesDir, { withFileTypes: true });
|
|
649
|
+
const stories = [];
|
|
650
|
+
for (const entry of entries) {
|
|
651
|
+
if (!entry.isFile() || !entry.name.endsWith(".stories.tsx")) continue;
|
|
652
|
+
const file = entry.name;
|
|
653
|
+
const source = await readFile(join(storiesDir, file), "utf-8");
|
|
654
|
+
stories.push({
|
|
655
|
+
file,
|
|
656
|
+
title: extractStoryTitle(source, file)
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return stories.sort((a, b) => a.file.localeCompare(b.file));
|
|
660
|
+
}
|
|
661
|
+
function extractStoryTitle(source, file) {
|
|
662
|
+
const metaMatch = source.match(META_TITLE_PATTERN);
|
|
663
|
+
if (metaMatch?.[1]) return metaMatch[1];
|
|
664
|
+
const metaIndex = source.indexOf("const meta");
|
|
665
|
+
if (metaIndex >= 0) {
|
|
666
|
+
const slice = source.slice(metaIndex, metaIndex + 600);
|
|
667
|
+
const titleMatch = slice.match(/title:\s*['"]([^'"]+)['"]/);
|
|
668
|
+
if (titleMatch?.[1]) return titleMatch[1];
|
|
669
|
+
}
|
|
670
|
+
const head = source.split("\n").slice(0, 25).join("\n");
|
|
671
|
+
const headMatch = head.match(/title:\s*['"]([^'"]+)['"]/);
|
|
672
|
+
return headMatch?.[1] ?? file.replace(".stories.tsx", "");
|
|
673
|
+
}
|
|
674
|
+
function compareVisualBaseline(current, baseline) {
|
|
675
|
+
const baselineByFile = new Map(baseline.stories.map((story) => [story.file, story]));
|
|
676
|
+
const currentByFile = new Map(current.map((story) => [story.file, story]));
|
|
677
|
+
const missing = baseline.stories.filter((story) => !currentByFile.has(story.file));
|
|
678
|
+
const added = current.filter((story) => !baselineByFile.has(story.file));
|
|
679
|
+
const changed = [];
|
|
680
|
+
for (const story of current) {
|
|
681
|
+
const base = baselineByFile.get(story.file);
|
|
682
|
+
if (base && base.title !== story.title) {
|
|
683
|
+
changed.push({
|
|
684
|
+
file: story.file,
|
|
685
|
+
baselineTitle: base.title,
|
|
686
|
+
currentTitle: story.title
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
passed: missing.length === 0 && changed.length === 0,
|
|
692
|
+
current,
|
|
693
|
+
missing,
|
|
694
|
+
added,
|
|
695
|
+
changed
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function formatVisualRegressionReport(result) {
|
|
699
|
+
const lines = ["Visual regression manifest", ""];
|
|
700
|
+
if (result.passed && result.added.length === 0) {
|
|
701
|
+
lines.push(`All ${result.current.length} baseline stories present.`);
|
|
702
|
+
return lines.join("\n");
|
|
703
|
+
}
|
|
704
|
+
if (result.missing.length > 0) {
|
|
705
|
+
lines.push("Missing stories (removed or renamed):");
|
|
706
|
+
for (const story of result.missing) {
|
|
707
|
+
lines.push(` - ${story.file} (${story.title})`);
|
|
708
|
+
}
|
|
709
|
+
lines.push("");
|
|
710
|
+
}
|
|
711
|
+
if (result.changed.length > 0) {
|
|
712
|
+
lines.push("Changed story titles:");
|
|
713
|
+
for (const change of result.changed) {
|
|
714
|
+
lines.push(
|
|
715
|
+
` - ${change.file}: "${change.baselineTitle}" \u2192 "${change.currentTitle}"`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
lines.push("");
|
|
719
|
+
}
|
|
720
|
+
if (result.added.length > 0) {
|
|
721
|
+
lines.push("New stories (update quality/visual-baseline.json):");
|
|
722
|
+
for (const story of result.added) {
|
|
723
|
+
lines.push(` + ${story.file} (${story.title})`);
|
|
724
|
+
}
|
|
725
|
+
lines.push("");
|
|
726
|
+
}
|
|
727
|
+
lines.push(result.passed ? "Result: PASS" : "Result: FAIL");
|
|
728
|
+
return lines.join("\n");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// src/doctor.ts
|
|
732
|
+
var BUNDLE_BUDGETS_KB = {
|
|
733
|
+
"@larose-ui/core": 10,
|
|
734
|
+
"@larose-ui/tokens": 5,
|
|
735
|
+
"@larose-ui/network": 6,
|
|
736
|
+
"@larose-ui/offline": 5,
|
|
737
|
+
"@larose-ui/permissions": 8,
|
|
738
|
+
"@larose-ui/data": 15,
|
|
739
|
+
"@larose-ui/forms": 10,
|
|
740
|
+
"@larose-ui/react": 70,
|
|
741
|
+
"@larose-ui/runtime": 36,
|
|
742
|
+
"@larose-ui/observability": 26,
|
|
743
|
+
"@larose-ui/contracts": 5,
|
|
744
|
+
"@larose-ui/migration": 14,
|
|
745
|
+
"@larose-ui/testing": 10,
|
|
746
|
+
"@larose-ui/cli": 50,
|
|
747
|
+
"@larose-ui/devtools": 22,
|
|
748
|
+
"@larose-ui/enterprise": 25,
|
|
749
|
+
"@larose-ui/ai": 20,
|
|
750
|
+
"@larose-ui/accessibility": 5,
|
|
751
|
+
"@larose-ui/themes": 5
|
|
752
|
+
};
|
|
753
|
+
async function walkDir(dir, ext) {
|
|
754
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
755
|
+
const files = [];
|
|
756
|
+
for (const entry of entries) {
|
|
757
|
+
const full = join2(dir, entry.name);
|
|
758
|
+
if (entry.name === "node_modules" || entry.name === "dist" && dir.includes("node_modules")) continue;
|
|
759
|
+
if (entry.isDirectory()) {
|
|
760
|
+
files.push(...await walkDir(full, ext));
|
|
761
|
+
} else if (ext.some((e) => entry.name.endsWith(e))) {
|
|
762
|
+
files.push(full);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return files;
|
|
766
|
+
}
|
|
767
|
+
var BUNDLE_ENTRY = {
|
|
768
|
+
"@larose-ui/cli": "dist/cli.js"
|
|
769
|
+
};
|
|
770
|
+
async function checkBundleBudgets(packagesDir) {
|
|
771
|
+
const diagnostics = [];
|
|
772
|
+
const packages = await readdir2(packagesDir, { withFileTypes: true });
|
|
773
|
+
for (const pkg of packages) {
|
|
774
|
+
if (!pkg.isDirectory()) continue;
|
|
775
|
+
const pkgName = `@larose-ui/${pkg.name}`;
|
|
776
|
+
const budget = BUNDLE_BUDGETS_KB[pkgName];
|
|
777
|
+
if (!budget) continue;
|
|
778
|
+
const distFile = join2(packagesDir, pkg.name, BUNDLE_ENTRY[pkgName] ?? "dist/index.js");
|
|
779
|
+
try {
|
|
780
|
+
const info = await stat(distFile);
|
|
781
|
+
const sizeKb = info.size / 1024;
|
|
782
|
+
if (sizeKb > budget) {
|
|
783
|
+
diagnostics.push({
|
|
784
|
+
severity: "error",
|
|
785
|
+
category: "bundle",
|
|
786
|
+
message: `${pkgName} bundle ${sizeKb.toFixed(1)}KB exceeds budget ${budget}KB`,
|
|
787
|
+
fix: "Reduce bundle size or update budget in doctor.ts",
|
|
788
|
+
file: distFile
|
|
789
|
+
});
|
|
790
|
+
} else if (sizeKb > budget * 0.9) {
|
|
791
|
+
diagnostics.push({
|
|
792
|
+
severity: "info",
|
|
793
|
+
category: "bundle",
|
|
794
|
+
message: `${pkgName} bundle ${sizeKb.toFixed(1)}KB approaching budget ${budget}KB`,
|
|
795
|
+
file: distFile
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
diagnostics.push({
|
|
800
|
+
severity: "error",
|
|
801
|
+
category: "build",
|
|
802
|
+
message: `${pkgName} missing ${BUNDLE_ENTRY[pkgName] ?? "dist/index.js"} \u2014 run pnpm build`,
|
|
803
|
+
fix: "pnpm build"
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return diagnostics;
|
|
808
|
+
}
|
|
809
|
+
async function checkDeprecations(rootDir2) {
|
|
810
|
+
const diagnostics = [];
|
|
811
|
+
const srcFiles = await walkDir(join2(rootDir2, "packages"), [".ts", ".tsx"]);
|
|
812
|
+
const appFiles = await walkDir(join2(rootDir2, "apps"), [".ts", ".tsx"]);
|
|
813
|
+
const allFiles = [...srcFiles, ...appFiles];
|
|
814
|
+
for (const file of allFiles) {
|
|
815
|
+
if (file.includes(".test.") || file.includes("node_modules")) continue;
|
|
816
|
+
if (file.includes("packages/migration/")) continue;
|
|
817
|
+
const content = await readFile2(file, "utf-8");
|
|
818
|
+
const matches = scanSource(content, relative(rootDir2, file));
|
|
819
|
+
for (const m of matches) {
|
|
820
|
+
diagnostics.push({
|
|
821
|
+
severity: "warning",
|
|
822
|
+
category: "deprecation",
|
|
823
|
+
message: m.message,
|
|
824
|
+
fix: m.replacement,
|
|
825
|
+
file: m.file
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return diagnostics;
|
|
830
|
+
}
|
|
831
|
+
async function checkAccessibility(rootDir2) {
|
|
832
|
+
const diagnostics = [];
|
|
833
|
+
const reactFiles = await walkDir(join2(rootDir2, "packages/react/src"), [".tsx"]);
|
|
834
|
+
for (const file of reactFiles) {
|
|
835
|
+
if (file.includes(".test.")) continue;
|
|
836
|
+
const content = await readFile2(file, "utf-8");
|
|
837
|
+
const rel = relative(rootDir2, file);
|
|
838
|
+
const result = scanComponentSource(content, rel);
|
|
839
|
+
for (const v of result.violations) {
|
|
840
|
+
diagnostics.push({
|
|
841
|
+
severity: v.severity,
|
|
842
|
+
category: "accessibility",
|
|
843
|
+
message: v.message,
|
|
844
|
+
fix: v.fix,
|
|
845
|
+
file: v.file
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return diagnostics;
|
|
850
|
+
}
|
|
851
|
+
async function checkContracts(rootDir2) {
|
|
852
|
+
const diagnostics = [];
|
|
853
|
+
const contractsDir = join2(rootDir2, "contracts");
|
|
854
|
+
try {
|
|
855
|
+
const files = await readdir2(contractsDir);
|
|
856
|
+
for (const file of files) {
|
|
857
|
+
if (!file.endsWith(".json")) continue;
|
|
858
|
+
const raw = await readFile2(join2(contractsDir, file), "utf-8");
|
|
859
|
+
const data = JSON.parse(raw);
|
|
860
|
+
if (data.ui && data.api) {
|
|
861
|
+
const result = validateContract(data.ui, data.api);
|
|
862
|
+
if (!result.valid) {
|
|
863
|
+
for (const m of result.mismatches.filter((x) => x.severity === "error")) {
|
|
864
|
+
diagnostics.push({
|
|
865
|
+
severity: "error",
|
|
866
|
+
category: "contract",
|
|
867
|
+
message: m.message,
|
|
868
|
+
file: join2("contracts", file)
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
} catch {
|
|
875
|
+
}
|
|
876
|
+
return diagnostics;
|
|
877
|
+
}
|
|
878
|
+
async function checkBrowserMatrix(rootDir2) {
|
|
879
|
+
const diagnostics = [];
|
|
880
|
+
let matrix = DEFAULT_BROWSER_MATRIX;
|
|
881
|
+
let nodeEngine;
|
|
882
|
+
try {
|
|
883
|
+
const raw = await readFile2(join2(rootDir2, "quality/browser-matrix.json"), "utf-8");
|
|
884
|
+
matrix = JSON.parse(raw);
|
|
885
|
+
} catch {
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
const pkgRaw = await readFile2(join2(rootDir2, "package.json"), "utf-8");
|
|
889
|
+
const pkg = JSON.parse(pkgRaw);
|
|
890
|
+
nodeEngine = pkg.engines?.node;
|
|
891
|
+
} catch {
|
|
892
|
+
}
|
|
893
|
+
const check = validateBrowserMatrix(matrix, nodeEngine);
|
|
894
|
+
for (const issue of check.issues) {
|
|
895
|
+
diagnostics.push({
|
|
896
|
+
severity: "error",
|
|
897
|
+
category: "browser",
|
|
898
|
+
message: issue,
|
|
899
|
+
fix: "Align package.json engines with quality/browser-matrix.json"
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
return { diagnostics, check };
|
|
903
|
+
}
|
|
904
|
+
async function checkVisualRegression(rootDir2) {
|
|
905
|
+
const diagnostics = [];
|
|
906
|
+
const storiesDir = join2(rootDir2, "apps/playground/stories");
|
|
907
|
+
const current = await scanStoryManifest(storiesDir);
|
|
908
|
+
let baseline = { version: 1, stories: [] };
|
|
909
|
+
try {
|
|
910
|
+
const raw = await readFile2(join2(rootDir2, "quality/visual-baseline.json"), "utf-8");
|
|
911
|
+
baseline = JSON.parse(raw);
|
|
912
|
+
} catch {
|
|
913
|
+
diagnostics.push({
|
|
914
|
+
severity: "error",
|
|
915
|
+
category: "visual",
|
|
916
|
+
message: "Missing quality/visual-baseline.json",
|
|
917
|
+
fix: "Add visual baseline manifest for Storybook stories",
|
|
918
|
+
file: "quality/visual-baseline.json"
|
|
919
|
+
});
|
|
920
|
+
return {
|
|
921
|
+
diagnostics,
|
|
922
|
+
result: {
|
|
923
|
+
passed: false,
|
|
924
|
+
current,
|
|
925
|
+
missing: baseline.stories,
|
|
926
|
+
added: current,
|
|
927
|
+
changed: []
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
const result = compareVisualBaseline(current, baseline);
|
|
932
|
+
for (const story of result.missing) {
|
|
933
|
+
diagnostics.push({
|
|
934
|
+
severity: "error",
|
|
935
|
+
category: "visual",
|
|
936
|
+
message: `Story removed from baseline: ${story.file} (${story.title})`,
|
|
937
|
+
fix: "Restore story or update quality/visual-baseline.json intentionally",
|
|
938
|
+
file: join2("apps/playground/stories", story.file)
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
for (const change of result.changed) {
|
|
942
|
+
diagnostics.push({
|
|
943
|
+
severity: "warning",
|
|
944
|
+
category: "visual",
|
|
945
|
+
message: `Story title changed: ${change.file} "${change.baselineTitle}" \u2192 "${change.currentTitle}"`,
|
|
946
|
+
fix: "Update quality/visual-baseline.json if rename is intentional",
|
|
947
|
+
file: join2("apps/playground/stories", change.file)
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
for (const story of result.added) {
|
|
951
|
+
diagnostics.push({
|
|
952
|
+
severity: "warning",
|
|
953
|
+
category: "visual",
|
|
954
|
+
message: `New story not in baseline: ${story.file} (${story.title})`,
|
|
955
|
+
fix: "Add entry to quality/visual-baseline.json",
|
|
956
|
+
file: join2("apps/playground/stories", story.file)
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return { diagnostics, result };
|
|
960
|
+
}
|
|
961
|
+
async function runVisualRegressionCheck(rootDir2) {
|
|
962
|
+
const { result } = await checkVisualRegression(rootDir2);
|
|
963
|
+
return result;
|
|
964
|
+
}
|
|
965
|
+
async function runDoctor(rootDir2, options = {}) {
|
|
966
|
+
const packagesDir = join2(rootDir2, "packages");
|
|
967
|
+
const diagnostics = [
|
|
968
|
+
...await checkBundleBudgets(packagesDir),
|
|
969
|
+
...await checkDeprecations(rootDir2),
|
|
970
|
+
...await checkAccessibility(rootDir2),
|
|
971
|
+
...await checkContracts(rootDir2)
|
|
972
|
+
];
|
|
973
|
+
let browserMatrix;
|
|
974
|
+
if (!options.skipBrowser) {
|
|
975
|
+
const browser = await checkBrowserMatrix(rootDir2);
|
|
976
|
+
diagnostics.push(...browser.diagnostics);
|
|
977
|
+
browserMatrix = browser.check;
|
|
978
|
+
}
|
|
979
|
+
let visualRegression;
|
|
980
|
+
if (!options.skipVisual) {
|
|
981
|
+
const visual = await checkVisualRegression(rootDir2);
|
|
982
|
+
diagnostics.push(...visual.diagnostics);
|
|
983
|
+
visualRegression = visual.result;
|
|
984
|
+
}
|
|
985
|
+
const quality = computeQualityScores(diagnostics, { ci: options.ci });
|
|
986
|
+
const passed = qualityPassed(diagnostics, {
|
|
987
|
+
ci: options.ci,
|
|
988
|
+
browserMatrix,
|
|
989
|
+
visualRegression
|
|
990
|
+
});
|
|
991
|
+
return { passed, diagnostics, quality, browserMatrix, visualRegression };
|
|
992
|
+
}
|
|
993
|
+
function formatDoctorReport(result, options = {}) {
|
|
994
|
+
const lines = ["laRose Doctor", ""];
|
|
995
|
+
lines.push(`Quality score: ${result.quality.overall}/100`);
|
|
996
|
+
if (options.ci) lines.push("Mode: CI (warnings fail)");
|
|
997
|
+
lines.push("");
|
|
998
|
+
if (result.diagnostics.length === 0) {
|
|
999
|
+
lines.push("All checks passed.");
|
|
1000
|
+
lines.push("");
|
|
1001
|
+
lines.push("Result: PASS");
|
|
1002
|
+
return lines.join("\n");
|
|
1003
|
+
}
|
|
1004
|
+
for (const d of result.diagnostics) {
|
|
1005
|
+
if (d.severity === "info") continue;
|
|
1006
|
+
lines.push(`[${d.severity.toUpperCase()}] ${d.category}: ${d.message}`);
|
|
1007
|
+
if (d.file) lines.push(` File: ${d.file}`);
|
|
1008
|
+
if (d.fix) lines.push(` Fix: ${d.fix}`);
|
|
1009
|
+
lines.push("");
|
|
1010
|
+
}
|
|
1011
|
+
if (result.quality.components.length > 0) {
|
|
1012
|
+
lines.push("Lowest component scores:");
|
|
1013
|
+
for (const component of result.quality.components.slice(0, 5)) {
|
|
1014
|
+
lines.push(
|
|
1015
|
+
` ${component.id}: ${component.score}/100 (${component.errors}e/${component.warnings}w)`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
lines.push("");
|
|
1019
|
+
}
|
|
1020
|
+
lines.push(result.passed ? "Result: PASS" : "Result: FAIL");
|
|
1021
|
+
return lines.join("\n");
|
|
1022
|
+
}
|
|
1023
|
+
function formatDoctorJson(result, options = {}) {
|
|
1024
|
+
return JSON.stringify(
|
|
1025
|
+
{
|
|
1026
|
+
passed: result.passed,
|
|
1027
|
+
ci: options.ci === true,
|
|
1028
|
+
qualityScore: result.quality.overall,
|
|
1029
|
+
quality: result.quality,
|
|
1030
|
+
browserMatrix: result.browserMatrix,
|
|
1031
|
+
visualRegression: result.visualRegression ? {
|
|
1032
|
+
passed: result.visualRegression.passed,
|
|
1033
|
+
storyCount: result.visualRegression.current.length,
|
|
1034
|
+
missing: result.visualRegression.missing,
|
|
1035
|
+
added: result.visualRegression.added,
|
|
1036
|
+
changed: result.visualRegression.changed
|
|
1037
|
+
} : void 0,
|
|
1038
|
+
diagnostics: result.diagnostics
|
|
1039
|
+
},
|
|
1040
|
+
null,
|
|
1041
|
+
2
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
async function runMigrate(rootDir2, targetVersion, options = {}) {
|
|
1045
|
+
const files = [];
|
|
1046
|
+
const allFiles = [
|
|
1047
|
+
...await walkDir(join2(rootDir2, "packages"), [".ts", ".tsx"]),
|
|
1048
|
+
...await walkDir(join2(rootDir2, "apps"), [".ts", ".tsx"])
|
|
1049
|
+
];
|
|
1050
|
+
for (const file of allFiles) {
|
|
1051
|
+
if (file.includes(".test.") || file.includes("packages/migration/")) continue;
|
|
1052
|
+
files.push({
|
|
1053
|
+
path: relative(rootDir2, file),
|
|
1054
|
+
content: await readFile2(file, "utf-8")
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
const report = generateMigrationReport(files, targetVersion);
|
|
1058
|
+
const lines = [formatMigrationReport(report)];
|
|
1059
|
+
if (options.apply) {
|
|
1060
|
+
const results = applyCodemodsToFiles(files);
|
|
1061
|
+
for (const result of results) {
|
|
1062
|
+
if (result.changed) {
|
|
1063
|
+
await writeFile(join2(rootDir2, result.path), result.content, "utf-8");
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
lines.push("", formatCodemodReport(results));
|
|
1067
|
+
} else if (report.deprecatedUsages.length > 0) {
|
|
1068
|
+
lines.push("", `Run: larose migrate --to ${targetVersion} --apply`);
|
|
1069
|
+
}
|
|
1070
|
+
return lines.join("\n");
|
|
1071
|
+
}
|
|
1072
|
+
function runGenerate(type, name) {
|
|
1073
|
+
return runGenerator(type, name);
|
|
1074
|
+
}
|
|
1075
|
+
async function runRelease(rootDir2, json = false) {
|
|
1076
|
+
const packagesDir = join2(rootDir2, "packages");
|
|
1077
|
+
const entries = await readdir2(packagesDir, { withFileTypes: true });
|
|
1078
|
+
const manifests = [];
|
|
1079
|
+
for (const entry of entries) {
|
|
1080
|
+
if (!entry.isDirectory()) continue;
|
|
1081
|
+
try {
|
|
1082
|
+
const raw = await readFile2(join2(packagesDir, entry.name, "package.json"), "utf-8");
|
|
1083
|
+
const pkg = JSON.parse(raw);
|
|
1084
|
+
manifests.push({
|
|
1085
|
+
name: pkg.name,
|
|
1086
|
+
version: pkg.version,
|
|
1087
|
+
private: pkg.private,
|
|
1088
|
+
license: pkg.license,
|
|
1089
|
+
publishConfig: pkg.publishConfig,
|
|
1090
|
+
directory: `packages/${entry.name}`
|
|
1091
|
+
});
|
|
1092
|
+
} catch {
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const report = analyzeRelease(manifests);
|
|
1096
|
+
const blocked = report.packages.filter((pkg) => !pkg.private && !pkg.publishReady);
|
|
1097
|
+
const ready = report.aligned && blocked.length === 0;
|
|
1098
|
+
const output = json ? formatReleaseJson(report) : formatReleaseReport(report);
|
|
1099
|
+
return { output, ready };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/cli.ts
|
|
1103
|
+
var args = process.argv.slice(2);
|
|
1104
|
+
var command = args[0] ?? "help";
|
|
1105
|
+
var rootDir = resolve(process.cwd());
|
|
1106
|
+
function hasFlag(flag) {
|
|
1107
|
+
return args.includes(flag);
|
|
1108
|
+
}
|
|
1109
|
+
async function main() {
|
|
1110
|
+
switch (command) {
|
|
1111
|
+
case "doctor": {
|
|
1112
|
+
const options = {
|
|
1113
|
+
ci: hasFlag("--ci"),
|
|
1114
|
+
skipVisual: hasFlag("--skip-visual"),
|
|
1115
|
+
skipBrowser: hasFlag("--skip-browser")
|
|
1116
|
+
};
|
|
1117
|
+
const result = await runDoctor(rootDir, options);
|
|
1118
|
+
if (hasFlag("--json")) {
|
|
1119
|
+
console.log(formatDoctorJson(result, options));
|
|
1120
|
+
} else {
|
|
1121
|
+
console.log(formatDoctorReport(result, options));
|
|
1122
|
+
}
|
|
1123
|
+
process.exit(result.passed ? 0 : 1);
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
case "visual-regression": {
|
|
1127
|
+
const result = await runVisualRegressionCheck(rootDir);
|
|
1128
|
+
console.log(formatVisualRegressionReport(result));
|
|
1129
|
+
process.exit(result.passed ? 0 : 1);
|
|
1130
|
+
break;
|
|
1131
|
+
}
|
|
1132
|
+
case "migrate": {
|
|
1133
|
+
const toIndex = args.indexOf("--to");
|
|
1134
|
+
const version = toIndex >= 0 ? args[toIndex + 1] ?? "1.0.0" : "1.0.0";
|
|
1135
|
+
const apply = args.includes("--apply");
|
|
1136
|
+
console.log(await runMigrate(rootDir, version, { apply }));
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
case "generate": {
|
|
1140
|
+
const type = args[1] ?? "form";
|
|
1141
|
+
const name = args[2] ?? "Example";
|
|
1142
|
+
const output = args[3];
|
|
1143
|
+
const code = runGenerate(type, name);
|
|
1144
|
+
if (output) {
|
|
1145
|
+
writeFileSync(output, code);
|
|
1146
|
+
console.log(`Generated ${output}`);
|
|
1147
|
+
} else {
|
|
1148
|
+
console.log(code);
|
|
1149
|
+
}
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
case "release": {
|
|
1153
|
+
const { output, ready } = await runRelease(rootDir, hasFlag("--json"));
|
|
1154
|
+
console.log(output);
|
|
1155
|
+
process.exit(ready ? 0 : 1);
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
default:
|
|
1159
|
+
console.log(`laRose CLI
|
|
1160
|
+
|
|
1161
|
+
Usage:
|
|
1162
|
+
larose doctor Run quality checks
|
|
1163
|
+
larose doctor --ci CI mode (warnings fail)
|
|
1164
|
+
larose doctor --json JSON report for CI pipelines
|
|
1165
|
+
larose doctor --ci --json Combined CI + JSON output
|
|
1166
|
+
larose visual-regression Validate Storybook story manifest
|
|
1167
|
+
larose migrate --to 1.0.0 Migration report (dry run)
|
|
1168
|
+
larose migrate --to 1.0.0 --apply Apply safe codemods
|
|
1169
|
+
larose generate form Name Generate form scaffold
|
|
1170
|
+
larose generate page Name Generate page scaffold
|
|
1171
|
+
larose generate feature Name Generate full feature scaffold
|
|
1172
|
+
larose release Monorepo release readiness report
|
|
1173
|
+
larose release --json JSON release report
|
|
1174
|
+
`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
main().catch((err) => {
|
|
1178
|
+
console.error(err);
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@larose-ui/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for laRose UI platform — doctor, migrate, generate",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"larose": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@larose-ui/accessibility": "0.1.0",
|
|
14
|
+
"@larose-ui/contracts": "0.1.0",
|
|
15
|
+
"@larose-ui/migration": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"tsup": "^8.3.5",
|
|
19
|
+
"typescript": "^5.7.2",
|
|
20
|
+
"vitest": "^2.1.8"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/larose-ui/larose.git",
|
|
29
|
+
"directory": "packages/cli"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"larose",
|
|
33
|
+
"react",
|
|
34
|
+
"ui-platform",
|
|
35
|
+
"design-system",
|
|
36
|
+
"saas"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"dev": "tsup src/cli.ts --format esm --watch",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"clean": "rm -rf dist"
|
|
44
|
+
}
|
|
45
|
+
}
|