@agent-scope/cli 1.19.0 → 1.20.1
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/README.md +59 -8
- package/dist/cli.js +2200 -436
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1980 -245
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +1982 -248
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/program.ts
|
|
4
|
-
import { readFileSync as
|
|
10
|
+
import { readFileSync as readFileSync17 } from "fs";
|
|
5
11
|
import { generateTest, loadTrace } from "@agent-scope/playwright";
|
|
6
|
-
import { Command as
|
|
12
|
+
import { Command as Command12 } from "commander";
|
|
7
13
|
|
|
8
14
|
// src/browser.ts
|
|
9
15
|
import { writeFileSync } from "fs";
|
|
@@ -61,9 +67,9 @@ import { Command } from "commander";
|
|
|
61
67
|
// src/component-bundler.ts
|
|
62
68
|
import { dirname } from "path";
|
|
63
69
|
import * as esbuild from "esbuild";
|
|
64
|
-
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript) {
|
|
70
|
+
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
65
71
|
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
66
|
-
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
|
|
72
|
+
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding);
|
|
67
73
|
}
|
|
68
74
|
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
69
75
|
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
@@ -169,7 +175,7 @@ ${msg}`);
|
|
|
169
175
|
}
|
|
170
176
|
return outputFile.text;
|
|
171
177
|
}
|
|
172
|
-
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
|
|
178
|
+
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
173
179
|
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
174
180
|
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
175
181
|
</style>` : "";
|
|
@@ -179,10 +185,17 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
|
179
185
|
<head>
|
|
180
186
|
<meta charset="UTF-8" />
|
|
181
187
|
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
188
|
+
<script>
|
|
189
|
+
// Reset globals that persist on window across page.setContent() calls
|
|
190
|
+
// (document.open/write/close clears the DOM but NOT the JS global scope)
|
|
191
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
192
|
+
window.__SCOPE_RENDER_COMPLETE__ = false;
|
|
193
|
+
window.__SCOPE_RENDER_ERROR__ = null;
|
|
194
|
+
</script>
|
|
182
195
|
<style>
|
|
183
196
|
*, *::before, *::after { box-sizing: border-box; }
|
|
184
197
|
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
185
|
-
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
198
|
+
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; margin: ${screenshotPadding}px; }
|
|
186
199
|
</style>
|
|
187
200
|
${projectStyleBlock}
|
|
188
201
|
</head>
|
|
@@ -214,13 +227,15 @@ function buildTable(headers, rows) {
|
|
|
214
227
|
}
|
|
215
228
|
function formatListTable(rows) {
|
|
216
229
|
if (rows.length === 0) return "No components found.";
|
|
217
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
230
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
218
231
|
const tableRows = rows.map((r) => [
|
|
219
232
|
r.name,
|
|
220
233
|
r.file,
|
|
221
234
|
r.complexityClass,
|
|
222
235
|
String(r.hookCount),
|
|
223
|
-
String(r.contextCount)
|
|
236
|
+
String(r.contextCount),
|
|
237
|
+
r.collection ?? "\u2014",
|
|
238
|
+
r.internal ? "yes" : "no"
|
|
224
239
|
]);
|
|
225
240
|
return buildTable(headers, tableRows);
|
|
226
241
|
}
|
|
@@ -251,6 +266,8 @@ function formatGetTable(name, descriptor) {
|
|
|
251
266
|
` Composes: ${descriptor.composes.join(", ") || "none"}`,
|
|
252
267
|
` Composed By: ${descriptor.composedBy.join(", ") || "none"}`,
|
|
253
268
|
` Side Effects: ${formatSideEffects(descriptor.sideEffects)}`,
|
|
269
|
+
` Collection: ${descriptor.collection ?? "\u2014"}`,
|
|
270
|
+
` Internal: ${descriptor.internal}`,
|
|
254
271
|
"",
|
|
255
272
|
` Props (${propNames.length}):`
|
|
256
273
|
];
|
|
@@ -273,8 +290,16 @@ function formatGetJson(name, descriptor) {
|
|
|
273
290
|
}
|
|
274
291
|
function formatQueryTable(rows, queryDesc) {
|
|
275
292
|
if (rows.length === 0) return `No components match: ${queryDesc}`;
|
|
276
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
277
|
-
const tableRows = rows.map((r) => [
|
|
293
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
294
|
+
const tableRows = rows.map((r) => [
|
|
295
|
+
r.name,
|
|
296
|
+
r.file,
|
|
297
|
+
r.complexityClass,
|
|
298
|
+
r.hooks,
|
|
299
|
+
r.contexts,
|
|
300
|
+
r.collection ?? "\u2014",
|
|
301
|
+
r.internal ? "yes" : "no"
|
|
302
|
+
]);
|
|
278
303
|
return `Query: ${queryDesc}
|
|
279
304
|
|
|
280
305
|
${buildTable(headers, tableRows)}`;
|
|
@@ -557,22 +582,22 @@ async function getTailwindCompiler(cwd) {
|
|
|
557
582
|
from: entryPath,
|
|
558
583
|
loadStylesheet
|
|
559
584
|
});
|
|
560
|
-
const
|
|
561
|
-
compilerCache = { cwd, build:
|
|
562
|
-
return
|
|
585
|
+
const build4 = result.build.bind(result);
|
|
586
|
+
compilerCache = { cwd, build: build4 };
|
|
587
|
+
return build4;
|
|
563
588
|
}
|
|
564
589
|
async function getCompiledCssForClasses(cwd, classes) {
|
|
565
|
-
const
|
|
566
|
-
if (
|
|
590
|
+
const build4 = await getTailwindCompiler(cwd);
|
|
591
|
+
if (build4 === null) return null;
|
|
567
592
|
const deduped = [...new Set(classes)].filter(Boolean);
|
|
568
593
|
if (deduped.length === 0) return null;
|
|
569
|
-
return
|
|
594
|
+
return build4(deduped);
|
|
570
595
|
}
|
|
571
596
|
async function compileGlobalCssFile(cssFilePath, cwd) {
|
|
572
|
-
const { existsSync:
|
|
597
|
+
const { existsSync: existsSync18, readFileSync: readFileSync18 } = await import("fs");
|
|
573
598
|
const { createRequire: createRequire3 } = await import("module");
|
|
574
|
-
if (!
|
|
575
|
-
const raw =
|
|
599
|
+
if (!existsSync18(cssFilePath)) return null;
|
|
600
|
+
const raw = readFileSync18(cssFilePath, "utf-8");
|
|
576
601
|
const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
|
|
577
602
|
if (!needsCompile) {
|
|
578
603
|
return raw;
|
|
@@ -655,8 +680,17 @@ async function shutdownPool() {
|
|
|
655
680
|
}
|
|
656
681
|
}
|
|
657
682
|
async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
683
|
+
const PAD = 24;
|
|
658
684
|
const pool = await getPool(viewportWidth, viewportHeight);
|
|
659
|
-
const htmlHarness = await buildComponentHarness(
|
|
685
|
+
const htmlHarness = await buildComponentHarness(
|
|
686
|
+
filePath,
|
|
687
|
+
componentName,
|
|
688
|
+
props,
|
|
689
|
+
viewportWidth,
|
|
690
|
+
void 0,
|
|
691
|
+
void 0,
|
|
692
|
+
PAD
|
|
693
|
+
);
|
|
660
694
|
const slot = await pool.acquire();
|
|
661
695
|
const { page } = slot;
|
|
662
696
|
try {
|
|
@@ -696,7 +730,6 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
|
|
|
696
730
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
697
731
|
);
|
|
698
732
|
}
|
|
699
|
-
const PAD = 24;
|
|
700
733
|
const MIN_W = 320;
|
|
701
734
|
const MIN_H = 200;
|
|
702
735
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -998,7 +1031,7 @@ function parseChecks(raw) {
|
|
|
998
1031
|
}
|
|
999
1032
|
function createCiCommand() {
|
|
1000
1033
|
return new Command("ci").description(
|
|
1001
|
-
"Run
|
|
1034
|
+
"Run the full Scope pipeline non-interactively and exit with a structured code.\n\nPIPELINE STEPS (in order):\n 1. manifest generate scan source, build .reactscope/manifest.json\n 2. render all screenshot every component\n 3. tokens compliance score on-token CSS coverage\n 4. visual regression pixel diff against --baseline (if provided)\n\nCHECKS (--checks flag, comma-separated):\n compliance token coverage below --threshold \u2192 exit 1\n a11y accessibility violations \u2192 exit 2\n console-errors console.error during render \u2192 exit 3\n visual-regression pixel diff against baseline \u2192 exit 4\n (render failures always \u2192 exit 5 regardless of --checks)\n\nEXIT CODES:\n 0 all checks passed\n 1 compliance below threshold\n 2 accessibility violations\n 3 console errors during render\n 4 visual regression detected\n 5 component render failures\n\nExamples:\n scope ci\n scope ci --baseline .reactscope/baseline --threshold 0.95\n scope ci --checks compliance,a11y --json -o ci-result.json\n scope ci --viewport 1280x720"
|
|
1002
1035
|
).option(
|
|
1003
1036
|
"-b, --baseline <dir>",
|
|
1004
1037
|
"Baseline directory for visual regression comparison (omit to skip)"
|
|
@@ -1043,14 +1076,137 @@ function createCiCommand() {
|
|
|
1043
1076
|
}
|
|
1044
1077
|
|
|
1045
1078
|
// src/doctor-commands.ts
|
|
1046
|
-
import { existsSync as
|
|
1047
|
-
import { join, resolve as resolve3 } from "path";
|
|
1079
|
+
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4, statSync } from "fs";
|
|
1080
|
+
import { join as join2, resolve as resolve3 } from "path";
|
|
1048
1081
|
import { Command as Command2 } from "commander";
|
|
1082
|
+
|
|
1083
|
+
// src/diagnostics.ts
|
|
1084
|
+
import { constants, existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1085
|
+
import { access } from "fs/promises";
|
|
1086
|
+
import { dirname as dirname2, join } from "path";
|
|
1087
|
+
var PLAYWRIGHT_BROWSER_HINTS = [
|
|
1088
|
+
"executable doesn't exist",
|
|
1089
|
+
"browserType.launch",
|
|
1090
|
+
"looks like playwright was just installed or updated",
|
|
1091
|
+
"please run the following command to download new browsers",
|
|
1092
|
+
"could not find chromium"
|
|
1093
|
+
];
|
|
1094
|
+
var MISSING_DEPENDENCY_HINTS = ["could not resolve", "cannot find module", "module not found"];
|
|
1095
|
+
var REQUIRED_HARNESS_DEPENDENCIES = ["react", "react-dom", "react/jsx-runtime"];
|
|
1096
|
+
function getEffectivePlaywrightBrowsersPath() {
|
|
1097
|
+
const value = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
1098
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1099
|
+
}
|
|
1100
|
+
function getPlaywrightBrowserRemediation(status) {
|
|
1101
|
+
const effectivePath = status?.effectiveBrowserPath ?? getEffectivePlaywrightBrowsersPath();
|
|
1102
|
+
if (effectivePath !== null) {
|
|
1103
|
+
const pathProblem = status?.browserPathExists === false ? "missing" : status?.browserPathWritable === false ? "unwritable" : "unavailable";
|
|
1104
|
+
return `PLAYWRIGHT_BROWSERS_PATH is set to ${effectivePath}, but that browser cache is ${pathProblem}. Unset PLAYWRIGHT_BROWSERS_PATH and run \`bunx playwright install chromium\`, or install and run with the same writable path: \`PLAYWRIGHT_BROWSERS_PATH=${effectivePath} bunx playwright install chromium\`.`;
|
|
1105
|
+
}
|
|
1106
|
+
return "Run `bunx playwright install chromium` in this sandbox, then retry the Scope command.";
|
|
1107
|
+
}
|
|
1108
|
+
function diagnoseScopeError(error, cwd = process.cwd()) {
|
|
1109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1110
|
+
const normalized = message.toLowerCase();
|
|
1111
|
+
if (PLAYWRIGHT_BROWSER_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1112
|
+
const browserPath = extractPlaywrightBrowserPath(message);
|
|
1113
|
+
const browserPathHint = browserPath === null ? "" : ` Scope tried to launch Chromium from ${browserPath}.`;
|
|
1114
|
+
return {
|
|
1115
|
+
code: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1116
|
+
message: "Playwright Chromium is unavailable for Scope browser rendering.",
|
|
1117
|
+
recovery: getPlaywrightBrowserRemediation() + browserPathHint + " Use `scope doctor --json` to verify the browser check passes before rerunning render/site/instrument."
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
if (MISSING_DEPENDENCY_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1121
|
+
const packageManager = detectPackageManager(cwd);
|
|
1122
|
+
return {
|
|
1123
|
+
code: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1124
|
+
message: "The target project's dependencies appear to be missing or incomplete.",
|
|
1125
|
+
recovery: `Run \`${packageManager} install\` in ${cwd}, then rerun \`scope doctor\` and retry the Scope command.`
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
function formatScopeDiagnostic(error, cwd = process.cwd()) {
|
|
1131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1132
|
+
const diagnostic = diagnoseScopeError(error, cwd);
|
|
1133
|
+
if (diagnostic === null) return `Error: ${message}`;
|
|
1134
|
+
return `Error [${diagnostic.code}]: ${diagnostic.message}
|
|
1135
|
+
Recovery: ${diagnostic.recovery}
|
|
1136
|
+
Cause: ${message}`;
|
|
1137
|
+
}
|
|
1138
|
+
async function getPlaywrightBrowserStatus(cwd = process.cwd()) {
|
|
1139
|
+
const effectiveBrowserPath = getEffectivePlaywrightBrowsersPath();
|
|
1140
|
+
const executablePath = getPlaywrightChromiumExecutablePath(cwd);
|
|
1141
|
+
const available = executablePath !== null && existsSync3(executablePath);
|
|
1142
|
+
const browserPathExists = effectiveBrowserPath === null ? null : existsSync3(effectiveBrowserPath);
|
|
1143
|
+
const browserPathWritable = effectiveBrowserPath === null ? null : await isWritableBrowserPath(effectiveBrowserPath);
|
|
1144
|
+
return {
|
|
1145
|
+
effectiveBrowserPath,
|
|
1146
|
+
executablePath,
|
|
1147
|
+
available,
|
|
1148
|
+
browserPathExists,
|
|
1149
|
+
browserPathWritable,
|
|
1150
|
+
remediation: getPlaywrightBrowserRemediation({
|
|
1151
|
+
effectiveBrowserPath,
|
|
1152
|
+
browserPathExists,
|
|
1153
|
+
browserPathWritable
|
|
1154
|
+
})
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
function getPlaywrightChromiumExecutablePath(cwd = process.cwd()) {
|
|
1158
|
+
try {
|
|
1159
|
+
const packageJsonPath = __require.resolve("playwright/package.json", { paths: [cwd] });
|
|
1160
|
+
const packageJson = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
|
|
1161
|
+
if (!packageJson.version) return null;
|
|
1162
|
+
const playwrightPath = __require.resolve("playwright", { paths: [cwd] });
|
|
1163
|
+
const { chromium: chromium5 } = __require(playwrightPath);
|
|
1164
|
+
const executablePath = chromium5?.executablePath?.();
|
|
1165
|
+
if (typeof executablePath !== "string" || executablePath.length === 0) return null;
|
|
1166
|
+
return executablePath;
|
|
1167
|
+
} catch {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
async function isWritableBrowserPath(browserPath) {
|
|
1172
|
+
const candidate = existsSync3(browserPath) ? browserPath : dirname2(browserPath);
|
|
1173
|
+
try {
|
|
1174
|
+
await access(candidate, constants.W_OK);
|
|
1175
|
+
return true;
|
|
1176
|
+
} catch {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1181
|
+
if (existsSync3(join(cwd, "bun.lock")) || existsSync3(join(cwd, "bun.lockb"))) return "bun";
|
|
1182
|
+
if (existsSync3(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1183
|
+
if (existsSync3(join(cwd, "yarn.lock"))) return "yarn";
|
|
1184
|
+
return "npm";
|
|
1185
|
+
}
|
|
1186
|
+
function hasLikelyInstalledDependencies(cwd = process.cwd()) {
|
|
1187
|
+
return existsSync3(join(cwd, "node_modules"));
|
|
1188
|
+
}
|
|
1189
|
+
function getMissingHarnessDependencies(cwd = process.cwd()) {
|
|
1190
|
+
return REQUIRED_HARNESS_DEPENDENCIES.filter((dependencyName) => {
|
|
1191
|
+
try {
|
|
1192
|
+
__require.resolve(dependencyName, { paths: [cwd] });
|
|
1193
|
+
return false;
|
|
1194
|
+
} catch {
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
function extractPlaywrightBrowserPath(message) {
|
|
1200
|
+
const match = message.match(/Executable doesn't exist at\s+([^\n]+)/i);
|
|
1201
|
+
return match?.[1]?.trim() ?? null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/doctor-commands.ts
|
|
1049
1205
|
function collectSourceFiles(dir) {
|
|
1050
|
-
if (!
|
|
1206
|
+
if (!existsSync4(dir)) return [];
|
|
1051
1207
|
const results = [];
|
|
1052
1208
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1053
|
-
const full =
|
|
1209
|
+
const full = join2(dir, entry.name);
|
|
1054
1210
|
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".reactscope") {
|
|
1055
1211
|
results.push(...collectSourceFiles(full));
|
|
1056
1212
|
} else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
@@ -1059,17 +1215,47 @@ function collectSourceFiles(dir) {
|
|
|
1059
1215
|
}
|
|
1060
1216
|
return results;
|
|
1061
1217
|
}
|
|
1218
|
+
var TAILWIND_CONFIG_FILES = [
|
|
1219
|
+
"tailwind.config.js",
|
|
1220
|
+
"tailwind.config.cjs",
|
|
1221
|
+
"tailwind.config.mjs",
|
|
1222
|
+
"tailwind.config.ts",
|
|
1223
|
+
"postcss.config.js",
|
|
1224
|
+
"postcss.config.cjs",
|
|
1225
|
+
"postcss.config.mjs",
|
|
1226
|
+
"postcss.config.ts"
|
|
1227
|
+
];
|
|
1228
|
+
function hasTailwindSetup(cwd) {
|
|
1229
|
+
if (TAILWIND_CONFIG_FILES.some((file) => existsSync4(resolve3(cwd, file)))) {
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
const packageJsonPath = resolve3(cwd, "package.json");
|
|
1233
|
+
if (!existsSync4(packageJsonPath)) return false;
|
|
1234
|
+
try {
|
|
1235
|
+
const pkg = JSON.parse(readFileSync4(packageJsonPath, "utf-8"));
|
|
1236
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
1237
|
+
(deps) => deps && Object.keys(deps).some(
|
|
1238
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
1239
|
+
)
|
|
1240
|
+
);
|
|
1241
|
+
} catch {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function getPlaywrightInstallCommand(effectiveBrowserPath) {
|
|
1246
|
+
return effectiveBrowserPath === null ? "bunx playwright install chromium" : `PLAYWRIGHT_BROWSERS_PATH=${effectiveBrowserPath} bunx playwright install chromium`;
|
|
1247
|
+
}
|
|
1062
1248
|
function checkConfig(cwd) {
|
|
1063
1249
|
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1064
|
-
if (!
|
|
1250
|
+
if (!existsSync4(configPath)) {
|
|
1065
1251
|
return {
|
|
1066
1252
|
name: "config",
|
|
1067
1253
|
status: "error",
|
|
1068
|
-
message: "reactscope.config.json not found \u2014 run `scope init`"
|
|
1254
|
+
message: "reactscope.config.json not found \u2014 run `scope init` in the target project root"
|
|
1069
1255
|
};
|
|
1070
1256
|
}
|
|
1071
1257
|
try {
|
|
1072
|
-
JSON.parse(
|
|
1258
|
+
JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
1073
1259
|
return { name: "config", status: "ok", message: "reactscope.config.json valid" };
|
|
1074
1260
|
} catch {
|
|
1075
1261
|
return { name: "config", status: "error", message: "reactscope.config.json is not valid JSON" };
|
|
@@ -1078,14 +1264,14 @@ function checkConfig(cwd) {
|
|
|
1078
1264
|
function checkTokens(cwd) {
|
|
1079
1265
|
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1080
1266
|
let tokensPath = resolve3(cwd, "reactscope.tokens.json");
|
|
1081
|
-
if (
|
|
1267
|
+
if (existsSync4(configPath)) {
|
|
1082
1268
|
try {
|
|
1083
|
-
const cfg = JSON.parse(
|
|
1269
|
+
const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
1084
1270
|
if (cfg.tokens?.file) tokensPath = resolve3(cwd, cfg.tokens.file);
|
|
1085
1271
|
} catch {
|
|
1086
1272
|
}
|
|
1087
1273
|
}
|
|
1088
|
-
if (!
|
|
1274
|
+
if (!existsSync4(tokensPath)) {
|
|
1089
1275
|
return {
|
|
1090
1276
|
name: "tokens",
|
|
1091
1277
|
status: "warn",
|
|
@@ -1093,7 +1279,7 @@ function checkTokens(cwd) {
|
|
|
1093
1279
|
};
|
|
1094
1280
|
}
|
|
1095
1281
|
try {
|
|
1096
|
-
const raw = JSON.parse(
|
|
1282
|
+
const raw = JSON.parse(readFileSync4(tokensPath, "utf-8"));
|
|
1097
1283
|
if (!raw.version) {
|
|
1098
1284
|
return { name: "tokens", status: "warn", message: "Token file is missing a `version` field" };
|
|
1099
1285
|
}
|
|
@@ -1105,21 +1291,28 @@ function checkTokens(cwd) {
|
|
|
1105
1291
|
function checkGlobalCss(cwd) {
|
|
1106
1292
|
const configPath = resolve3(cwd, "reactscope.config.json");
|
|
1107
1293
|
let globalCss = [];
|
|
1108
|
-
if (
|
|
1294
|
+
if (existsSync4(configPath)) {
|
|
1109
1295
|
try {
|
|
1110
|
-
const cfg = JSON.parse(
|
|
1296
|
+
const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
1111
1297
|
globalCss = cfg.components?.wrappers?.globalCSS ?? [];
|
|
1112
1298
|
} catch {
|
|
1113
1299
|
}
|
|
1114
1300
|
}
|
|
1115
1301
|
if (globalCss.length === 0) {
|
|
1302
|
+
if (!hasTailwindSetup(cwd)) {
|
|
1303
|
+
return {
|
|
1304
|
+
name: "globalCSS",
|
|
1305
|
+
status: "ok",
|
|
1306
|
+
message: "No globalCSS configured \u2014 skipping CSS injection for this non-Tailwind project"
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1116
1309
|
return {
|
|
1117
1310
|
name: "globalCSS",
|
|
1118
1311
|
status: "warn",
|
|
1119
1312
|
message: "No globalCSS configured \u2014 Tailwind styles won't apply to renders. Add `components.wrappers.globalCSS` to reactscope.config.json"
|
|
1120
1313
|
};
|
|
1121
1314
|
}
|
|
1122
|
-
const missing = globalCss.filter((f) => !
|
|
1315
|
+
const missing = globalCss.filter((f) => !existsSync4(resolve3(cwd, f)));
|
|
1123
1316
|
if (missing.length > 0) {
|
|
1124
1317
|
return {
|
|
1125
1318
|
name: "globalCSS",
|
|
@@ -1135,11 +1328,11 @@ function checkGlobalCss(cwd) {
|
|
|
1135
1328
|
}
|
|
1136
1329
|
function checkManifest(cwd) {
|
|
1137
1330
|
const manifestPath = resolve3(cwd, ".reactscope", "manifest.json");
|
|
1138
|
-
if (!
|
|
1331
|
+
if (!existsSync4(manifestPath)) {
|
|
1139
1332
|
return {
|
|
1140
1333
|
name: "manifest",
|
|
1141
1334
|
status: "warn",
|
|
1142
|
-
message: "Manifest not found \u2014 run `scope manifest generate`"
|
|
1335
|
+
message: "Manifest not found \u2014 run `scope manifest generate` in the target project root"
|
|
1143
1336
|
};
|
|
1144
1337
|
}
|
|
1145
1338
|
const manifestMtime = statSync(manifestPath).mtimeMs;
|
|
@@ -1156,23 +1349,105 @@ function checkManifest(cwd) {
|
|
|
1156
1349
|
return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
|
|
1157
1350
|
}
|
|
1158
1351
|
var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
|
|
1352
|
+
function checkDependencies(cwd) {
|
|
1353
|
+
const packageManager = detectPackageManager(cwd);
|
|
1354
|
+
if (!hasLikelyInstalledDependencies(cwd)) {
|
|
1355
|
+
return {
|
|
1356
|
+
name: "dependencies",
|
|
1357
|
+
status: "error",
|
|
1358
|
+
remediationCode: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1359
|
+
fixCommand: `${packageManager} install`,
|
|
1360
|
+
message: `node_modules not found \u2014 run \`${packageManager} install\` in ${cwd} before render/site/instrument`
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const missingHarnessDependencies = getMissingHarnessDependencies(cwd);
|
|
1364
|
+
if (missingHarnessDependencies.length > 0) {
|
|
1365
|
+
return {
|
|
1366
|
+
name: "dependencies",
|
|
1367
|
+
status: "error",
|
|
1368
|
+
remediationCode: "TARGET_PROJECT_HARNESS_DEPENDENCIES_MISSING",
|
|
1369
|
+
fixCommand: `${packageManager} install`,
|
|
1370
|
+
message: `Missing React harness dependencies: ${missingHarnessDependencies.join(", ")}. Run \`${packageManager} install\` in ${cwd}, then retry render/site/instrument.`
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
return {
|
|
1374
|
+
name: "dependencies",
|
|
1375
|
+
status: "ok",
|
|
1376
|
+
message: "node_modules and React harness dependencies present"
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
async function checkPlaywright(cwd) {
|
|
1380
|
+
const status = await getPlaywrightBrowserStatus(cwd);
|
|
1381
|
+
const pathDetails = status.effectiveBrowserPath === null ? "PLAYWRIGHT_BROWSERS_PATH is unset" : `PLAYWRIGHT_BROWSERS_PATH=${status.effectiveBrowserPath}; exists=${status.browserPathExists}; writable=${status.browserPathWritable}`;
|
|
1382
|
+
if (status.available) {
|
|
1383
|
+
return {
|
|
1384
|
+
name: "playwright",
|
|
1385
|
+
status: "ok",
|
|
1386
|
+
message: `Playwright package available (${pathDetails})`
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
return {
|
|
1390
|
+
name: "playwright",
|
|
1391
|
+
status: "error",
|
|
1392
|
+
remediationCode: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1393
|
+
fixCommand: getPlaywrightInstallCommand(status.effectiveBrowserPath),
|
|
1394
|
+
message: `Playwright Chromium unavailable (${pathDetails}) \u2014 ${status.remediation}`
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
function collectFixCommands(checks) {
|
|
1398
|
+
return checks.filter((check) => check.status === "error" && check.fixCommand !== void 0).map((check) => check.fixCommand).filter((command, index, commands) => commands.indexOf(command) === index);
|
|
1399
|
+
}
|
|
1159
1400
|
function formatCheck(check) {
|
|
1160
1401
|
return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
|
|
1161
1402
|
}
|
|
1162
1403
|
function createDoctorCommand() {
|
|
1163
|
-
return new Command2("doctor").description(
|
|
1404
|
+
return new Command2("doctor").description(
|
|
1405
|
+
`Verify your Scope project setup before running other commands.
|
|
1406
|
+
|
|
1407
|
+
CHECKS PERFORMED:
|
|
1408
|
+
config reactscope.config.json exists and is valid JSON
|
|
1409
|
+
tokens reactscope.tokens.json exists and passes validation
|
|
1410
|
+
css globalCSS files referenced in config actually exist
|
|
1411
|
+
manifest .reactscope/manifest.json exists and is not stale
|
|
1412
|
+
dependencies node_modules exists in the target project root
|
|
1413
|
+
playwright Playwright browser runtime is available
|
|
1414
|
+
(stale = source files modified after last generate)
|
|
1415
|
+
|
|
1416
|
+
STATUS LEVELS: ok | warn | error
|
|
1417
|
+
|
|
1418
|
+
Run this first whenever renders fail or produce unexpected output.
|
|
1419
|
+
|
|
1420
|
+
Examples:
|
|
1421
|
+
scope doctor
|
|
1422
|
+
scope doctor --json
|
|
1423
|
+
scope doctor --print-fix-commands
|
|
1424
|
+
scope doctor --json | jq '.checks[] | select(.status == "error")'`
|
|
1425
|
+
).option("--json", "Emit structured JSON output", false).option(
|
|
1426
|
+
"--print-fix-commands",
|
|
1427
|
+
"Print deduplicated shell remediation commands for failing checks",
|
|
1428
|
+
false
|
|
1429
|
+
).action(async (opts) => {
|
|
1164
1430
|
const cwd = process.cwd();
|
|
1165
1431
|
const checks = [
|
|
1166
1432
|
checkConfig(cwd),
|
|
1167
1433
|
checkTokens(cwd),
|
|
1168
1434
|
checkGlobalCss(cwd),
|
|
1169
|
-
checkManifest(cwd)
|
|
1435
|
+
checkManifest(cwd),
|
|
1436
|
+
checkDependencies(cwd),
|
|
1437
|
+
await checkPlaywright(cwd)
|
|
1170
1438
|
];
|
|
1171
1439
|
const errors = checks.filter((c) => c.status === "error").length;
|
|
1172
1440
|
const warnings = checks.filter((c) => c.status === "warn").length;
|
|
1441
|
+
const fixCommands = collectFixCommands(checks);
|
|
1442
|
+
if (opts.printFixCommands) {
|
|
1443
|
+
process.stdout.write(`${JSON.stringify({ cwd, fixCommands }, null, 2)}
|
|
1444
|
+
`);
|
|
1445
|
+
if (errors > 0) process.exit(1);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1173
1448
|
if (opts.json) {
|
|
1174
1449
|
process.stdout.write(
|
|
1175
|
-
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
|
|
1450
|
+
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, fixCommands, checks }, null, 2)}
|
|
1176
1451
|
`
|
|
1177
1452
|
);
|
|
1178
1453
|
if (errors > 0) process.exit(1);
|
|
@@ -1198,16 +1473,36 @@ function createDoctorCommand() {
|
|
|
1198
1473
|
});
|
|
1199
1474
|
}
|
|
1200
1475
|
|
|
1476
|
+
// src/get-skill-command.ts
|
|
1477
|
+
import { Command as Command3 } from "commander";
|
|
1478
|
+
|
|
1479
|
+
// src/skill-content.ts
|
|
1480
|
+
var SKILL_CONTENT = '# Scope \u2014 Agent Skill\n\n## TLDR\nScope is a React codebase introspection toolkit. Use it to answer questions about component structure, props, context dependencies, side effects, and visual output \u2014 without running the app.\n\n**When to reach for it:** Any task requiring "which components use X", "what props does Y accept", "render Z for visual verification", "does this component depend on a provider", or "what design tokens are in use".\n\n**Canonical agent workflow:**\n```\nscope get-skill > /tmp/scope-skill.md # bootstrap command semantics into agent context\nscope init --yes # scaffold config + auto-generate manifest\nscope doctor --json # validate config, tokens, globalCSS, manifest freshness, deps, Playwright\nscope manifest query --context ThemeContext --format json\nscope render all --format json --output-dir .reactscope/renders\nscope site build --output .reactscope/site\n```\n\n---\n\n---\n\n## Mental Model\n\nUnderstanding how Scope\'s data flows is the key to using it effectively as an agent.\n\n```\nSource TypeScript files\n \u2193 (ts-morph AST parse)\n manifest.json \u2190 structural facts: props, hooks, contexts, complexity\n \u2193 (esbuild + Playwright)\n renders/*.json \u2190 visual facts: screenshot, computedStyles, dom, a11y\n \u2193 (token engine)\n compliance-styles.json \u2190 audit facts: which CSS values match tokens, which don\'t\n \u2193 (site generator)\n site/ \u2190 human-readable docs combining all of the above\n```\n\nEach layer depends on the previous. If you\'re getting unexpected results, check whether the earlier layers are stale (run `scope doctor` to diagnose).\n\n---\n\n## The Four Subsystems\n\n### 1. Manifest (`scope manifest *`)\nThe manifest is a static analysis snapshot of your TypeScript source. It tells you:\n- What components exist, where they live, and how they\'re exported\n- What props each component accepts (types, defaults, required/optional)\n- What React hooks they call (`detectedHooks`)\n- What contexts they consume (`requiredContexts`) \u2014 must be provided for a render to succeed\n- Whether they compose other components (`composes` / `composedBy`)\n- Their **complexity class** \u2014 `"simple"` or `"complex"` \u2014 which determines the render engine\n\nThe manifest never runs your code. It only reads TypeScript. This means it\'s fast and safe, but it can\'t know about runtime values.\n\n### 2. Render Engine (`scope render *`)\nThe render engine compiles components with esbuild and renders them in Chromium (Playwright). Two paths exist:\n\n| Path | When | Speed | Capability |\n|------|------|-------|------------|\n| **Satori** | `complexityClass: "simple"` | ~8ms | Flexbox only, no JS, no CSS-in-JS |\n| **BrowserPool** | `complexityClass: "complex"` | ~200\u2013800ms | Full DOM, CSS, Tailwind, animations |\n\nMost real-world components route through BrowserPool. Scope defaults to `"complex"` when uncertain (safe fallback).\n\nEach render produces:\n- `screenshot` \u2014 retina-quality PNG (2\xD7 `deviceScaleFactor`; display at CSS px dimensions)\n- `width` / `height` \u2014 CSS pixel dimensions of the component root\n- `computedStyles` \u2014 per-node computed CSS keyed by `#node-0`, `#node-1`, etc.\n- `dom` \u2014 full DOM tree with bounding boxes (BrowserPool only)\n- `accessibility` \u2014 role, aria-name, violation list (BrowserPool only)\n- `renderTimeMs` \u2014 wall-clock render duration\n\n### 3. Scope Files (`.scope.tsx` / `.scope.ts`)\nScope files define **named rendering scenarios** and optional provider wrappers next to a component. They are the primary way to ensure `render all` produces meaningful screenshots.\n\nDiscovery is deterministic: for `Button.tsx`, Scope checks `Button.scope.tsx`, then `.scope.ts`, `.scope.jsx`, `.scope.js` in the same directory and uses the first file that exists.\n\n```tsx\n// Button.scope.tsx\nexport const scenarios = {\n default: { variant: \'primary\', children: \'Click me\' },\n ghost: { variant: \'ghost\', children: \'Cancel\' },\n danger: { variant: \'danger\', children: \'Delete\' },\n disabled: { variant: \'primary\', children: \'Disabled\', disabled: true },\n} satisfies Record<string, Record<string, unknown>>;\n```\n\nOptional wrapper:\n\n```tsx\nimport type { ReactNode } from \'react\';\nimport { ThemeProvider } from \'../providers/ThemeProvider\';\n\nexport function wrapper({ children }: { children: ReactNode }) {\n return <ThemeProvider theme="dark">{children}</ThemeProvider>;\n}\n```\n\nContract:\n- Export `scenarios` as a plain object of scenario-name \u2192 props-object, either as a named export or under `default.scenarios`\n- Export `wrapper` as a function, either as a named export or under `default.wrapper`\n- Non-object scenario values are skipped with a warning\n- If no scope file or no valid scenarios exist, Scope falls back to one bare render and inferred required-prop defaults\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into each component JSON\n\nWhen a component renders blank with `{}` props, **the fix is usually to create a `.scope.tsx` file** with real props.\n\n### 4. Token Compliance\nThe compliance pipeline:\n1. `scope render all` captures `computedStyles` for every element in every component\n2. These are written to `.reactscope/compliance-styles.json`\n3. The token engine compares each computed CSS value against your `reactscope.tokens.json`\n4. `scope tokens compliance` reports the aggregate on-system percentage\n5. `scope ci` fails if the percentage is below `complianceThreshold` (default 90%)\n\n**On-system** means the value exactly matches a resolved token value. Off-system means it\'s a hardcoded value with no token backing it.\n\n---\n\n## Complexity Classes \u2014 Practical Guide\n\nThe `complexityClass` field determines which render engine runs. Scope auto-detects it, but agents should understand it:\n\n**`"simple"` components:**\n- Pure presentational, flexbox layout only\n- No CSS grid, no absolute/fixed/sticky positioning\n- No CSS animations, transitions, or transforms\n- No `className` values Scope can\'t statically trace (e.g. dynamic Tailwind classes)\n- Renders in ~8ms via Satori (SVG-based, no browser needed)\n\n**`"complex"` components:**\n- Anything using Tailwind (CSS injection required)\n- CSS grid, positioned elements, overflow, z-index\n- Components that read from context at render time\n- Any component Scope isn\'t sure about (conservative default)\n- Renders in ~200\u2013800ms via Playwright BrowserPool\n\nWhen in doubt: complex is always safe. Simple is an optimization.\n\n---\n\n## Required Contexts \u2014 Why Renders Fail\n\nIf `requiredContexts` is non-empty, the component calls `useContext` on one or more contexts. Without a provider, it will either render broken or throw entirely.\n\nTwo ways to fix:\n1. **Provider presets in config** (recommended): add provider names to `reactscope.config.json \u2192 components.wrappers.providers`\n2. **Scope file with wrapper**: wrap the component in a provider in the scenario itself\n\nBuilt-in mocks (always provided): `ThemeContext \u2192 { theme: \'light\' }`, `LocaleContext \u2192 { locale: \'en-US\' }`.\n\n---\n\n## `scope doctor` \u2014 Always Run This First\n\nBefore debugging any render issue, run:\n```bash\nscope doctor --json\n```\n\nIt checks:\n- `reactscope.config.json` is valid JSON\n- Token file exists and has a `version` field\n- Every path in `globalCSS` resolves on disk\n- Manifest is present and up to date (not stale relative to source)\n- Target-project dependencies are installed (`node_modules` exists)\n- Playwright Chromium is available for render/site/instrument commands\n\n**If `globalCSS` is empty or missing**: Tailwind styles won\'t apply to renders. Every component will look unstyled. This is the most common footgun. Fix: add your CSS entry file (the one with `@tailwind base; @tailwind components; @tailwind utilities;`) to `globalCSS` in config.\n\n---\n\n## Agent Decision Tree\n\n**"I want to know what props Component X accepts"**\n\u2192 `scope manifest get X --format json | jq \'.props\'`\n\n**"I want to know which components will break if I change a context"**\n\u2192 `scope manifest query --context MyContext --format json`\n\n**"I want to render a component to verify visual output"**\n\u2192 Create a `.scope.tsx` file with real props first, then `scope render component X --format json`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all --format json --output-dir .reactscope/renders` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\' --format json`\n\n**"I want to audit token compliance"**\n\u2192 `scope render all` first (populates computedStyles), then `scope tokens compliance`\n\n**"Renders look unstyled / blank"**\n\u2192 Run `scope doctor` \u2014 likely missing `globalCSS`\n\u2192 If props are the issue: create/update the `.scope.tsx` file\n\n**"I want to understand blast radius of a token change"**\n\u2192 `scope tokens impact color.primary.500 --new-value \'#0077dd\'`\n\u2192 `scope tokens preview color.primary.500 --new-value \'#0077dd\'` for visual diff\n\n**"I need to set up Scope in a new project"**\n\u2192 `scope init --yes` (auto-detects Tailwind + CSS, generates manifest automatically)\n\u2192 `scope doctor` to validate\n\u2192 Create `.scope.tsx` files for key components\n\u2192 `scope render all`\n\n**"I want to profile a live SPA"**\n\u2192 Generate a manifest, then `scope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'`\n\u2192 For auth/timing-heavy apps, follow `docs/profiling-production-spas.md`\n\n**"I want to run Scope in CI"**\n\u2192 `scope ci --json --output ci-result.json`\n\u2192 Exit code 0 = pass, non-zero = specific failure type\n\n---\n\n\n## Installation\n\nPublished package:\n\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nLocal-from-source quickstart for this repo:\n\n```bash\nbun install\nbun run build\nbunx playwright install chromium\ncd fixtures/tailwind-showcase\nbun install\n../../packages/cli/dist/cli.js init --yes\n../../packages/cli/dist/cli.js doctor --json\n../../packages/cli/dist/cli.js render all --format json --output-dir .reactscope/renders\n../../packages/cli/dist/cli.js site build --output .reactscope/site\n```\n\nBinary: `scope` (published install) or `packages/cli/dist/cli.js` (local build)\n\n---\n\n## Core Workflow\n\n```\nget-skill \u2192 init --yes \u2192 doctor --json \u2192 manifest query/get/list --format json \u2192 render all --format json \u2192 site build \u2192 instrument profile \u2192 ci\n```\n\n- **init**: Scaffold `reactscope.config.json` + token stub, auto-detect framework/globalCSS, **immediately runs `manifest generate`** so you see results right away.\n- **doctor**: Health-check command \u2014 validates config, token file, globalCSS presence, manifest staleness, target-project dependencies, and Playwright Chromium availability.\n- **generate**: Parse TypeScript AST and emit `.reactscope/manifest.json`. Run once per codebase change (or automatically via `scope init`).\n- **query / get / list**: Ask structural questions. No network required. Works from manifest alone. Supports filtering by `--collection` and `--internal`.\n- **render**: Produce PNGs of components via esbuild + Playwright (BrowserPool). Requires manifest for file paths. Auto-injects required prop defaults and globalCSS.\n- **token audit**: Validate design tokens via `@scope/tokens` CLI commands (`tokens list`, `tokens compliance`, `tokens impact`, `tokens preview`, `tokens export`).\n- **ci**: Run compliance checks and exit with code 0/1 for CI pipelines. `report pr-comment` posts results to GitHub PRs.\n\n---\n\n## Full CLI Reference\n\n### `scope init`\nScaffold config, detect framework, extract Tailwind tokens, detect globalCSS files, and **automatically run `scope manifest generate`**.\n\n```bash\nscope init\nscope init --force # overwrite existing config\n```\n\nAfter init completes, the manifest is already written \u2014 no manual `scope manifest generate` step needed.\n\n**Tailwind token extraction**: reads `tailwind.config.js`, extracts colors (with nested scale support), spacing, fontFamily, borderRadius. Stored in `reactscope.tokens.json`.\n\n**globalCSS detection**: checks 9 common patterns (`src/styles.css`, `src/index.css`, `app/globals.css`, etc.). Stored in `components.wrappers.globalCSS` in config.\n\n---\n\n### `scope doctor`\nValidate the Scope setup. Exits non-zero on errors, zero on warnings-only.\n\n```bash\nscope doctor --json\nscope doctor --json\n```\n\nChecks:\n- `config` \u2014 `reactscope.config.json` is valid JSON with required fields\n- `tokens` \u2014 token file is present and has a valid `version` field\n- `globalCSS` \u2014 globalCSS files listed in config exist on disk\n- `manifest` \u2014 manifest exists and is not stale (compares source file mtimes)\n- `dependencies` \u2014 `node_modules` exists in the target project root\n- `playwright` \u2014 Playwright Chromium is available before render/site/instrument\n\nIf `dependencies` fails, run `bun install` (or the package manager detected by your lockfile) in the target project root, then rerun `scope doctor --json`.\n\nIf `playwright` fails, run `bunx playwright install chromium`, then rerun `scope doctor --json` before retrying `scope render component`, `scope render all`, `scope site build`, or `scope instrument ...`.\n\n```\n$ scope doctor --json\nScope Doctor\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n [\u2713] config reactscope.config.json valid\n [\u2713] tokens Token file valid\n [\u2713] globalCSS 1 globalCSS file(s) present\n [!] manifest Manifest may be stale \u2014 5 source file(s) modified since last generate\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 1 warning(s) \u2014 everything works but could be better\n```\n\n---\n\n### `scope capture <url>`\nCapture a live React component tree from a running app URL.\n\n```bash\nscope capture http://localhost:3000\nscope capture http://localhost:3000 --output report.json --pretty\nscope capture http://localhost:3000 --timeout 15000 --wait 2000\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `-o, --output <path>` | string | stdout | Write JSON to file |\n| `--pretty` | bool | false | Pretty-print JSON |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\nOutput (stdout): serialized PageReport JSON (or path when `--output` is set)\n\n---\n\n### `scope tree <url>`\nPrint the React component tree from a live URL.\n\n```bash\nscope tree http://localhost:3000\nscope tree http://localhost:3000 --depth 3 --show-props --show-hooks\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--depth <n>` | number | unlimited | Max depth to display |\n| `--show-props` | bool | false | Include prop names next to components |\n| `--show-hooks` | bool | false | Show hook counts per component |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report <url>`\nCapture and print a human-readable summary of a React app.\n\n```bash\nscope report http://localhost:3000\nscope report http://localhost:3000 --json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--json` | bool | false | Emit structured JSON instead of text |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report baseline`\nSave a baseline snapshot for future diff comparisons.\n\n```bash\nscope report baseline\nscope report baseline --output baselines/my-baseline.json\n```\n\n---\n\n### `scope report diff`\nDiff the current app state against a saved baseline.\n\n```bash\nscope report diff\nscope report diff --baseline baselines/my-baseline.json\nscope report diff --json\n```\n\n---\n\n### `scope report pr-comment`\nPost a Scope CI report as a GitHub PR comment. Used in CI pipelines via the reusable `scope-ci` workflow.\n\n```bash\nscope report pr-comment --report-path scope-ci-report.json\n```\n\nRequires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and `GITHUB_PR_NUMBER` in environment.\n\n---\n\n### `scope manifest generate`\nScan source files and write `.reactscope/manifest.json`.\n\n```bash\nscope manifest generate\nscope manifest generate --root ./packages/ui\nscope manifest generate --include "src/**/*.tsx" --exclude "**/*.test.tsx"\nscope manifest generate --output custom/manifest.json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--root <path>` | string | cwd | Project root directory |\n| `--output <path>` | string | `.reactscope/manifest.json` | Output path |\n| `--include <globs>` | string | `src/**/*.tsx,src/**/*.ts` | Comma-separated include globs |\n| `--exclude <globs>` | string | `**/node_modules/**,...` | Comma-separated exclude globs |\n\n---\n\n### `scope manifest list`\nList all components in the manifest.\n\n```bash\nscope manifest list\nscope manifest list --filter "Button*"\nscope manifest list --format json\nscope manifest list --collection Forms # filter to named collection\nscope manifest list --internal # only internal components\nscope manifest list --no-internal # hide internal components\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--format <fmt>` | `json\\|table` | auto (TTY\u2192table, pipe\u2192json) | Output format |\n| `--filter <glob>` | string | \u2014 | Filter component names by glob |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--internal` | bool | false | Show only internal components |\n| `--no-internal` | bool | false | Hide internal components |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\nTTY table output (includes COLLECTION and INTERNAL columns):\n```\nNAME FILE COMPLEXITY HOOKS CONTEXTS COLLECTION INTERNAL\n------------ --------------------------- ---------- ----- -------- ---------- --------\nButton src/components/Button.tsx simple 1 0 \u2014 no\nThemeToggle src/components/Toggle.tsx complex 3 1 Forms no\n```\n\n---\n\n### `scope manifest get <name>`\nGet full details of a single component.\n\n```bash\nscope manifest get Button\nscope manifest get Button --format json\n```\n\nJSON output includes `collection` and `internal` fields:\n```json\n{\n "name": "Button",\n "filePath": "src/components/Button.tsx",\n "collection": "Primitives",\n "internal": false,\n ...\n}\n```\n\n---\n\n### `scope manifest query`\nQuery components by attributes.\n\n```bash\nscope manifest query --context ThemeContext\nscope manifest query --hook useEffect\nscope manifest query --complexity complex\nscope manifest query --side-effects\nscope manifest query --has-fetch\nscope manifest query --has-prop <propName>\nscope manifest query --composed-by <ComponentName>\nscope manifest query --internal\nscope manifest query --collection Forms\nscope manifest query --context ThemeContext --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--context <name>` | string | \u2014 | Find components consuming a context by name |\n| `--hook <name>` | string | \u2014 | Find components using a specific hook |\n| `--complexity <class>` | `simple\\|complex` | \u2014 | Filter by complexity class |\n| `--side-effects` | bool | false | Any side effects detected |\n| `--has-fetch` | bool | false | Components with fetch calls specifically |\n| `--has-prop <name>` | string | \u2014 | Components that accept a specific prop |\n| `--composed-by <name>` | string | \u2014 | Components rendered inside a specific parent |\n| `--internal` | bool | false | Only internal components |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--format <fmt>` | `json\\|table` | auto | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render <component>`\nRender a single component to PNG (TTY) or JSON (pipe).\n\n**Auto prop defaults**: if `--props` is omitted, Scope injects sensible defaults so required props don\'t produce blank renders: strings/nodes \u2192 component name, unions \u2192 first value, booleans \u2192 `false`, numbers \u2192 `0`.\n\n**globalCSS auto-injection**: reads `components.wrappers.globalCSS` from config and compiles/injects CSS (supports Tailwind v3 via PostCSS) into the render harness. A warning is printed to stderr if no globalCSS is configured (common cause of unstyled renders).\n\n```bash\nscope render component Button\nscope render component Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render component Button --viewport 375x812\nscope render component Button --output button.png\nscope render component Button --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--props <json>` | string | `{}` | Inline props as JSON string |\n| `--viewport <WxH>` | string | `375x812` | Viewport size |\n| `--theme <name>` | string | \u2014 | Theme name from token system |\n| `-o, --output <path>` | string | \u2014 | Write PNG to specific path |\n| `--format <fmt>` | `png\\|json` | auto (TTY\u2192file, pipe\u2192json) | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render matrix <component>`\nRender across a Cartesian product of prop axes. Accepts both `key:v1,v2` and `{"key":["v1","v2"]}` JSON format for `--axes`.\n\n```bash\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\nscope render matrix Button --axes \'variant:primary,secondary size:sm,md,lg\'\nscope render matrix Button --sprite button-matrix.png --format json\n```\n\n---\n\n### `scope render all`\nRender every component in the manifest.\n\n```bash\nscope render all\nscope render all --concurrency 4 --output-dir renders/\n```\n\nHandles imports of CSS files in components (maps to empty loader so styles are injected at page level). SVG and font imports are handled via dataurl loaders.\n\n---\n\n### `scope instrument tree`\nCapture the live React component tree with instrumentation metadata.\n\n```bash\nscope instrument tree http://localhost:3000\nscope instrument tree http://localhost:3000 --depth 5 --show-props\n```\n\n**Implementation note**: uses a fresh `chromium.launch()` + `newContext()` + `newPage()` per call (not BrowserPool), with `addInitScript` called before `setContent` to ensure the Scope runtime is injected at document-start before React loads.\n\n---\n\n### `scope instrument hooks`\nProfile hook execution in live components.\n\n```bash\nscope instrument hooks http://localhost:3000\nscope instrument hooks http://localhost:3000 --component Button\n```\n\n**Implementation note**: requires `addInitScript({ content: getBrowserEntryScript() })` before `setContent` so `__REACT_DEVTOOLS_GLOBAL_HOOK__` is present when React loads its renderer.\n\n---\n\n### `scope instrument profile`\nProfile render performance of live components.\n\n```bash\nscope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'\n```\n\n---\n\n### `scope instrument renders`\nRe-render causality analysis \u2014 what triggered each render.\n\n```bash\nscope instrument renders http://localhost:3000\n```\n\n---\n\n### `scope tokens get <name>`\nGet details of a single design token.\n\n### `scope tokens list`\nList all tokens. Token file must have a `version` field (written by `scope init`).\n\n```bash\nscope tokens list\nscope tokens list --type color\nscope tokens list --format json\n```\n\n### `scope tokens search <query>`\nFull-text search across token names/values.\n\n### `scope tokens resolve <value>`\nResolve a CSS value or alias back to its token name.\n\n### `scope tokens validate`\nValidate token file schema.\n\n### `scope tokens compliance`\nCheck rendered components for design token compliance.\n\n```bash\nscope tokens compliance\nscope tokens compliance --threshold 95\n```\n\n### `scope tokens impact <token>`\nAnalyze impact of changing a token \u2014 which components use it.\n\n```bash\nscope tokens impact --token color.primary.500\n```\n\n### `scope tokens preview <token>`\nPreview a token value change visually before committing.\n\n### `scope tokens export`\nExport tokens in multiple formats.\n\n```bash\nscope tokens export --format flat-json\nscope tokens export --format css\nscope tokens export --format scss\nscope tokens export --format ts\nscope tokens export --format tailwind\nscope tokens export --format style-dictionary\n```\n\n**Format aliases** (auto-corrected with "Did you mean?" hint):\n- `json` \u2192 `flat-json`\n- `js` \u2192 `ts`\n- `sass` \u2192 `scss`\n- `tw` \u2192 `tailwind`\n\n---\n\n### `scope ci`\nRun all CI checks (compliance, accessibility, console errors) and exit 0/1.\n\n```bash\nscope ci\nscope ci --json\nscope ci --threshold 90 # compliance threshold (default: 90)\n```\n\n```\n$ scope ci --json\n\u2192 CI passed in 3.2s\n\u2192 Compliance 100.0% >= threshold 90.0% \u2705\n\u2192 Accessibility audit not yet implemented \u2014 skipped \u2705\n\u2192 No console errors detected \u2705\n\u2192 Exit code 0\n```\n\nThe `scope-ci` **reusable GitHub Actions workflow** is available at `.github/workflows/scope-ci.yml` and can be included in any repo\'s CI to run `scope ci` and post results as a PR comment via `scope report pr-comment`.\n\n---\n\n### `scope site build`\nGenerate a static HTML component gallery site from the manifest.\n\n```bash\nscope site build\nscope site build --output ./dist/site\n```\n\n**Collections support**: components are grouped under named collection sections in the sidebar and index grid. Internal components are hidden from the sidebar and card grid but appear in composition detail sections with an `internal` badge.\n\n**Collection display rules**:\n- Sidebar: one section divider per collection + an "Ungrouped" section; internal components excluded\n- Index page: named sections with heading + optional description; internal components excluded\n- Component detail page: Composes/Composed By lists ALL components including internal ones (with subtle badge)\n- Falls back to flat list when no collections configured (backwards-compatible)\n\n### `scope site serve`\nServe the generated site locally.\n\n```bash\nscope site serve\nscope site serve --port 4000\n```\n\n---\n\n## Collections & Internal Components\n\nComponents can be organized into named **collections** and flagged as **internal** (library implementation details not shown in the public gallery).\n\n### Defining collections\n\n**1. TSDoc tag** (highest precedence):\n```tsx\n/**\n * @collection Forms\n */\nexport function Input() { ... }\n```\n\n**2. `.scope.ts` co-located file**:\n```ts\n// Input.scope.ts\nexport const collection = "Forms"\n```\n\n**3. Config-level glob patterns**:\n```json\n// reactscope.config.json\n{\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ]\n}\n```\n\nResolution precedence: TSDoc `@collection` > `.scope.ts` export > config pattern.\n\n### Flagging internal components\n\n**TSDoc tag**:\n```tsx\n/**\n * @internal\n */\nexport function InternalHelperButton() { ... }\n```\n\n**Config glob patterns**:\n```json\n{\n "internalPatterns": ["src/internal/**", "src/**/*Internal*"]\n}\n```\n\n---\n\n## Manifest Output Schema\n\nFile: `.reactscope/manifest.json`\n\n```typescript\n{\n version: "0.1",\n generatedAt: string, // ISO 8601\n collections: CollectionConfig[], // echoes config.collections, [] when not set\n components: Record<string, ComponentDescriptor>,\n tree: Record<string, { children: string[], parents: string[] }>\n}\n```\n\n### `ComponentDescriptor` fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `filePath` | `string` | Relative path from project root to source file |\n| `exportType` | `"named" \\| "default" \\| "none"` | How the component is exported |\n| `displayName` | `string` | `displayName` if set, else function/class name |\n| `collection` | `string?` | Resolved collection name (`undefined` = ungrouped) |\n| `internal` | `boolean` | `true` if flagged as internal (default: `false`) |\n| `props` | `Record<string, PropDescriptor>` | Extracted prop types keyed by prop name |\n| `composes` | `string[]` | Components this one renders in its JSX |\n| `composedBy` | `string[]` | Components that render this one in their JSX |\n| `complexityClass` | `"simple" \\| "complex"` | Render path: simple = Satori-safe, complex = requires BrowserPool |\n| `requiredContexts` | `string[]` | React context names consumed |\n| `detectedHooks` | `string[]` | All hooks called, sorted alphabetically |\n| `sideEffects` | `SideEffects` | Side effect categories detected |\n| `memoized` | `boolean` | Wrapped with `React.memo` |\n| `forwardedRef` | `boolean` | Wrapped with `React.forwardRef` |\n| `hocWrappers` | `string[]` | HOC wrapper names (excluding memo/forwardRef) |\n| `loc` | `{ start: number, end: number }` | Line numbers in source file |\n\n---\n\n## Common Agent Workflows\n\n### Structural queries\n\n```bash\n# Which components use ThemeContext?\nscope manifest query --context ThemeContext\n\n# What props does Button accept?\nscope manifest get Button --format json | jq \'.props\'\n\n# Which components are safe to render without a provider?\nscope manifest query --complexity simple # + check requiredContexts === []\n\n# Show all components with side effects\nscope manifest query --side-effects\n\n# Which components make fetch calls?\nscope manifest query --has-fetch\n\n# Which components use useEffect?\nscope manifest query --hook useEffect\n\n# Which components accept a disabled prop?\nscope manifest query --has-prop disabled\n\n# Which components are composed inside Modal?\nscope manifest query --composed-by Modal\n\n# All components in the Forms collection\nscope manifest list --collection Forms\n\n# All internal components (library implementation details)\nscope manifest list --internal\n\n# Public components only (hide internals)\nscope manifest list --no-internal\n```\n\n### Render workflows\n\n```bash\n# Render Button in all variants (auto-defaults props if not provided)\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\n\n# Render with JSON axes format\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\n\n# Render with explicit props\nscope render component Button --props \'{"variant":"primary","disabled":true}\'\n\n# Render all components (handles CSS/SVG/font imports automatically)\nscope render all --concurrency 8\n\n# Get render as JSON\nscope render component Button --format json | jq \'.screenshot\' | base64 -d > button.png\n```\n\n### Token workflows\n\n```bash\n# List all tokens\nscope tokens list\n\n# Check compliance\nscope tokens compliance --threshold 95\n\n# See what a token change impacts\nscope tokens impact --token color.primary.500\n\n# Export for Tailwind\nscope tokens export --format tailwind\n```\n\n### CI workflow\n\n```bash\n# Full compliance check\nscope ci --json\n\n# In GitHub Actions \u2014 use the reusable workflow\n# .github/workflows/ci.yml:\n# uses: FlatFilers/Scope/.github/workflows/scope-ci.yml@main\n```\n\n---\n\n## Error Patterns\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `"React root not found"` | App not running, wrong URL, or Vite HMR interfering | Use `scope capture --wait 2000` |\n| `"Component not in manifest"` | Manifest is stale | Run `scope manifest generate` first |\n| `"Manifest not found"` | Missing manifest | Run `scope init` or `scope manifest generate` |\n| `"requiredContexts missing"` | Component needs a provider | Add provider presets to `reactscope.config.json` |\n| Blank PNG / 16\xD76px renders | No globalCSS injected (common with Tailwind) | Set `components.wrappers.globalCSS` in config; run `scope doctor` to verify |\n| `PLAYWRIGHT_BROWSERS_MISSING` / `browserType.launch: Executable doesn\'t exist` | Playwright Chromium is not installed in this sandbox | Run `bunx playwright install chromium`, then `scope doctor --json`, then retry render/site/instrument |\n| `TARGET_PROJECT_DEPENDENCIES_MISSING` / `Could not resolve "react"` | Target project dependencies are missing | Run `bun install` (or the detected package manager) in the target project root, then `scope doctor --json` |\n| `"Invalid props JSON"` | Malformed JSON in `--props` | Use single outer quotes: `--props \'{"key":"val"}\'` |\n| `"SCOPE_CAPTURE_JSON not available"` | Scope runtime not injected before React loaded | Fixed in PR #83 \u2014 update CLI |\n| `"No React DevTools hook found"` | Hook instrumentation init order bug | Fixed in PR #83 \u2014 update CLI |\n| `"ERR_MODULE_NOT_FOUND"` after tokens commands | Old Node shebang in CLI binary | Fixed in PR #90 \u2014 CLI now uses `#!/usr/bin/env bun` |\n| `"version" field missing in tokens` | Token stub written by old `scope init` | Re-run `scope init --force` or add `"version": "1"` to token file |\n| `"unknown option --has-prop"` | Old CLI version | Fixed in PR #90 \u2014 update CLI |\n| Format alias error (`json`, `js`, `sass`, `tw`) | Wrong format name for `tokens export` | Use `flat-json`, `ts`, `scss`, `tailwind`; CLI shows "Did you mean?" hint |\n\n---\n\n## `reactscope.config.json`\n\n```json\n{\n "components": {\n "wrappers": {\n "globalCSS": ["src/styles.css"]\n }\n },\n "tokens": {\n "file": "reactscope.tokens.json"\n },\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ],\n "internalPatterns": ["src/internal/**"],\n "providers": {\n "theme": { "component": "ThemeProvider", "props": { "theme": "light" } },\n "router": { "component": "MemoryRouter", "props": { "initialEntries": ["/"] } }\n }\n}\n```\n\n**Built-in mock providers** (always available, no config needed):\n- `ThemeContext` \u2192 `{ theme: \'light\' }` (or `--theme <name>`)\n- `LocaleContext` \u2192 `{ locale: \'en-US\' }`\n\n---\n\n## What Scope Cannot Do\n\n- **Runtime state**: `useState` values after user interaction\n- **Network requests**: `fetch`, `XHR`, `WebSocket`\n- **User interactions**: click, type, hover, drag\n- **Auth/session-gated components**: components that redirect or throw without a session\n- **Server components (RSC)**: React Server Components\n- **Dynamic CSS**: CSS-in-JS styles computed at runtime from props Scope can\'t infer\n\n---\n\n## Version History\n\n| Version | Date | Summary |\n|---------|------|---------|\n| v1.0 | 2026-03-11 | Initial SKILL.md (PR #36) \u2014 manifest, render, capture, tree, report, tokens, ci commands |\n| v1.1 | 2026-03-11 | Updated through PR #82 \u2014 Phase 2 CLI commands complete |\n| v1.2 | 2026-03-13 | PRs #83\u2013#95: runtime injection fix, dogfooding fixes (12 bugs), `scope doctor`, `scope init` auto-manifest, globalCSS render warning, collections & internal components feature |\n';
|
|
1481
|
+
|
|
1482
|
+
// src/get-skill-command.ts
|
|
1483
|
+
function createGetSkillCommand() {
|
|
1484
|
+
return new Command3("get-skill").description(
|
|
1485
|
+
'Print the embedded Scope SKILL.md to stdout.\n\nAgents: load this first, then follow the canonical workflow in the skill:\n init --yes \u2192 doctor --json \u2192 manifest --format json \u2192 render all --format json \u2192 site build\nThe skill documents .scope.tsx discovery/exports, config paths, profiler usage,\nand failure recovery.\n\nEMBEDDED AT BUILD TIME \u2014 works in any install context (global npm, npx, local).\n\nExamples:\n scope get-skill # raw markdown to stdout\n scope get-skill --json # { "skill": "..." } for structured ingestion\n scope get-skill | head -50 # preview the skill\n scope get-skill > /tmp/SKILL.md # save locally'
|
|
1486
|
+
).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
|
|
1487
|
+
if (opts.json) {
|
|
1488
|
+
process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
|
|
1489
|
+
`);
|
|
1490
|
+
} else {
|
|
1491
|
+
process.stdout.write(SKILL_CONTENT);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1201
1496
|
// src/init/index.ts
|
|
1202
|
-
import { appendFileSync, existsSync as
|
|
1203
|
-
import { join as
|
|
1497
|
+
import { appendFileSync, existsSync as existsSync6, mkdirSync, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
|
|
1498
|
+
import { join as join4 } from "path";
|
|
1204
1499
|
import * as readline from "readline";
|
|
1205
1500
|
|
|
1206
1501
|
// src/init/detect.ts
|
|
1207
|
-
import { existsSync as
|
|
1208
|
-
import { join as
|
|
1502
|
+
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "fs";
|
|
1503
|
+
import { join as join3 } from "path";
|
|
1209
1504
|
function hasConfigFile(dir, stem) {
|
|
1210
|
-
if (!
|
|
1505
|
+
if (!existsSync5(dir)) return false;
|
|
1211
1506
|
try {
|
|
1212
1507
|
const entries = readdirSync2(dir);
|
|
1213
1508
|
return entries.some((f) => f === stem || f.startsWith(`${stem}.`));
|
|
@@ -1217,7 +1512,7 @@ function hasConfigFile(dir, stem) {
|
|
|
1217
1512
|
}
|
|
1218
1513
|
function readSafe(path) {
|
|
1219
1514
|
try {
|
|
1220
|
-
return
|
|
1515
|
+
return readFileSync5(path, "utf-8");
|
|
1221
1516
|
} catch {
|
|
1222
1517
|
return null;
|
|
1223
1518
|
}
|
|
@@ -1229,16 +1524,16 @@ function detectFramework(rootDir, packageDeps) {
|
|
|
1229
1524
|
if ("react-scripts" in packageDeps) return "cra";
|
|
1230
1525
|
return "unknown";
|
|
1231
1526
|
}
|
|
1232
|
-
function
|
|
1233
|
-
if (
|
|
1234
|
-
if (
|
|
1235
|
-
if (
|
|
1236
|
-
if (
|
|
1527
|
+
function detectPackageManager2(rootDir) {
|
|
1528
|
+
if (existsSync5(join3(rootDir, "bun.lock"))) return "bun";
|
|
1529
|
+
if (existsSync5(join3(rootDir, "yarn.lock"))) return "yarn";
|
|
1530
|
+
if (existsSync5(join3(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
1531
|
+
if (existsSync5(join3(rootDir, "package-lock.json"))) return "npm";
|
|
1237
1532
|
return "npm";
|
|
1238
1533
|
}
|
|
1239
1534
|
function detectTypeScript(rootDir) {
|
|
1240
|
-
const candidate =
|
|
1241
|
-
if (
|
|
1535
|
+
const candidate = join3(rootDir, "tsconfig.json");
|
|
1536
|
+
if (existsSync5(candidate)) {
|
|
1242
1537
|
return { typescript: true, tsconfigPath: candidate };
|
|
1243
1538
|
}
|
|
1244
1539
|
return { typescript: false, tsconfigPath: null };
|
|
@@ -1250,8 +1545,8 @@ function detectComponentPatterns(rootDir, typescript) {
|
|
|
1250
1545
|
const ext = typescript ? "tsx" : "jsx";
|
|
1251
1546
|
const altExt = typescript ? "jsx" : "jsx";
|
|
1252
1547
|
for (const dir of COMPONENT_DIRS) {
|
|
1253
|
-
const absDir =
|
|
1254
|
-
if (!
|
|
1548
|
+
const absDir = join3(rootDir, dir);
|
|
1549
|
+
if (!existsSync5(absDir)) continue;
|
|
1255
1550
|
let hasComponents = false;
|
|
1256
1551
|
try {
|
|
1257
1552
|
const entries = readdirSync2(absDir, { withFileTypes: true });
|
|
@@ -1262,7 +1557,7 @@ function detectComponentPatterns(rootDir, typescript) {
|
|
|
1262
1557
|
hasComponents = entries.some(
|
|
1263
1558
|
(e) => e.isDirectory() && (() => {
|
|
1264
1559
|
try {
|
|
1265
|
-
return readdirSync2(
|
|
1560
|
+
return readdirSync2(join3(absDir, e.name)).some(
|
|
1266
1561
|
(f) => COMPONENT_EXTS.some((x) => f.endsWith(x))
|
|
1267
1562
|
);
|
|
1268
1563
|
} catch {
|
|
@@ -1299,12 +1594,37 @@ var GLOBAL_CSS_CANDIDATES = [
|
|
|
1299
1594
|
"styles/index.css"
|
|
1300
1595
|
];
|
|
1301
1596
|
function detectGlobalCSSFiles(rootDir) {
|
|
1302
|
-
return GLOBAL_CSS_CANDIDATES.filter((rel) =>
|
|
1597
|
+
return GLOBAL_CSS_CANDIDATES.filter((rel) => existsSync5(join3(rootDir, rel)));
|
|
1303
1598
|
}
|
|
1304
1599
|
var TAILWIND_STEMS = ["tailwind.config"];
|
|
1305
1600
|
var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
|
|
1306
1601
|
var THEME_SUFFIXES = [".theme.ts", ".theme.js", ".theme.tsx"];
|
|
1307
1602
|
var CSS_CUSTOM_PROPS_RE = /:root\s*\{[^}]*--[a-zA-Z]/;
|
|
1603
|
+
var TAILWIND_V4_THEME_RE = /@theme\s*(?:inline\s*)?\{[^}]*--[a-zA-Z]/;
|
|
1604
|
+
var MAX_SCAN_DEPTH = 4;
|
|
1605
|
+
var SKIP_CSS_NAMES = ["compiled", ".min."];
|
|
1606
|
+
function collectCSSFiles(dir, depth) {
|
|
1607
|
+
if (depth > MAX_SCAN_DEPTH) return [];
|
|
1608
|
+
const results = [];
|
|
1609
|
+
try {
|
|
1610
|
+
const entries = readdirSync2(dir, { withFileTypes: true });
|
|
1611
|
+
for (const entry of entries) {
|
|
1612
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next") {
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
const full = join3(dir, entry.name);
|
|
1616
|
+
if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
|
|
1617
|
+
if (!SKIP_CSS_NAMES.some((skip) => entry.name.includes(skip))) {
|
|
1618
|
+
results.push(full);
|
|
1619
|
+
}
|
|
1620
|
+
} else if (entry.isDirectory()) {
|
|
1621
|
+
results.push(...collectCSSFiles(full, depth + 1));
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
} catch {
|
|
1625
|
+
}
|
|
1626
|
+
return results;
|
|
1627
|
+
}
|
|
1308
1628
|
function detectTokenSources(rootDir) {
|
|
1309
1629
|
const sources = [];
|
|
1310
1630
|
for (const stem of TAILWIND_STEMS) {
|
|
@@ -1313,44 +1633,65 @@ function detectTokenSources(rootDir) {
|
|
|
1313
1633
|
const entries = readdirSync2(rootDir);
|
|
1314
1634
|
const match = entries.find((f) => f === stem || f.startsWith(`${stem}.`));
|
|
1315
1635
|
if (match) {
|
|
1316
|
-
sources.push({ kind: "tailwind-config", path:
|
|
1636
|
+
sources.push({ kind: "tailwind-config", path: join3(rootDir, match) });
|
|
1317
1637
|
}
|
|
1318
1638
|
} catch {
|
|
1319
1639
|
}
|
|
1320
1640
|
}
|
|
1321
1641
|
}
|
|
1322
|
-
const srcDir =
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
}
|
|
1642
|
+
const srcDir = join3(rootDir, "src");
|
|
1643
|
+
if (existsSync5(srcDir)) {
|
|
1644
|
+
const cssFiles = collectCSSFiles(srcDir, 0);
|
|
1645
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1646
|
+
for (const filePath of cssFiles) {
|
|
1647
|
+
const content = readSafe(filePath);
|
|
1648
|
+
if (content === null) continue;
|
|
1649
|
+
if (TAILWIND_V4_THEME_RE.test(content) && !seen.has(filePath)) {
|
|
1650
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1651
|
+
seen.add(filePath);
|
|
1652
|
+
}
|
|
1653
|
+
if (CSS_CUSTOM_PROPS_RE.test(content) && !seen.has(filePath)) {
|
|
1654
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1655
|
+
seen.add(filePath);
|
|
1335
1656
|
}
|
|
1336
|
-
} catch {
|
|
1337
1657
|
}
|
|
1338
1658
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1659
|
+
for (const tokenDir of ["tokens", "styles", "theme"]) {
|
|
1660
|
+
const dir = join3(rootDir, tokenDir);
|
|
1661
|
+
if (!existsSync5(dir)) continue;
|
|
1662
|
+
const cssFiles = collectCSSFiles(dir, 0);
|
|
1663
|
+
for (const filePath of cssFiles) {
|
|
1664
|
+
const content = readSafe(filePath);
|
|
1665
|
+
if (content === null) continue;
|
|
1666
|
+
if (TAILWIND_V4_THEME_RE.test(content)) {
|
|
1667
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1668
|
+
} else if (CSS_CUSTOM_PROPS_RE.test(content)) {
|
|
1669
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1346
1670
|
}
|
|
1347
|
-
} catch {
|
|
1348
1671
|
}
|
|
1349
1672
|
}
|
|
1673
|
+
if (existsSync5(srcDir)) {
|
|
1674
|
+
const scanThemeFiles = (dir, depth) => {
|
|
1675
|
+
if (depth > MAX_SCAN_DEPTH) return;
|
|
1676
|
+
try {
|
|
1677
|
+
const entries = readdirSync2(dir, { withFileTypes: true });
|
|
1678
|
+
for (const entry of entries) {
|
|
1679
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
1680
|
+
if (entry.isFile() && THEME_SUFFIXES.some((s) => entry.name.endsWith(s))) {
|
|
1681
|
+
sources.push({ kind: "theme-file", path: join3(dir, entry.name) });
|
|
1682
|
+
} else if (entry.isDirectory()) {
|
|
1683
|
+
scanThemeFiles(join3(dir, entry.name), depth + 1);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
} catch {
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
scanThemeFiles(srcDir, 0);
|
|
1690
|
+
}
|
|
1350
1691
|
return sources;
|
|
1351
1692
|
}
|
|
1352
1693
|
function detectProject(rootDir) {
|
|
1353
|
-
const pkgPath =
|
|
1694
|
+
const pkgPath = join3(rootDir, "package.json");
|
|
1354
1695
|
let packageDeps = {};
|
|
1355
1696
|
const pkgContent = readSafe(pkgPath);
|
|
1356
1697
|
if (pkgContent !== null) {
|
|
@@ -1365,7 +1706,7 @@ function detectProject(rootDir) {
|
|
|
1365
1706
|
}
|
|
1366
1707
|
const framework = detectFramework(rootDir, packageDeps);
|
|
1367
1708
|
const { typescript, tsconfigPath } = detectTypeScript(rootDir);
|
|
1368
|
-
const packageManager =
|
|
1709
|
+
const packageManager = detectPackageManager2(rootDir);
|
|
1369
1710
|
const componentPatterns = detectComponentPatterns(rootDir, typescript);
|
|
1370
1711
|
const tokenSources = detectTokenSources(rootDir);
|
|
1371
1712
|
const globalCSSFiles = detectGlobalCSSFiles(rootDir);
|
|
@@ -1382,7 +1723,7 @@ function detectProject(rootDir) {
|
|
|
1382
1723
|
|
|
1383
1724
|
// src/init/index.ts
|
|
1384
1725
|
import { generateManifest as generateManifest2 } from "@agent-scope/manifest";
|
|
1385
|
-
import { Command as
|
|
1726
|
+
import { Command as Command4 } from "commander";
|
|
1386
1727
|
function buildDefaultConfig(detected, tokenFile, outputDir) {
|
|
1387
1728
|
const include = detected.componentPatterns.length > 0 ? detected.componentPatterns : ["src/**/*.tsx"];
|
|
1388
1729
|
return {
|
|
@@ -1420,9 +1761,9 @@ function createRL() {
|
|
|
1420
1761
|
});
|
|
1421
1762
|
}
|
|
1422
1763
|
async function ask(rl, question) {
|
|
1423
|
-
return new Promise((
|
|
1764
|
+
return new Promise((resolve21) => {
|
|
1424
1765
|
rl.question(question, (answer) => {
|
|
1425
|
-
|
|
1766
|
+
resolve21(answer.trim());
|
|
1426
1767
|
});
|
|
1427
1768
|
});
|
|
1428
1769
|
}
|
|
@@ -1431,9 +1772,9 @@ async function askWithDefault(rl, label, defaultValue) {
|
|
|
1431
1772
|
return answer.length > 0 ? answer : defaultValue;
|
|
1432
1773
|
}
|
|
1433
1774
|
function ensureGitignoreEntry(rootDir, entry) {
|
|
1434
|
-
const gitignorePath =
|
|
1435
|
-
if (
|
|
1436
|
-
const content =
|
|
1775
|
+
const gitignorePath = join4(rootDir, ".gitignore");
|
|
1776
|
+
if (existsSync6(gitignorePath)) {
|
|
1777
|
+
const content = readFileSync6(gitignorePath, "utf-8");
|
|
1437
1778
|
const normalised = entry.replace(/\/$/, "");
|
|
1438
1779
|
const lines = content.split("\n").map((l) => l.trim());
|
|
1439
1780
|
if (lines.includes(entry) || lines.includes(normalised)) {
|
|
@@ -1462,7 +1803,7 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1462
1803
|
return result;
|
|
1463
1804
|
};
|
|
1464
1805
|
var parseBlock = parseBlock2;
|
|
1465
|
-
const raw =
|
|
1806
|
+
const raw = readFileSync6(tailwindSource.path, "utf-8");
|
|
1466
1807
|
const tokens = {};
|
|
1467
1808
|
const colorsKeyIdx = raw.indexOf("colors:");
|
|
1468
1809
|
if (colorsKeyIdx !== -1) {
|
|
@@ -1543,14 +1884,14 @@ function extractTailwindTokens(tokenSources) {
|
|
|
1543
1884
|
}
|
|
1544
1885
|
}
|
|
1545
1886
|
function scaffoldConfig(rootDir, config) {
|
|
1546
|
-
const path =
|
|
1887
|
+
const path = join4(rootDir, "reactscope.config.json");
|
|
1547
1888
|
writeFileSync3(path, `${JSON.stringify(config, null, 2)}
|
|
1548
1889
|
`);
|
|
1549
1890
|
return path;
|
|
1550
1891
|
}
|
|
1551
1892
|
function scaffoldTokenFile(rootDir, tokenFile, extractedTokens) {
|
|
1552
|
-
const path =
|
|
1553
|
-
if (!
|
|
1893
|
+
const path = join4(rootDir, tokenFile);
|
|
1894
|
+
if (!existsSync6(path)) {
|
|
1554
1895
|
const stub = {
|
|
1555
1896
|
$schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
|
|
1556
1897
|
version: "1.0.0",
|
|
@@ -1566,19 +1907,19 @@ function scaffoldTokenFile(rootDir, tokenFile, extractedTokens) {
|
|
|
1566
1907
|
return path;
|
|
1567
1908
|
}
|
|
1568
1909
|
function scaffoldOutputDir(rootDir, outputDir) {
|
|
1569
|
-
const dirPath =
|
|
1910
|
+
const dirPath = join4(rootDir, outputDir);
|
|
1570
1911
|
mkdirSync(dirPath, { recursive: true });
|
|
1571
|
-
const keepPath =
|
|
1572
|
-
if (!
|
|
1912
|
+
const keepPath = join4(dirPath, ".gitkeep");
|
|
1913
|
+
if (!existsSync6(keepPath)) {
|
|
1573
1914
|
writeFileSync3(keepPath, "");
|
|
1574
1915
|
}
|
|
1575
1916
|
return dirPath;
|
|
1576
1917
|
}
|
|
1577
1918
|
async function runInit(options) {
|
|
1578
1919
|
const rootDir = options.cwd ?? process.cwd();
|
|
1579
|
-
const configPath =
|
|
1920
|
+
const configPath = join4(rootDir, "reactscope.config.json");
|
|
1580
1921
|
const created = [];
|
|
1581
|
-
if (
|
|
1922
|
+
if (existsSync6(configPath) && !options.force) {
|
|
1582
1923
|
const msg = "reactscope.config.json already exists. Run with --force to overwrite.";
|
|
1583
1924
|
process.stderr.write(`\u26A0\uFE0F ${msg}
|
|
1584
1925
|
`);
|
|
@@ -1661,8 +2002,8 @@ async function runInit(options) {
|
|
|
1661
2002
|
};
|
|
1662
2003
|
const manifest = await generateManifest2(manifestConfig);
|
|
1663
2004
|
const manifestCount = Object.keys(manifest.components).length;
|
|
1664
|
-
const manifestOutPath =
|
|
1665
|
-
mkdirSync(
|
|
2005
|
+
const manifestOutPath = join4(rootDir, config.output.dir, "manifest.json");
|
|
2006
|
+
mkdirSync(join4(rootDir, config.output.dir), { recursive: true });
|
|
1666
2007
|
writeFileSync3(manifestOutPath, `${JSON.stringify(manifest, null, 2)}
|
|
1667
2008
|
`);
|
|
1668
2009
|
process.stdout.write(
|
|
@@ -1683,7 +2024,9 @@ async function runInit(options) {
|
|
|
1683
2024
|
};
|
|
1684
2025
|
}
|
|
1685
2026
|
function createInitCommand() {
|
|
1686
|
-
return new
|
|
2027
|
+
return new Command4("init").description(
|
|
2028
|
+
"Auto-detect your project layout and scaffold reactscope.config.json.\n\nWHAT IT DOES:\n - Detects component glob patterns from tsconfig / package.json / directory scan\n - Detects globalCSS files (Tailwind, PostCSS)\n - Writes reactscope.config.json with all detected values\n - Adds .reactscope/ to .gitignore\n - Creates .reactscope/ output directory\n\nCONFIG FIELDS GENERATED:\n components.include glob patterns for component discovery\n components.wrappers providers + globalCSS to inject on every render\n render.viewport default viewport (1280\xD7800)\n tokens.file path to reactscope.tokens.json\n output.dir .reactscope/ (all outputs go here)\n ci.complianceThreshold 0.90 (90% on-token required to pass CI)\n\nSafe to re-run \u2014 will not overwrite existing config unless --force.\n\nExamples:\n scope init\n scope init --yes # accept all detected defaults, no prompts\n scope init --force # overwrite existing config"
|
|
2029
|
+
).option("-y, --yes", "Accept all detected defaults without prompting", false).option("--force", "Overwrite existing reactscope.config.json if present", false).action(async (opts) => {
|
|
1687
2030
|
try {
|
|
1688
2031
|
const result = await runInit({ yes: opts.yes, force: opts.force });
|
|
1689
2032
|
if (!result.success && !result.skipped) {
|
|
@@ -1701,21 +2044,21 @@ function createInitCommand() {
|
|
|
1701
2044
|
import { resolve as resolve8 } from "path";
|
|
1702
2045
|
import { getBrowserEntryScript as getBrowserEntryScript5 } from "@agent-scope/playwright";
|
|
1703
2046
|
import { BrowserPool as BrowserPool2 } from "@agent-scope/render";
|
|
1704
|
-
import { Command as
|
|
2047
|
+
import { Command as Command7 } from "commander";
|
|
1705
2048
|
|
|
1706
2049
|
// src/manifest-commands.ts
|
|
1707
|
-
import { existsSync as
|
|
2050
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
|
|
1708
2051
|
import { resolve as resolve4 } from "path";
|
|
1709
2052
|
import { generateManifest as generateManifest3 } from "@agent-scope/manifest";
|
|
1710
|
-
import { Command as
|
|
2053
|
+
import { Command as Command5 } from "commander";
|
|
1711
2054
|
var MANIFEST_PATH = ".reactscope/manifest.json";
|
|
1712
2055
|
function loadManifest(manifestPath = MANIFEST_PATH) {
|
|
1713
2056
|
const absPath = resolve4(process.cwd(), manifestPath);
|
|
1714
|
-
if (!
|
|
2057
|
+
if (!existsSync7(absPath)) {
|
|
1715
2058
|
throw new Error(`Manifest not found at ${absPath}.
|
|
1716
2059
|
Run \`scope manifest generate\` first.`);
|
|
1717
2060
|
}
|
|
1718
|
-
const raw =
|
|
2061
|
+
const raw = readFileSync7(absPath, "utf-8");
|
|
1719
2062
|
return JSON.parse(raw);
|
|
1720
2063
|
}
|
|
1721
2064
|
function resolveFormat(formatFlag) {
|
|
@@ -1724,34 +2067,56 @@ function resolveFormat(formatFlag) {
|
|
|
1724
2067
|
return isTTY() ? "table" : "json";
|
|
1725
2068
|
}
|
|
1726
2069
|
function registerList(manifestCmd) {
|
|
1727
|
-
manifestCmd.command("list").description(
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
2070
|
+
manifestCmd.command("list").description(
|
|
2071
|
+
`List all components in the manifest as a table (TTY) or JSON (piped).
|
|
2072
|
+
|
|
2073
|
+
Examples:
|
|
2074
|
+
scope manifest list
|
|
2075
|
+
scope manifest list --format json | jq '.[].name'
|
|
2076
|
+
scope manifest list --filter "Button*"`
|
|
2077
|
+
).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--filter <glob>", "Filter components by name glob pattern").option("--collection <name>", "Filter to only components in the named collection").option("--internal", "Show only internal components").option("--no-internal", "Hide internal components from output").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
2078
|
+
(opts) => {
|
|
2079
|
+
try {
|
|
2080
|
+
const manifest = loadManifest(opts.manifest);
|
|
2081
|
+
const format = resolveFormat(opts.format);
|
|
2082
|
+
let entries = Object.entries(manifest.components);
|
|
2083
|
+
if (opts.filter !== void 0) {
|
|
2084
|
+
const filterPattern = opts.filter ?? "";
|
|
2085
|
+
entries = entries.filter(([name]) => matchGlob(filterPattern, name));
|
|
2086
|
+
}
|
|
2087
|
+
if (opts.collection !== void 0) {
|
|
2088
|
+
const col = opts.collection;
|
|
2089
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
2090
|
+
}
|
|
2091
|
+
if (opts.internal === true) {
|
|
2092
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
2093
|
+
} else if (opts.internal === false) {
|
|
2094
|
+
entries = entries.filter(([, d]) => !d.internal);
|
|
2095
|
+
}
|
|
2096
|
+
const rows = entries.map(([name, descriptor]) => ({
|
|
2097
|
+
name,
|
|
2098
|
+
file: descriptor.filePath,
|
|
2099
|
+
complexityClass: descriptor.complexityClass,
|
|
2100
|
+
hookCount: descriptor.detectedHooks.length,
|
|
2101
|
+
contextCount: descriptor.requiredContexts.length,
|
|
2102
|
+
collection: descriptor.collection,
|
|
2103
|
+
internal: descriptor.internal
|
|
2104
|
+
}));
|
|
2105
|
+
const output = format === "json" ? formatListJson(rows) : formatListTable(rows);
|
|
2106
|
+
process.stdout.write(`${output}
|
|
1745
2107
|
`);
|
|
1746
|
-
|
|
1747
|
-
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1748
2110
|
`);
|
|
1749
|
-
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
}
|
|
1750
2113
|
}
|
|
1751
|
-
|
|
2114
|
+
);
|
|
1752
2115
|
}
|
|
1753
2116
|
function registerGet(manifestCmd) {
|
|
1754
|
-
manifestCmd.command("get <name>").description(
|
|
2117
|
+
manifestCmd.command("get <name>").description(
|
|
2118
|
+
"Get full details of a single component: props, hooks, complexity class, file path.\n\nExamples:\n scope manifest get Button\n scope manifest get Button --format json\n scope manifest get Button --format json | jq '.complexity'"
|
|
2119
|
+
).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((name, opts) => {
|
|
1755
2120
|
try {
|
|
1756
2121
|
const manifest = loadManifest(opts.manifest);
|
|
1757
2122
|
const format = resolveFormat(opts.format);
|
|
@@ -1775,10 +2140,12 @@ Available: ${available}${hint}`
|
|
|
1775
2140
|
});
|
|
1776
2141
|
}
|
|
1777
2142
|
function registerQuery(manifestCmd) {
|
|
1778
|
-
manifestCmd.command("query").description(
|
|
2143
|
+
manifestCmd.command("query").description(
|
|
2144
|
+
'Filter components by structural attributes. All flags are AND-combined.\n\nCOMPLEXITY CLASSES:\n simple \u2014 pure/presentational, no side effects, Satori-renderable\n complex \u2014 uses context/hooks/effects, requires BrowserPool to render\n\nExamples:\n scope manifest query --complexity simple\n scope manifest query --has-fetch\n scope manifest query --hook useContext --side-effects\n scope manifest query --has-prop "variant:union" --format json\n scope manifest query --composed-by Layout'
|
|
2145
|
+
).option("--context <name>", "Find components consuming a context").option("--hook <name>", "Find components using a specific hook").option("--complexity <class>", "Filter by complexity class: simple or complex").option("--side-effects", "Find components with any side effects", false).option("--has-fetch", "Find components with fetch calls", false).option(
|
|
1779
2146
|
"--has-prop <spec>",
|
|
1780
2147
|
"Find components with a prop matching name or name:type (e.g. 'loading' or 'variant:union')"
|
|
1781
|
-
).option("--composed-by <name>", "Find components that compose the named component").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
2148
|
+
).option("--composed-by <name>", "Find components that compose the named component").option("--internal", "Find only internal components", false).option("--collection <name>", "Filter to only components in the named collection").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
1782
2149
|
(opts) => {
|
|
1783
2150
|
try {
|
|
1784
2151
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1791,9 +2158,11 @@ function registerQuery(manifestCmd) {
|
|
|
1791
2158
|
if (opts.hasFetch) queryParts.push("has-fetch");
|
|
1792
2159
|
if (opts.hasProp !== void 0) queryParts.push(`has-prop=${opts.hasProp}`);
|
|
1793
2160
|
if (opts.composedBy !== void 0) queryParts.push(`composed-by=${opts.composedBy}`);
|
|
2161
|
+
if (opts.internal) queryParts.push("internal");
|
|
2162
|
+
if (opts.collection !== void 0) queryParts.push(`collection=${opts.collection}`);
|
|
1794
2163
|
if (queryParts.length === 0) {
|
|
1795
2164
|
process.stderr.write(
|
|
1796
|
-
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop,
|
|
2165
|
+
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop, --composed-by, --internal, or --collection.\n"
|
|
1797
2166
|
);
|
|
1798
2167
|
process.exit(1);
|
|
1799
2168
|
}
|
|
@@ -1838,15 +2207,24 @@ function registerQuery(manifestCmd) {
|
|
|
1838
2207
|
const targetName = opts.composedBy;
|
|
1839
2208
|
entries = entries.filter(([, d]) => {
|
|
1840
2209
|
const composedBy = d.composedBy;
|
|
1841
|
-
return composedBy
|
|
2210
|
+
return composedBy?.includes(targetName);
|
|
1842
2211
|
});
|
|
1843
2212
|
}
|
|
2213
|
+
if (opts.internal) {
|
|
2214
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
2215
|
+
}
|
|
2216
|
+
if (opts.collection !== void 0) {
|
|
2217
|
+
const col = opts.collection;
|
|
2218
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
2219
|
+
}
|
|
1844
2220
|
const rows = entries.map(([name, d]) => ({
|
|
1845
2221
|
name,
|
|
1846
2222
|
file: d.filePath,
|
|
1847
2223
|
complexityClass: d.complexityClass,
|
|
1848
2224
|
hooks: d.detectedHooks.join(", ") || "\u2014",
|
|
1849
|
-
contexts: d.requiredContexts.join(", ") || "\u2014"
|
|
2225
|
+
contexts: d.requiredContexts.join(", ") || "\u2014",
|
|
2226
|
+
collection: d.collection,
|
|
2227
|
+
internal: d.internal
|
|
1850
2228
|
}));
|
|
1851
2229
|
const output = format === "json" ? formatQueryJson(rows) : formatQueryTable(rows, queryDesc);
|
|
1852
2230
|
process.stdout.write(`${output}
|
|
@@ -1859,27 +2237,70 @@ function registerQuery(manifestCmd) {
|
|
|
1859
2237
|
}
|
|
1860
2238
|
);
|
|
1861
2239
|
}
|
|
2240
|
+
function loadReactScopeConfig(rootDir) {
|
|
2241
|
+
const configPath = resolve4(rootDir, "reactscope.config.json");
|
|
2242
|
+
if (!existsSync7(configPath)) return null;
|
|
2243
|
+
try {
|
|
2244
|
+
const raw = readFileSync7(configPath, "utf-8");
|
|
2245
|
+
const cfg = JSON.parse(raw);
|
|
2246
|
+
const result = {};
|
|
2247
|
+
const components = cfg.components;
|
|
2248
|
+
if (components !== void 0 && typeof components === "object" && components !== null) {
|
|
2249
|
+
if (Array.isArray(components.include)) {
|
|
2250
|
+
result.include = components.include;
|
|
2251
|
+
}
|
|
2252
|
+
if (Array.isArray(components.exclude)) {
|
|
2253
|
+
result.exclude = components.exclude;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
if (Array.isArray(cfg.internalPatterns)) {
|
|
2257
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
2258
|
+
}
|
|
2259
|
+
if (Array.isArray(cfg.collections)) {
|
|
2260
|
+
result.collections = cfg.collections;
|
|
2261
|
+
}
|
|
2262
|
+
const icons = cfg.icons;
|
|
2263
|
+
if (icons !== void 0 && typeof icons === "object" && icons !== null) {
|
|
2264
|
+
if (Array.isArray(icons.patterns)) {
|
|
2265
|
+
result.iconPatterns = icons.patterns;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return result;
|
|
2269
|
+
} catch {
|
|
2270
|
+
return null;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
1862
2273
|
function registerGenerate(manifestCmd) {
|
|
1863
2274
|
manifestCmd.command("generate").description(
|
|
1864
|
-
|
|
2275
|
+
'Scan source files and generate .reactscope/manifest.json.\n\nUses Babel static analysis \u2014 no runtime or bundler required.\nRe-run whenever components are added, removed, or significantly changed.\n\nReads reactscope.config.json for:\n components.include glob patterns for source files\n components.exclude glob patterns to skip\n internalPatterns globs to flag components as internal\n collections named groups of components\n\nCLI flags (--include, --exclude) override config-file values.\n\nWHAT IT CAPTURES per component:\n - File path and export name\n - All props with types and default values\n - Hook usage (useState, useEffect, useContext, custom hooks)\n - Side effects (fetch, timers, subscriptions)\n - Complexity class: simple | complex\n - Context dependencies and composed child components\n\nExamples:\n scope manifest generate\n scope manifest generate --root ./packages/ui\n scope manifest generate --include "src/components/**/*.tsx" --exclude "**/*.stories.tsx"\n scope manifest generate --output ./custom-manifest.json'
|
|
1865
2276
|
).option("--root <path>", "Project root directory (default: cwd)").option("--output <path>", "Output path for manifest.json", MANIFEST_PATH).option("--include <globs>", "Comma-separated glob patterns to include").option("--exclude <globs>", "Comma-separated glob patterns to exclude").action(async (opts) => {
|
|
1866
2277
|
try {
|
|
1867
2278
|
const rootDir = resolve4(process.cwd(), opts.root ?? ".");
|
|
1868
2279
|
const outputPath = resolve4(process.cwd(), opts.output);
|
|
1869
|
-
const
|
|
1870
|
-
const
|
|
2280
|
+
const configValues = loadReactScopeConfig(rootDir);
|
|
2281
|
+
const include = opts.include?.split(",").map((s) => s.trim()) ?? configValues?.include;
|
|
2282
|
+
const exclude = opts.exclude?.split(",").map((s) => s.trim()) ?? configValues?.exclude;
|
|
1871
2283
|
process.stderr.write(`Scanning ${rootDir} for React components...
|
|
1872
2284
|
`);
|
|
1873
2285
|
const manifest = await generateManifest3({
|
|
1874
2286
|
rootDir,
|
|
1875
2287
|
...include !== void 0 && { include },
|
|
1876
|
-
...exclude !== void 0 && { exclude }
|
|
2288
|
+
...exclude !== void 0 && { exclude },
|
|
2289
|
+
...configValues?.internalPatterns !== void 0 && {
|
|
2290
|
+
internalPatterns: configValues.internalPatterns
|
|
2291
|
+
},
|
|
2292
|
+
...configValues?.collections !== void 0 && {
|
|
2293
|
+
collections: configValues.collections
|
|
2294
|
+
},
|
|
2295
|
+
...configValues?.iconPatterns !== void 0 && {
|
|
2296
|
+
iconPatterns: configValues.iconPatterns
|
|
2297
|
+
}
|
|
1877
2298
|
});
|
|
1878
2299
|
const componentCount = Object.keys(manifest.components).length;
|
|
1879
2300
|
process.stderr.write(`Found ${componentCount} components.
|
|
1880
2301
|
`);
|
|
1881
2302
|
const outputDir = outputPath.replace(/\/[^/]+$/, "");
|
|
1882
|
-
if (!
|
|
2303
|
+
if (!existsSync7(outputDir)) {
|
|
1883
2304
|
mkdirSync2(outputDir, { recursive: true });
|
|
1884
2305
|
}
|
|
1885
2306
|
writeFileSync4(outputPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
@@ -1895,8 +2316,8 @@ function registerGenerate(manifestCmd) {
|
|
|
1895
2316
|
});
|
|
1896
2317
|
}
|
|
1897
2318
|
function createManifestCommand() {
|
|
1898
|
-
const manifestCmd = new
|
|
1899
|
-
"Query and explore the component manifest"
|
|
2319
|
+
const manifestCmd = new Command5("manifest").description(
|
|
2320
|
+
"Query and explore the component manifest (.reactscope/manifest.json).\n\nThe manifest is the source-of-truth registry of every React component\nin your codebase \u2014 generated by static analysis (no runtime needed).\n\nRun `scope manifest generate` first, then use list/get/query to explore.\n\nExamples:\n scope manifest generate\n scope manifest list\n scope manifest get Button\n scope manifest query --complexity complex --has-fetch"
|
|
1900
2321
|
);
|
|
1901
2322
|
registerList(manifestCmd);
|
|
1902
2323
|
registerGet(manifestCmd);
|
|
@@ -2218,7 +2639,19 @@ async function runHooksProfiling(componentName, filePath, props) {
|
|
|
2218
2639
|
}
|
|
2219
2640
|
function createInstrumentHooksCommand() {
|
|
2220
2641
|
const cmd = new Cmd("hooks").description(
|
|
2221
|
-
|
|
2642
|
+
`Profile per-hook-instance data for a component.
|
|
2643
|
+
|
|
2644
|
+
METRICS CAPTURED per hook instance:
|
|
2645
|
+
useState update count, current value
|
|
2646
|
+
useCallback cache hit rate (stable reference %)
|
|
2647
|
+
useMemo cache hit rate (recompute %)
|
|
2648
|
+
useEffect execution count
|
|
2649
|
+
useRef current value snapshot
|
|
2650
|
+
|
|
2651
|
+
Examples:
|
|
2652
|
+
scope instrument hooks SearchInput
|
|
2653
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}' --json
|
|
2654
|
+
scope instrument hooks Dropdown --json | jq '.hooks[] | select(.type == "useMemo")' `
|
|
2222
2655
|
).argument("<component>", "Component name (must exist in the manifest)").option("--props <json>", "Inline props JSON passed to the component", "{}").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH2).option("--format <fmt>", "Output format: json|text (default: auto)", "json").option("--show-flags", "Show heuristic flags only (useful for CI checks)", false).action(
|
|
2223
2656
|
async (componentName, opts) => {
|
|
2224
2657
|
try {
|
|
@@ -2256,7 +2689,7 @@ Available: ${available}`
|
|
|
2256
2689
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2257
2690
|
`);
|
|
2258
2691
|
} catch (err) {
|
|
2259
|
-
process.stderr.write(
|
|
2692
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2260
2693
|
`);
|
|
2261
2694
|
process.exit(1);
|
|
2262
2695
|
}
|
|
@@ -2365,13 +2798,11 @@ function buildProfilingCollectScript() {
|
|
|
2365
2798
|
// mount commit), it *may* have been wasted if it didn't actually need to re-render.
|
|
2366
2799
|
// For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
|
|
2367
2800
|
// This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
|
|
2368
|
-
var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
|
|
2369
|
-
|
|
2370
2801
|
return {
|
|
2371
2802
|
commitCount: totalCommits,
|
|
2372
2803
|
uniqueComponents: uniqueNames.length,
|
|
2373
2804
|
componentNames: uniqueNames,
|
|
2374
|
-
wastedRenders:
|
|
2805
|
+
wastedRenders: null,
|
|
2375
2806
|
layoutTime: window.__scopeLayoutTime || 0,
|
|
2376
2807
|
paintTime: window.__scopePaintTime || 0,
|
|
2377
2808
|
layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
|
|
@@ -2419,7 +2850,7 @@ async function replayInteraction(page, steps) {
|
|
|
2419
2850
|
}
|
|
2420
2851
|
function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
|
|
2421
2852
|
const flags = /* @__PURE__ */ new Set();
|
|
2422
|
-
if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2853
|
+
if (wastedRenders !== null && wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2423
2854
|
flags.add("WASTED_RENDER");
|
|
2424
2855
|
}
|
|
2425
2856
|
if (totalRenders > 10) {
|
|
@@ -2490,13 +2921,18 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2490
2921
|
};
|
|
2491
2922
|
const totalRenders = profileData.commitCount ?? 0;
|
|
2492
2923
|
const uniqueComponents = profileData.uniqueComponents ?? 0;
|
|
2493
|
-
const wastedRenders = profileData.wastedRenders ??
|
|
2924
|
+
const wastedRenders = profileData.wastedRenders ?? null;
|
|
2494
2925
|
const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
|
|
2495
2926
|
return {
|
|
2496
2927
|
component: componentName,
|
|
2497
2928
|
totalRenders,
|
|
2498
2929
|
uniqueComponents,
|
|
2499
2930
|
wastedRenders,
|
|
2931
|
+
wastedRendersHeuristic: {
|
|
2932
|
+
measured: false,
|
|
2933
|
+
value: null,
|
|
2934
|
+
note: "profile.wastedRenders is retained for compatibility but set to null because Scope does not directly measure wasted renders yet."
|
|
2935
|
+
},
|
|
2500
2936
|
timing,
|
|
2501
2937
|
layoutShifts,
|
|
2502
2938
|
flags,
|
|
@@ -2508,7 +2944,19 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2508
2944
|
}
|
|
2509
2945
|
function createInstrumentProfileCommand() {
|
|
2510
2946
|
const cmd = new Cmd2("profile").description(
|
|
2511
|
-
|
|
2947
|
+
`Capture a full performance profile for an interaction sequence.
|
|
2948
|
+
|
|
2949
|
+
PROFILE INCLUDES:
|
|
2950
|
+
renders total re-renders triggered by the interaction
|
|
2951
|
+
timing interaction start \u2192 paint time (ms)
|
|
2952
|
+
layoutShifts cumulative layout shift (CLS) score
|
|
2953
|
+
scriptTime JS execution time (ms)
|
|
2954
|
+
longTasks count of tasks >50ms
|
|
2955
|
+
|
|
2956
|
+
Examples:
|
|
2957
|
+
scope instrument profile Button --interaction '[{"action":"click","target":"button"}]'
|
|
2958
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]' --json
|
|
2959
|
+
scope instrument profile Form --json | jq '.summary.renderCount'`
|
|
2512
2960
|
).argument("<component>", "Component name (must exist in the manifest)").option(
|
|
2513
2961
|
"--interaction <json>",
|
|
2514
2962
|
`Interaction steps JSON, e.g. '[{"action":"click","target":"button.primary"}]'`,
|
|
@@ -2559,7 +3007,7 @@ Available: ${available}`
|
|
|
2559
3007
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2560
3008
|
`);
|
|
2561
3009
|
} catch (err) {
|
|
2562
|
-
process.stderr.write(
|
|
3010
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2563
3011
|
`);
|
|
2564
3012
|
process.exit(1);
|
|
2565
3013
|
}
|
|
@@ -2571,7 +3019,7 @@ Available: ${available}`
|
|
|
2571
3019
|
// src/instrument/tree.ts
|
|
2572
3020
|
import { resolve as resolve7 } from "path";
|
|
2573
3021
|
import { getBrowserEntryScript as getBrowserEntryScript4 } from "@agent-scope/playwright";
|
|
2574
|
-
import { Command as
|
|
3022
|
+
import { Command as Command6 } from "commander";
|
|
2575
3023
|
import { chromium as chromium4 } from "playwright";
|
|
2576
3024
|
var MANIFEST_PATH4 = ".reactscope/manifest.json";
|
|
2577
3025
|
var DEFAULT_VIEWPORT_WIDTH = 375;
|
|
@@ -2852,7 +3300,21 @@ async function runInstrumentTree(options) {
|
|
|
2852
3300
|
}
|
|
2853
3301
|
}
|
|
2854
3302
|
function createInstrumentTreeCommand() {
|
|
2855
|
-
return new
|
|
3303
|
+
return new Command6("tree").description(
|
|
3304
|
+
`Render a component and output the full instrumentation tree:
|
|
3305
|
+
DOM structure, computed styles per node, a11y roles, and React fibers.
|
|
3306
|
+
|
|
3307
|
+
OUTPUT STRUCTURE per node:
|
|
3308
|
+
tag / id / className DOM identity
|
|
3309
|
+
computedStyles resolved CSS properties
|
|
3310
|
+
a11y role, name, focusable
|
|
3311
|
+
children nested child nodes
|
|
3312
|
+
|
|
3313
|
+
Examples:
|
|
3314
|
+
scope instrument tree Card
|
|
3315
|
+
scope instrument tree Button --props '{"variant":"primary"}' --json
|
|
3316
|
+
scope instrument tree Input --json | jq '.tree.computedStyles'`
|
|
3317
|
+
).argument("<component>", "Component name to instrument (must exist in the manifest)").option("--sort-by <field>", "Sort nodes by field: renderCount | depth").option("--limit <n>", "Limit output to the first N nodes (depth-first)").option("--uses-context <name>", "Filter to components that use a specific context").option("--provider-depth", "Annotate each node with its context-provider nesting depth", false).option(
|
|
2856
3318
|
"--wasted-renders",
|
|
2857
3319
|
"Filter to components with wasted renders (no prop/state/context changes, not memoized)",
|
|
2858
3320
|
false
|
|
@@ -2898,7 +3360,7 @@ Available: ${available}`
|
|
|
2898
3360
|
`);
|
|
2899
3361
|
}
|
|
2900
3362
|
} catch (err) {
|
|
2901
|
-
process.stderr.write(
|
|
3363
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2902
3364
|
`);
|
|
2903
3365
|
process.exit(1);
|
|
2904
3366
|
}
|
|
@@ -3248,7 +3710,8 @@ Available: ${available}`
|
|
|
3248
3710
|
}
|
|
3249
3711
|
const rootDir = process.cwd();
|
|
3250
3712
|
const filePath = resolve8(rootDir, descriptor.filePath);
|
|
3251
|
-
const preScript = getBrowserEntryScript5()
|
|
3713
|
+
const preScript = `${getBrowserEntryScript5()}
|
|
3714
|
+
${buildInstrumentationScript()}`;
|
|
3252
3715
|
const htmlHarness = await buildComponentHarness(
|
|
3253
3716
|
filePath,
|
|
3254
3717
|
options.componentName,
|
|
@@ -3337,7 +3800,24 @@ function formatRendersTable(result) {
|
|
|
3337
3800
|
return lines.join("\n");
|
|
3338
3801
|
}
|
|
3339
3802
|
function createInstrumentRendersCommand() {
|
|
3340
|
-
return new
|
|
3803
|
+
return new Command7("renders").description(
|
|
3804
|
+
`Trace every re-render triggered by an interaction and identify root causes.
|
|
3805
|
+
|
|
3806
|
+
OUTPUT INCLUDES per render event:
|
|
3807
|
+
component which component re-rendered
|
|
3808
|
+
trigger why it re-rendered: state_change | props_change | context_change |
|
|
3809
|
+
parent_rerender | force_update | hook_dependency
|
|
3810
|
+
wasted true if re-rendered with no changed inputs and not memoized
|
|
3811
|
+
chain full causality chain from root cause to this render
|
|
3812
|
+
|
|
3813
|
+
WASTED RENDERS: propsChanged=false AND stateChanged=false AND contextChanged=false
|
|
3814
|
+
AND memoized=false \u2014 these are optimisation opportunities.
|
|
3815
|
+
|
|
3816
|
+
Examples:
|
|
3817
|
+
scope instrument renders SearchPage --interaction '[{"action":"type","target":"input","text":"hello"}]'
|
|
3818
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]' --json
|
|
3819
|
+
scope instrument renders Form --json | jq '.events[] | select(.wasted == true)'`
|
|
3820
|
+
).argument("<component>", "Component name to instrument (must be in manifest)").option(
|
|
3341
3821
|
"--interaction <json>",
|
|
3342
3822
|
`Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
|
|
3343
3823
|
"[]"
|
|
@@ -3374,7 +3854,7 @@ function createInstrumentRendersCommand() {
|
|
|
3374
3854
|
}
|
|
3375
3855
|
} catch (err) {
|
|
3376
3856
|
await shutdownPool2();
|
|
3377
|
-
process.stderr.write(
|
|
3857
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3378
3858
|
`);
|
|
3379
3859
|
process.exit(1);
|
|
3380
3860
|
}
|
|
@@ -3382,8 +3862,29 @@ function createInstrumentRendersCommand() {
|
|
|
3382
3862
|
);
|
|
3383
3863
|
}
|
|
3384
3864
|
function createInstrumentCommand() {
|
|
3385
|
-
const instrumentCmd = new
|
|
3386
|
-
|
|
3865
|
+
const instrumentCmd = new Command7("instrument").description(
|
|
3866
|
+
`Runtime instrumentation for React component behaviour analysis.
|
|
3867
|
+
|
|
3868
|
+
All instrument commands:
|
|
3869
|
+
1. Build an esbuild harness for the component
|
|
3870
|
+
2. Load it in a Playwright browser
|
|
3871
|
+
3. Inject instrumentation hooks into React DevTools fiber
|
|
3872
|
+
4. Execute interactions and collect events
|
|
3873
|
+
|
|
3874
|
+
PREREQUISITES:
|
|
3875
|
+
scope manifest generate (component must be in manifest)
|
|
3876
|
+
reactscope.config.json (for wrappers/globalCSS)
|
|
3877
|
+
|
|
3878
|
+
INTERACTION FORMAT:
|
|
3879
|
+
JSON array of step objects: [{action, target, text?}]
|
|
3880
|
+
Actions: click | type | focus | blur | hover | key
|
|
3881
|
+
Target: CSS selector for the element to interact with
|
|
3882
|
+
|
|
3883
|
+
Examples:
|
|
3884
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]'
|
|
3885
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}'
|
|
3886
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]'
|
|
3887
|
+
scope instrument tree Card`
|
|
3387
3888
|
);
|
|
3388
3889
|
instrumentCmd.addCommand(createInstrumentRendersCommand());
|
|
3389
3890
|
instrumentCmd.addCommand(createInstrumentHooksCommand());
|
|
@@ -3393,8 +3894,8 @@ function createInstrumentCommand() {
|
|
|
3393
3894
|
}
|
|
3394
3895
|
|
|
3395
3896
|
// src/render-commands.ts
|
|
3396
|
-
import { existsSync as
|
|
3397
|
-
import { resolve as
|
|
3897
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
3898
|
+
import { resolve as resolve11 } from "path";
|
|
3398
3899
|
import {
|
|
3399
3900
|
ALL_CONTEXT_IDS,
|
|
3400
3901
|
ALL_STRESS_IDS,
|
|
@@ -3405,29 +3906,81 @@ import {
|
|
|
3405
3906
|
safeRender as safeRender2,
|
|
3406
3907
|
stressAxis
|
|
3407
3908
|
} from "@agent-scope/render";
|
|
3408
|
-
import { Command as
|
|
3909
|
+
import { Command as Command8 } from "commander";
|
|
3910
|
+
|
|
3911
|
+
// src/run-summary.ts
|
|
3912
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
3913
|
+
import { dirname as dirname3, resolve as resolve9 } from "path";
|
|
3914
|
+
var RUN_SUMMARY_PATH = ".reactscope/run-summary.json";
|
|
3915
|
+
function buildNextActions(summary) {
|
|
3916
|
+
const actions = /* @__PURE__ */ new Set();
|
|
3917
|
+
for (const failure of summary.failures) {
|
|
3918
|
+
if (failure.stage === "render" || failure.stage === "matrix") {
|
|
3919
|
+
actions.add(
|
|
3920
|
+
`Inspect ${failure.outputPath ?? ".reactscope/renders"} and add/fix ${failure.component}.scope.tsx scenarios or wrappers.`
|
|
3921
|
+
);
|
|
3922
|
+
} else if (failure.stage === "playground") {
|
|
3923
|
+
actions.add(
|
|
3924
|
+
`Open the generated component page and inspect the playground bundling error for ${failure.component}.`
|
|
3925
|
+
);
|
|
3926
|
+
} else if (failure.stage === "compliance") {
|
|
3927
|
+
actions.add(
|
|
3928
|
+
"Run `scope render all` first, then inspect .reactscope/compliance-styles.json and reactscope.tokens.json."
|
|
3929
|
+
);
|
|
3930
|
+
} else if (failure.stage === "site") {
|
|
3931
|
+
actions.add(
|
|
3932
|
+
"Inspect .reactscope/site output and rerun `scope site build` after fixing render/playground failures."
|
|
3933
|
+
);
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
if (summary.compliance && summary.compliance.auditedProperties === 0) {
|
|
3937
|
+
actions.add(
|
|
3938
|
+
"No CSS properties were audited. Verify renders produced computed styles and your token file contains matching token categories."
|
|
3939
|
+
);
|
|
3940
|
+
} else if (summary.compliance && summary.compliance.threshold !== void 0 && summary.compliance.score < summary.compliance.threshold) {
|
|
3941
|
+
actions.add(
|
|
3942
|
+
"Inspect .reactscope/compliance-report.json for off-system values and update tokens or component styles."
|
|
3943
|
+
);
|
|
3944
|
+
}
|
|
3945
|
+
if (actions.size === 0) {
|
|
3946
|
+
actions.add("No follow-up needed. Outputs are ready for inspection.");
|
|
3947
|
+
}
|
|
3948
|
+
return [...actions];
|
|
3949
|
+
}
|
|
3950
|
+
function writeRunSummary(summary, summaryPath = RUN_SUMMARY_PATH) {
|
|
3951
|
+
const outputPath = resolve9(process.cwd(), summaryPath);
|
|
3952
|
+
mkdirSync3(dirname3(outputPath), { recursive: true });
|
|
3953
|
+
const payload = {
|
|
3954
|
+
...summary,
|
|
3955
|
+
generatedAt: summary.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3956
|
+
nextActions: summary.nextActions ?? buildNextActions(summary)
|
|
3957
|
+
};
|
|
3958
|
+
writeFileSync5(outputPath, `${JSON.stringify(payload, null, 2)}
|
|
3959
|
+
`, "utf-8");
|
|
3960
|
+
return outputPath;
|
|
3961
|
+
}
|
|
3409
3962
|
|
|
3410
3963
|
// src/scope-file.ts
|
|
3411
|
-
import { existsSync as
|
|
3964
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync4, rmSync } from "fs";
|
|
3412
3965
|
import { createRequire as createRequire2 } from "module";
|
|
3413
3966
|
import { tmpdir } from "os";
|
|
3414
|
-
import { dirname as
|
|
3967
|
+
import { dirname as dirname4, join as join5, resolve as resolve10 } from "path";
|
|
3415
3968
|
import * as esbuild2 from "esbuild";
|
|
3416
3969
|
var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
|
|
3417
3970
|
function findScopeFile(componentFilePath) {
|
|
3418
|
-
const dir =
|
|
3971
|
+
const dir = dirname4(componentFilePath);
|
|
3419
3972
|
const stem = componentFilePath.replace(/\.(tsx?|jsx?)$/, "");
|
|
3420
3973
|
const baseName = stem.slice(dir.length + 1);
|
|
3421
3974
|
for (const ext of SCOPE_EXTENSIONS) {
|
|
3422
|
-
const candidate =
|
|
3423
|
-
if (
|
|
3975
|
+
const candidate = join5(dir, `${baseName}${ext}`);
|
|
3976
|
+
if (existsSync8(candidate)) return candidate;
|
|
3424
3977
|
}
|
|
3425
3978
|
return null;
|
|
3426
3979
|
}
|
|
3427
3980
|
async function loadScopeFile(scopeFilePath) {
|
|
3428
|
-
const tmpDir =
|
|
3429
|
-
|
|
3430
|
-
const outFile =
|
|
3981
|
+
const tmpDir = join5(tmpdir(), `scope-file-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
3982
|
+
mkdirSync4(tmpDir, { recursive: true });
|
|
3983
|
+
const outFile = join5(tmpDir, "scope-file.cjs");
|
|
3431
3984
|
try {
|
|
3432
3985
|
const result = await esbuild2.build({
|
|
3433
3986
|
entryPoints: [scopeFilePath],
|
|
@@ -3452,7 +4005,7 @@ async function loadScopeFile(scopeFilePath) {
|
|
|
3452
4005
|
${msg}`);
|
|
3453
4006
|
}
|
|
3454
4007
|
const req = createRequire2(import.meta.url);
|
|
3455
|
-
delete req.cache[
|
|
4008
|
+
delete req.cache[resolve10(outFile)];
|
|
3456
4009
|
const mod = req(outFile);
|
|
3457
4010
|
const scenarios = extractScenarios(mod, scopeFilePath);
|
|
3458
4011
|
const hasWrapper = typeof mod.wrapper === "function" || typeof mod.default?.wrapper === "function";
|
|
@@ -3502,7 +4055,7 @@ window.__SCOPE_WRAPPER__ = wrapper;
|
|
|
3502
4055
|
const result = await esbuild2.build({
|
|
3503
4056
|
stdin: {
|
|
3504
4057
|
contents: wrapperEntry,
|
|
3505
|
-
resolveDir:
|
|
4058
|
+
resolveDir: dirname4(scopeFilePath),
|
|
3506
4059
|
loader: "tsx",
|
|
3507
4060
|
sourcefile: "__scope_wrapper_entry__.tsx"
|
|
3508
4061
|
},
|
|
@@ -3530,16 +4083,73 @@ ${msg}`);
|
|
|
3530
4083
|
|
|
3531
4084
|
// src/render-commands.ts
|
|
3532
4085
|
function loadGlobalCssFilesFromConfig(cwd) {
|
|
3533
|
-
const configPath =
|
|
3534
|
-
if (!
|
|
4086
|
+
const configPath = resolve11(cwd, "reactscope.config.json");
|
|
4087
|
+
if (!existsSync9(configPath)) return [];
|
|
3535
4088
|
try {
|
|
3536
|
-
const raw =
|
|
4089
|
+
const raw = readFileSync9(configPath, "utf-8");
|
|
3537
4090
|
const cfg = JSON.parse(raw);
|
|
3538
4091
|
return cfg.components?.wrappers?.globalCSS ?? [];
|
|
3539
4092
|
} catch {
|
|
3540
4093
|
return [];
|
|
3541
4094
|
}
|
|
3542
4095
|
}
|
|
4096
|
+
var TAILWIND_CONFIG_FILES2 = [
|
|
4097
|
+
"tailwind.config.js",
|
|
4098
|
+
"tailwind.config.cjs",
|
|
4099
|
+
"tailwind.config.mjs",
|
|
4100
|
+
"tailwind.config.ts",
|
|
4101
|
+
"postcss.config.js",
|
|
4102
|
+
"postcss.config.cjs",
|
|
4103
|
+
"postcss.config.mjs",
|
|
4104
|
+
"postcss.config.ts"
|
|
4105
|
+
];
|
|
4106
|
+
function shouldWarnForMissingGlobalCss(cwd) {
|
|
4107
|
+
if (TAILWIND_CONFIG_FILES2.some((file) => existsSync9(resolve11(cwd, file)))) {
|
|
4108
|
+
return true;
|
|
4109
|
+
}
|
|
4110
|
+
const packageJsonPath = resolve11(cwd, "package.json");
|
|
4111
|
+
if (!existsSync9(packageJsonPath)) return false;
|
|
4112
|
+
try {
|
|
4113
|
+
const pkg = JSON.parse(readFileSync9(packageJsonPath, "utf-8"));
|
|
4114
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
4115
|
+
(deps) => deps && Object.keys(deps).some(
|
|
4116
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
4117
|
+
)
|
|
4118
|
+
);
|
|
4119
|
+
} catch {
|
|
4120
|
+
return false;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
function loadIconPatternsFromConfig(cwd) {
|
|
4124
|
+
const configPath = resolve11(cwd, "reactscope.config.json");
|
|
4125
|
+
if (!existsSync9(configPath)) return [];
|
|
4126
|
+
try {
|
|
4127
|
+
const raw = readFileSync9(configPath, "utf-8");
|
|
4128
|
+
const cfg = JSON.parse(raw);
|
|
4129
|
+
return cfg.icons?.patterns ?? [];
|
|
4130
|
+
} catch {
|
|
4131
|
+
return [];
|
|
4132
|
+
}
|
|
4133
|
+
}
|
|
4134
|
+
function matchGlob2(pattern, value) {
|
|
4135
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
4136
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/§GLOBSTAR§/g, ".*");
|
|
4137
|
+
return new RegExp(`^${regexStr}$`, "i").test(value);
|
|
4138
|
+
}
|
|
4139
|
+
function isIconComponent(filePath, displayName, patterns) {
|
|
4140
|
+
return patterns.length > 0 && patterns.some((p) => matchGlob2(p, filePath) || matchGlob2(p, displayName));
|
|
4141
|
+
}
|
|
4142
|
+
function formatAggregateRenderFailureJson(componentName, failures, scenarioCount, runSummaryPath) {
|
|
4143
|
+
return {
|
|
4144
|
+
command: `scope render component ${componentName}`,
|
|
4145
|
+
status: "failed",
|
|
4146
|
+
component: componentName,
|
|
4147
|
+
scenarioCount,
|
|
4148
|
+
failureCount: failures.length,
|
|
4149
|
+
failures,
|
|
4150
|
+
runSummaryPath
|
|
4151
|
+
};
|
|
4152
|
+
}
|
|
3543
4153
|
var MANIFEST_PATH6 = ".reactscope/manifest.json";
|
|
3544
4154
|
var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
|
|
3545
4155
|
var _pool3 = null;
|
|
@@ -3560,7 +4170,7 @@ async function shutdownPool3() {
|
|
|
3560
4170
|
_pool3 = null;
|
|
3561
4171
|
}
|
|
3562
4172
|
}
|
|
3563
|
-
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript) {
|
|
4173
|
+
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript, iconMode = false) {
|
|
3564
4174
|
const satori = new SatoriRenderer({
|
|
3565
4175
|
defaultViewport: { width: viewportWidth, height: viewportHeight }
|
|
3566
4176
|
});
|
|
@@ -3570,13 +4180,15 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3570
4180
|
const startMs = performance.now();
|
|
3571
4181
|
const pool = await getPool3(viewportWidth, viewportHeight);
|
|
3572
4182
|
const projectCss = await loadGlobalCss(globalCssFiles, projectCwd);
|
|
4183
|
+
const PAD = 8;
|
|
3573
4184
|
const htmlHarness = await buildComponentHarness(
|
|
3574
4185
|
filePath,
|
|
3575
4186
|
componentName,
|
|
3576
4187
|
props,
|
|
3577
4188
|
viewportWidth,
|
|
3578
4189
|
projectCss ?? void 0,
|
|
3579
|
-
wrapperScript
|
|
4190
|
+
wrapperScript,
|
|
4191
|
+
PAD
|
|
3580
4192
|
);
|
|
3581
4193
|
const slot = await pool.acquire();
|
|
3582
4194
|
const { page } = slot;
|
|
@@ -3617,17 +4229,28 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3617
4229
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
3618
4230
|
);
|
|
3619
4231
|
}
|
|
3620
|
-
const PAD = 8;
|
|
3621
4232
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
3622
4233
|
const clipY = Math.max(0, boundingBox.y - PAD);
|
|
3623
4234
|
const rawW = boundingBox.width + PAD * 2;
|
|
3624
4235
|
const rawH = boundingBox.height + PAD * 2;
|
|
3625
4236
|
const safeW = Math.min(rawW, viewportWidth - clipX);
|
|
3626
4237
|
const safeH = Math.min(rawH, viewportHeight - clipY);
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
4238
|
+
let svgContent;
|
|
4239
|
+
let screenshot;
|
|
4240
|
+
if (iconMode) {
|
|
4241
|
+
svgContent = await page.evaluate((sel) => {
|
|
4242
|
+
const root = document.querySelector(sel);
|
|
4243
|
+
const el = root?.firstElementChild;
|
|
4244
|
+
if (!el) return void 0;
|
|
4245
|
+
return el.outerHTML;
|
|
4246
|
+
}, "[data-reactscope-root]") ?? void 0;
|
|
4247
|
+
screenshot = Buffer.alloc(0);
|
|
4248
|
+
} else {
|
|
4249
|
+
screenshot = await page.screenshot({
|
|
4250
|
+
clip: { x: clipX, y: clipY, width: safeW, height: safeH },
|
|
4251
|
+
type: "png"
|
|
4252
|
+
});
|
|
4253
|
+
}
|
|
3631
4254
|
const STYLE_PROPS = [
|
|
3632
4255
|
"display",
|
|
3633
4256
|
"width",
|
|
@@ -3750,7 +4373,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3750
4373
|
name: a11yInfo.name,
|
|
3751
4374
|
violations: imgViolations
|
|
3752
4375
|
};
|
|
3753
|
-
|
|
4376
|
+
const renderResult = {
|
|
3754
4377
|
screenshot,
|
|
3755
4378
|
width: Math.round(safeW),
|
|
3756
4379
|
height: Math.round(safeH),
|
|
@@ -3759,6 +4382,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3759
4382
|
dom,
|
|
3760
4383
|
accessibility
|
|
3761
4384
|
};
|
|
4385
|
+
if (iconMode && svgContent) {
|
|
4386
|
+
renderResult.svgContent = svgContent;
|
|
4387
|
+
}
|
|
4388
|
+
return renderResult;
|
|
3762
4389
|
} finally {
|
|
3763
4390
|
pool.release(slot);
|
|
3764
4391
|
}
|
|
@@ -3795,7 +4422,27 @@ Available: ${available}`
|
|
|
3795
4422
|
return { __default__: {} };
|
|
3796
4423
|
}
|
|
3797
4424
|
function registerRenderSingle(renderCmd) {
|
|
3798
|
-
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
4425
|
+
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
4426
|
+
`Render one component to a PNG screenshot or JSON data object.
|
|
4427
|
+
|
|
4428
|
+
PROP SOURCES (in priority order):
|
|
4429
|
+
--scenario <name> named scenario from <ComponentName>.scope file
|
|
4430
|
+
--props <json> inline props JSON string
|
|
4431
|
+
(no flag) component rendered with all-default props
|
|
4432
|
+
|
|
4433
|
+
FORMAT DETECTION:
|
|
4434
|
+
--format png always write PNG
|
|
4435
|
+
--format json always write JSON render data
|
|
4436
|
+
auto (default) PNG when -o has .png extension or stdout is file;
|
|
4437
|
+
JSON when stdout is a pipe
|
|
4438
|
+
|
|
4439
|
+
Examples:
|
|
4440
|
+
scope render component Button
|
|
4441
|
+
scope render component Button --props '{"variant":"primary","size":"lg"}'
|
|
4442
|
+
scope render component Button --scenario hover-state -o button-hover.png
|
|
4443
|
+
scope render component Card --viewport 375x812 --theme dark
|
|
4444
|
+
scope render component Badge --format json | jq '.a11y'`
|
|
4445
|
+
).option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--scenario <name>", "Run a named scenario from the component's .scope file").option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
|
|
3799
4446
|
async (componentName, opts) => {
|
|
3800
4447
|
try {
|
|
3801
4448
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -3833,12 +4480,12 @@ Available: ${available}`
|
|
|
3833
4480
|
}
|
|
3834
4481
|
const { width, height } = parseViewport(opts.viewport);
|
|
3835
4482
|
const rootDir = process.cwd();
|
|
3836
|
-
const filePath =
|
|
4483
|
+
const filePath = resolve11(rootDir, descriptor.filePath);
|
|
3837
4484
|
const scopeData = await loadScopeFileForComponent(filePath);
|
|
3838
4485
|
const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
3839
4486
|
const scenarios = buildScenarioMap(opts, scopeData);
|
|
3840
4487
|
const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
3841
|
-
if (globalCssFiles.length === 0) {
|
|
4488
|
+
if (globalCssFiles.length === 0 && shouldWarnForMissingGlobalCss(rootDir)) {
|
|
3842
4489
|
process.stderr.write(
|
|
3843
4490
|
"warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
|
|
3844
4491
|
);
|
|
@@ -3857,7 +4504,8 @@ Available: ${available}`
|
|
|
3857
4504
|
`
|
|
3858
4505
|
);
|
|
3859
4506
|
const fmt2 = resolveSingleFormat(opts.format);
|
|
3860
|
-
|
|
4507
|
+
const failures = [];
|
|
4508
|
+
const outputPaths = [];
|
|
3861
4509
|
for (const [scenarioName, props2] of Object.entries(scenarios)) {
|
|
3862
4510
|
const isNamed = scenarioName !== "__default__";
|
|
3863
4511
|
const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
|
|
@@ -3880,14 +4528,22 @@ Available: ${available}`
|
|
|
3880
4528
|
process.stderr.write(` Hints: ${hintList}
|
|
3881
4529
|
`);
|
|
3882
4530
|
}
|
|
3883
|
-
|
|
4531
|
+
failures.push({
|
|
4532
|
+
component: componentName,
|
|
4533
|
+
scenario: isNamed ? scenarioName : void 0,
|
|
4534
|
+
stage: "render",
|
|
4535
|
+
message: outcome.error.message,
|
|
4536
|
+
outputPath: `${DEFAULT_OUTPUT_DIR}/${isNamed ? `${componentName}-${scenarioName}.error.json` : `${componentName}.error.json`}`,
|
|
4537
|
+
hints: outcome.error.heuristicFlags
|
|
4538
|
+
});
|
|
3884
4539
|
continue;
|
|
3885
4540
|
}
|
|
3886
4541
|
const result = outcome.result;
|
|
3887
4542
|
const outFileName = isNamed ? `${componentName}-${scenarioName}.png` : `${componentName}.png`;
|
|
3888
4543
|
if (opts.output !== void 0 && !isNamed) {
|
|
3889
|
-
const outPath =
|
|
3890
|
-
|
|
4544
|
+
const outPath = resolve11(process.cwd(), opts.output);
|
|
4545
|
+
writeFileSync6(outPath, result.screenshot);
|
|
4546
|
+
outputPaths.push(outPath);
|
|
3891
4547
|
process.stdout.write(
|
|
3892
4548
|
`\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
3893
4549
|
`
|
|
@@ -3897,22 +4553,41 @@ Available: ${available}`
|
|
|
3897
4553
|
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
3898
4554
|
`);
|
|
3899
4555
|
} else {
|
|
3900
|
-
const dir =
|
|
3901
|
-
|
|
3902
|
-
const outPath =
|
|
3903
|
-
|
|
4556
|
+
const dir = resolve11(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
4557
|
+
mkdirSync5(dir, { recursive: true });
|
|
4558
|
+
const outPath = resolve11(dir, outFileName);
|
|
4559
|
+
writeFileSync6(outPath, result.screenshot);
|
|
3904
4560
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
|
|
4561
|
+
outputPaths.push(relPath);
|
|
3905
4562
|
process.stdout.write(
|
|
3906
4563
|
`\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
3907
4564
|
`
|
|
3908
4565
|
);
|
|
3909
4566
|
}
|
|
3910
4567
|
}
|
|
4568
|
+
const summaryPath = writeRunSummary({
|
|
4569
|
+
command: `scope render ${componentName}`,
|
|
4570
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4571
|
+
outputPaths,
|
|
4572
|
+
failures
|
|
4573
|
+
});
|
|
4574
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4575
|
+
`);
|
|
4576
|
+
if (fmt2 === "json" && failures.length > 0) {
|
|
4577
|
+
const aggregateFailure = formatAggregateRenderFailureJson(
|
|
4578
|
+
componentName,
|
|
4579
|
+
failures,
|
|
4580
|
+
Object.keys(scenarios).length,
|
|
4581
|
+
summaryPath
|
|
4582
|
+
);
|
|
4583
|
+
process.stderr.write(`${JSON.stringify(aggregateFailure, null, 2)}
|
|
4584
|
+
`);
|
|
4585
|
+
}
|
|
3911
4586
|
await shutdownPool3();
|
|
3912
|
-
if (
|
|
4587
|
+
if (failures.length > 0) process.exit(1);
|
|
3913
4588
|
} catch (err) {
|
|
3914
4589
|
await shutdownPool3();
|
|
3915
|
-
process.stderr.write(
|
|
4590
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3916
4591
|
`);
|
|
3917
4592
|
process.exit(1);
|
|
3918
4593
|
}
|
|
@@ -3920,7 +4595,9 @@ Available: ${available}`
|
|
|
3920
4595
|
);
|
|
3921
4596
|
}
|
|
3922
4597
|
function registerRenderMatrix(renderCmd) {
|
|
3923
|
-
renderCmd.command("matrix <component>").description(
|
|
4598
|
+
renderCmd.command("matrix <component>").description(
|
|
4599
|
+
'Render every combination of values across one or more prop axes.\nProduces a matrix of screenshots \u2014 one cell per combination.\n\nAXES FORMAT (two equivalent forms):\n Short: --axes "variant:primary,ghost size:sm,md,lg"\n JSON: --axes {"variant":["primary","ghost"],"size":["sm","md","lg"]}\n\nCOMPOSITION CONTEXTS (--contexts):\n Test component in different layout environments.\n Available IDs: centered, rtl, sidebar, dark-bg, light-bg\n (Define custom contexts in reactscope.config.json)\n\nSTRESS PRESETS (--stress):\n Inject adversarial content to test edge cases.\n Available IDs: text.long, text.unicode, text.empty\n\nExamples:\n scope render matrix Button --axes "variant:primary,ghost,destructive"\n scope render matrix Button --axes "variant:primary,ghost size:sm,lg" --sprite matrix.png\n scope render matrix Badge --axes "type:info,warn,error" --contexts centered,rtl\n scope render matrix Input --stress text.long,text.unicode --format json'
|
|
4600
|
+
).option(
|
|
3924
4601
|
"--axes <spec>",
|
|
3925
4602
|
`Axis definitions: key:v1,v2 space-separated OR JSON object e.g. 'variant:primary,ghost size:sm,lg' or '{"variant":["primary","ghost"],"size":["sm","lg"]}'`
|
|
3926
4603
|
).option(
|
|
@@ -3941,7 +4618,7 @@ Available: ${available}`
|
|
|
3941
4618
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 8);
|
|
3942
4619
|
const { width, height } = { width: 375, height: 812 };
|
|
3943
4620
|
const rootDir = process.cwd();
|
|
3944
|
-
const filePath =
|
|
4621
|
+
const filePath = resolve11(rootDir, descriptor.filePath);
|
|
3945
4622
|
const matrixCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
3946
4623
|
const renderer = buildRenderer(
|
|
3947
4624
|
filePath,
|
|
@@ -4034,8 +4711,8 @@ Available: ${available}`
|
|
|
4034
4711
|
const { SpriteSheetGenerator: SpriteSheetGenerator2 } = await import("@agent-scope/render");
|
|
4035
4712
|
const gen = new SpriteSheetGenerator2();
|
|
4036
4713
|
const sheet = await gen.generate(result);
|
|
4037
|
-
const spritePath =
|
|
4038
|
-
|
|
4714
|
+
const spritePath = resolve11(process.cwd(), opts.sprite);
|
|
4715
|
+
writeFileSync6(spritePath, sheet.png);
|
|
4039
4716
|
process.stderr.write(`Sprite sheet saved to ${spritePath}
|
|
4040
4717
|
`);
|
|
4041
4718
|
}
|
|
@@ -4044,10 +4721,10 @@ Available: ${available}`
|
|
|
4044
4721
|
const { SpriteSheetGenerator: SpriteSheetGenerator2 } = await import("@agent-scope/render");
|
|
4045
4722
|
const gen = new SpriteSheetGenerator2();
|
|
4046
4723
|
const sheet = await gen.generate(result);
|
|
4047
|
-
const dir =
|
|
4048
|
-
|
|
4049
|
-
const outPath =
|
|
4050
|
-
|
|
4724
|
+
const dir = resolve11(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
4725
|
+
mkdirSync5(dir, { recursive: true });
|
|
4726
|
+
const outPath = resolve11(dir, `${componentName}-matrix.png`);
|
|
4727
|
+
writeFileSync6(outPath, sheet.png);
|
|
4051
4728
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}-matrix.png`;
|
|
4052
4729
|
process.stdout.write(
|
|
4053
4730
|
`\u2713 ${componentName} matrix (${result.stats.totalCells} cells) \u2192 ${relPath} (${result.stats.wallClockTimeMs.toFixed(0)}ms total)
|
|
@@ -4071,7 +4748,7 @@ Available: ${available}`
|
|
|
4071
4748
|
}
|
|
4072
4749
|
} catch (err) {
|
|
4073
4750
|
await shutdownPool3();
|
|
4074
|
-
process.stderr.write(
|
|
4751
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4075
4752
|
`);
|
|
4076
4753
|
process.exit(1);
|
|
4077
4754
|
}
|
|
@@ -4079,7 +4756,9 @@ Available: ${available}`
|
|
|
4079
4756
|
);
|
|
4080
4757
|
}
|
|
4081
4758
|
function registerRenderAll(renderCmd) {
|
|
4082
|
-
renderCmd.command("all").description(
|
|
4759
|
+
renderCmd.command("all").description(
|
|
4760
|
+
"Render every component in the manifest and write to .reactscope/renders/.\n\nAlso emits .reactscope/compliance-styles.json (computed CSS class\u2192value map)\nwhich is required by `scope tokens compliance`.\n\nSCENARIO SELECTION:\n Each component is rendered using its default scenario from its .scope file\n if one exists, otherwise with all-default props.\n\nMATRIX AUTO-DETECTION:\n If a component has a .scope file with a matrix block, render all\n will render the matrix cells in addition to the default screenshot.\n\nExamples:\n scope render all\n scope render all --concurrency 8\n scope render all --format json --output-dir .reactscope/renders\n scope render all --manifest ./custom/manifest.json"
|
|
4761
|
+
).option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
|
|
4083
4762
|
async (opts) => {
|
|
4084
4763
|
try {
|
|
4085
4764
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -4087,22 +4766,40 @@ function registerRenderAll(renderCmd) {
|
|
|
4087
4766
|
const total = componentNames.length;
|
|
4088
4767
|
if (total === 0) {
|
|
4089
4768
|
process.stderr.write("No components found in manifest.\n");
|
|
4090
|
-
|
|
4769
|
+
const summaryPath2 = writeRunSummary({
|
|
4770
|
+
command: "scope render all",
|
|
4771
|
+
status: "failed",
|
|
4772
|
+
outputPaths: [],
|
|
4773
|
+
failures: [
|
|
4774
|
+
{
|
|
4775
|
+
component: "*",
|
|
4776
|
+
stage: "render",
|
|
4777
|
+
message: "No components found in manifest; refusing to report a false-green batch render."
|
|
4778
|
+
}
|
|
4779
|
+
]
|
|
4780
|
+
});
|
|
4781
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath2}
|
|
4782
|
+
`);
|
|
4783
|
+
process.exit(1);
|
|
4091
4784
|
}
|
|
4092
4785
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
|
|
4093
|
-
const outputDir =
|
|
4094
|
-
|
|
4786
|
+
const outputDir = resolve11(process.cwd(), opts.outputDir);
|
|
4787
|
+
mkdirSync5(outputDir, { recursive: true });
|
|
4095
4788
|
const rootDir = process.cwd();
|
|
4096
4789
|
process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
|
|
4097
4790
|
`);
|
|
4098
4791
|
const results = [];
|
|
4792
|
+
const failures = [];
|
|
4793
|
+
const outputPaths = [];
|
|
4099
4794
|
const complianceStylesMap = {};
|
|
4100
4795
|
let completed = 0;
|
|
4796
|
+
const iconPatterns = loadIconPatternsFromConfig(process.cwd());
|
|
4101
4797
|
const renderOne = async (name) => {
|
|
4102
4798
|
const descriptor = manifest.components[name];
|
|
4103
4799
|
if (descriptor === void 0) return;
|
|
4104
|
-
const filePath =
|
|
4800
|
+
const filePath = resolve11(rootDir, descriptor.filePath);
|
|
4105
4801
|
const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
|
|
4802
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
4106
4803
|
const scopeData = await loadScopeFileForComponent(filePath);
|
|
4107
4804
|
const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
|
|
4108
4805
|
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
@@ -4115,7 +4812,8 @@ function registerRenderAll(renderCmd) {
|
|
|
4115
4812
|
812,
|
|
4116
4813
|
allCssFiles,
|
|
4117
4814
|
process.cwd(),
|
|
4118
|
-
wrapperScript
|
|
4815
|
+
wrapperScript,
|
|
4816
|
+
isIcon
|
|
4119
4817
|
);
|
|
4120
4818
|
const outcome = await safeRender2(
|
|
4121
4819
|
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
@@ -4138,8 +4836,8 @@ function registerRenderAll(renderCmd) {
|
|
|
4138
4836
|
success: false,
|
|
4139
4837
|
errorMessage: outcome.error.message
|
|
4140
4838
|
});
|
|
4141
|
-
const errPath =
|
|
4142
|
-
|
|
4839
|
+
const errPath = resolve11(outputDir, `${name}.error.json`);
|
|
4840
|
+
writeFileSync6(
|
|
4143
4841
|
errPath,
|
|
4144
4842
|
JSON.stringify(
|
|
4145
4843
|
{
|
|
@@ -4152,14 +4850,32 @@ function registerRenderAll(renderCmd) {
|
|
|
4152
4850
|
2
|
|
4153
4851
|
)
|
|
4154
4852
|
);
|
|
4853
|
+
failures.push({
|
|
4854
|
+
component: name,
|
|
4855
|
+
stage: "render",
|
|
4856
|
+
message: outcome.error.message,
|
|
4857
|
+
outputPath: errPath,
|
|
4858
|
+
hints: outcome.error.heuristicFlags
|
|
4859
|
+
});
|
|
4860
|
+
outputPaths.push(errPath);
|
|
4155
4861
|
return;
|
|
4156
4862
|
}
|
|
4157
4863
|
const result = outcome.result;
|
|
4158
4864
|
results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4865
|
+
if (!isIcon) {
|
|
4866
|
+
const pngPath = resolve11(outputDir, `${name}.png`);
|
|
4867
|
+
writeFileSync6(pngPath, result.screenshot);
|
|
4868
|
+
outputPaths.push(pngPath);
|
|
4869
|
+
}
|
|
4870
|
+
const jsonPath = resolve11(outputDir, `${name}.json`);
|
|
4871
|
+
const renderJson = formatRenderJson(name, {}, result);
|
|
4872
|
+
const extResult = result;
|
|
4873
|
+
if (isIcon && extResult.svgContent) {
|
|
4874
|
+
renderJson.svgContent = extResult.svgContent;
|
|
4875
|
+
delete renderJson.screenshot;
|
|
4876
|
+
}
|
|
4877
|
+
writeFileSync6(jsonPath, JSON.stringify(renderJson, null, 2));
|
|
4878
|
+
outputPaths.push(jsonPath);
|
|
4163
4879
|
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
4164
4880
|
const compStyles = {
|
|
4165
4881
|
colors: {},
|
|
@@ -4220,20 +4936,26 @@ function registerRenderAll(renderCmd) {
|
|
|
4220
4936
|
height: cell.result.height,
|
|
4221
4937
|
renderTimeMs: cell.result.renderTimeMs
|
|
4222
4938
|
}));
|
|
4223
|
-
const existingJson = JSON.parse(
|
|
4939
|
+
const existingJson = JSON.parse(readFileSync9(jsonPath, "utf-8"));
|
|
4224
4940
|
existingJson.cells = matrixCells;
|
|
4225
4941
|
existingJson.axisLabels = [scenarioAxis.values];
|
|
4226
|
-
|
|
4942
|
+
writeFileSync6(jsonPath, JSON.stringify(existingJson, null, 2));
|
|
4227
4943
|
} catch (matrixErr) {
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
`
|
|
4231
|
-
|
|
4944
|
+
const message = matrixErr instanceof Error ? matrixErr.message : String(matrixErr);
|
|
4945
|
+
process.stderr.write(` [warn] Matrix render for ${name} failed: ${message}
|
|
4946
|
+
`);
|
|
4947
|
+
failures.push({
|
|
4948
|
+
component: name,
|
|
4949
|
+
stage: "matrix",
|
|
4950
|
+
message,
|
|
4951
|
+
outputPath: jsonPath
|
|
4952
|
+
});
|
|
4232
4953
|
}
|
|
4233
4954
|
}
|
|
4234
4955
|
if (isTTY()) {
|
|
4956
|
+
const suffix = isIcon ? " [icon/svg]" : "";
|
|
4235
4957
|
process.stdout.write(
|
|
4236
|
-
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
4958
|
+
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}${isIcon ? ".json" : ".png"} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)${suffix}
|
|
4237
4959
|
`
|
|
4238
4960
|
);
|
|
4239
4961
|
}
|
|
@@ -4254,21 +4976,31 @@ function registerRenderAll(renderCmd) {
|
|
|
4254
4976
|
}
|
|
4255
4977
|
await Promise.all(workers);
|
|
4256
4978
|
await shutdownPool3();
|
|
4257
|
-
const compStylesPath =
|
|
4258
|
-
|
|
4979
|
+
const compStylesPath = resolve11(
|
|
4980
|
+
resolve11(process.cwd(), opts.outputDir),
|
|
4259
4981
|
"..",
|
|
4260
4982
|
"compliance-styles.json"
|
|
4261
4983
|
);
|
|
4262
|
-
|
|
4984
|
+
writeFileSync6(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
|
|
4985
|
+
outputPaths.push(compStylesPath);
|
|
4263
4986
|
process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
|
|
4264
4987
|
`);
|
|
4265
4988
|
process.stderr.write("\n");
|
|
4266
4989
|
const summary = formatSummaryText(results, outputDir);
|
|
4267
4990
|
process.stderr.write(`${summary}
|
|
4268
4991
|
`);
|
|
4992
|
+
const summaryPath = writeRunSummary({
|
|
4993
|
+
command: "scope render all",
|
|
4994
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4995
|
+
outputPaths,
|
|
4996
|
+
failures
|
|
4997
|
+
});
|
|
4998
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4999
|
+
`);
|
|
5000
|
+
if (failures.length > 0) process.exit(1);
|
|
4269
5001
|
} catch (err) {
|
|
4270
5002
|
await shutdownPool3();
|
|
4271
|
-
process.stderr.write(
|
|
5003
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4272
5004
|
`);
|
|
4273
5005
|
process.exit(1);
|
|
4274
5006
|
}
|
|
@@ -4300,8 +5032,8 @@ function resolveMatrixFormat(formatFlag, spriteAlreadyWritten) {
|
|
|
4300
5032
|
return "json";
|
|
4301
5033
|
}
|
|
4302
5034
|
function createRenderCommand() {
|
|
4303
|
-
const renderCmd = new
|
|
4304
|
-
|
|
5035
|
+
const renderCmd = new Command8("render").description(
|
|
5036
|
+
'Render React components to PNG screenshots or JSON render data.\n\nScope uses two render engines depending on component complexity:\n Satori \u2014 fast SVG\u2192PNG renderer for simple/presentational components\n BrowserPool \u2014 Playwright headless browser for complex components\n (context providers, hooks, async, global CSS)\n\nPREREQUISITES:\n 1. reactscope.config.json exists (scope init)\n 2. manifest.json is up to date (scope manifest generate)\n 3. If using globalCSS: Tailwind/PostCSS is configured in the project\n\nOUTPUTS (written to .reactscope/renders/<ComponentName>/):\n screenshot.png retina-quality PNG (2\xD7 physical pixels, displayed at 1\xD7)\n render.json props, dimensions, DOM snapshot, a11y, computed styles\n compliance-styles.json (render all only) \u2014 token matching input\n\nExamples:\n scope render component Button\n scope render matrix Button --axes "variant:primary,ghost size:sm,md,lg"\n scope render all\n scope render all --format json --output-dir ./out'
|
|
4305
5037
|
);
|
|
4306
5038
|
registerRenderSingle(renderCmd);
|
|
4307
5039
|
registerRenderMatrix(renderCmd);
|
|
@@ -4310,8 +5042,8 @@ function createRenderCommand() {
|
|
|
4310
5042
|
}
|
|
4311
5043
|
|
|
4312
5044
|
// src/report/baseline.ts
|
|
4313
|
-
import { existsSync as
|
|
4314
|
-
import { resolve as
|
|
5045
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync6, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "fs";
|
|
5046
|
+
import { resolve as resolve12 } from "path";
|
|
4315
5047
|
import { generateManifest as generateManifest4 } from "@agent-scope/manifest";
|
|
4316
5048
|
import { BrowserPool as BrowserPool4, safeRender as safeRender3 } from "@agent-scope/render";
|
|
4317
5049
|
import { ComplianceEngine as ComplianceEngine2, TokenResolver as TokenResolver2 } from "@agent-scope/tokens";
|
|
@@ -4335,8 +5067,17 @@ async function shutdownPool4() {
|
|
|
4335
5067
|
}
|
|
4336
5068
|
}
|
|
4337
5069
|
async function renderComponent2(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
5070
|
+
const PAD = 24;
|
|
4338
5071
|
const pool = await getPool4(viewportWidth, viewportHeight);
|
|
4339
|
-
const htmlHarness = await buildComponentHarness(
|
|
5072
|
+
const htmlHarness = await buildComponentHarness(
|
|
5073
|
+
filePath,
|
|
5074
|
+
componentName,
|
|
5075
|
+
props,
|
|
5076
|
+
viewportWidth,
|
|
5077
|
+
void 0,
|
|
5078
|
+
void 0,
|
|
5079
|
+
PAD
|
|
5080
|
+
);
|
|
4340
5081
|
const slot = await pool.acquire();
|
|
4341
5082
|
const { page } = slot;
|
|
4342
5083
|
try {
|
|
@@ -4376,7 +5117,6 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
|
|
|
4376
5117
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4377
5118
|
);
|
|
4378
5119
|
}
|
|
4379
|
-
const PAD = 24;
|
|
4380
5120
|
const MIN_W = 320;
|
|
4381
5121
|
const MIN_H = 200;
|
|
4382
5122
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4460,20 +5200,20 @@ async function runBaseline(options = {}) {
|
|
|
4460
5200
|
} = options;
|
|
4461
5201
|
const startTime = performance.now();
|
|
4462
5202
|
const rootDir = process.cwd();
|
|
4463
|
-
const baselineDir =
|
|
4464
|
-
const rendersDir =
|
|
4465
|
-
if (
|
|
5203
|
+
const baselineDir = resolve12(rootDir, outputDir);
|
|
5204
|
+
const rendersDir = resolve12(baselineDir, "renders");
|
|
5205
|
+
if (existsSync10(baselineDir)) {
|
|
4466
5206
|
rmSync2(baselineDir, { recursive: true, force: true });
|
|
4467
5207
|
}
|
|
4468
|
-
|
|
5208
|
+
mkdirSync6(rendersDir, { recursive: true });
|
|
4469
5209
|
let manifest;
|
|
4470
5210
|
if (manifestPath !== void 0) {
|
|
4471
|
-
const { readFileSync:
|
|
4472
|
-
const absPath =
|
|
4473
|
-
if (!
|
|
5211
|
+
const { readFileSync: readFileSync18 } = await import("fs");
|
|
5212
|
+
const absPath = resolve12(rootDir, manifestPath);
|
|
5213
|
+
if (!existsSync10(absPath)) {
|
|
4474
5214
|
throw new Error(`Manifest not found at ${absPath}.`);
|
|
4475
5215
|
}
|
|
4476
|
-
manifest = JSON.parse(
|
|
5216
|
+
manifest = JSON.parse(readFileSync18(absPath, "utf-8"));
|
|
4477
5217
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4478
5218
|
`);
|
|
4479
5219
|
} else {
|
|
@@ -4483,7 +5223,7 @@ async function runBaseline(options = {}) {
|
|
|
4483
5223
|
process.stderr.write(`Found ${count} components.
|
|
4484
5224
|
`);
|
|
4485
5225
|
}
|
|
4486
|
-
|
|
5226
|
+
writeFileSync7(resolve12(baselineDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
|
4487
5227
|
let componentNames = Object.keys(manifest.components);
|
|
4488
5228
|
if (componentsGlob !== void 0) {
|
|
4489
5229
|
componentNames = componentNames.filter((name) => matchGlob(componentsGlob, name));
|
|
@@ -4503,8 +5243,8 @@ async function runBaseline(options = {}) {
|
|
|
4503
5243
|
aggregateCompliance: 1,
|
|
4504
5244
|
auditedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4505
5245
|
};
|
|
4506
|
-
|
|
4507
|
-
|
|
5246
|
+
writeFileSync7(
|
|
5247
|
+
resolve12(baselineDir, "compliance.json"),
|
|
4508
5248
|
JSON.stringify(emptyReport, null, 2),
|
|
4509
5249
|
"utf-8"
|
|
4510
5250
|
);
|
|
@@ -4525,7 +5265,7 @@ async function runBaseline(options = {}) {
|
|
|
4525
5265
|
const renderOne = async (name) => {
|
|
4526
5266
|
const descriptor = manifest.components[name];
|
|
4527
5267
|
if (descriptor === void 0) return;
|
|
4528
|
-
const filePath =
|
|
5268
|
+
const filePath = resolve12(rootDir, descriptor.filePath);
|
|
4529
5269
|
const outcome = await safeRender3(
|
|
4530
5270
|
() => renderComponent2(filePath, name, {}, viewportWidth, viewportHeight),
|
|
4531
5271
|
{
|
|
@@ -4544,8 +5284,8 @@ async function runBaseline(options = {}) {
|
|
|
4544
5284
|
}
|
|
4545
5285
|
if (outcome.crashed) {
|
|
4546
5286
|
failureCount++;
|
|
4547
|
-
const errPath =
|
|
4548
|
-
|
|
5287
|
+
const errPath = resolve12(rendersDir, `${name}.error.json`);
|
|
5288
|
+
writeFileSync7(
|
|
4549
5289
|
errPath,
|
|
4550
5290
|
JSON.stringify(
|
|
4551
5291
|
{
|
|
@@ -4562,10 +5302,10 @@ async function runBaseline(options = {}) {
|
|
|
4562
5302
|
return;
|
|
4563
5303
|
}
|
|
4564
5304
|
const result = outcome.result;
|
|
4565
|
-
|
|
5305
|
+
writeFileSync7(resolve12(rendersDir, `${name}.png`), result.screenshot);
|
|
4566
5306
|
const jsonOutput = formatRenderJson(name, {}, result);
|
|
4567
|
-
|
|
4568
|
-
|
|
5307
|
+
writeFileSync7(
|
|
5308
|
+
resolve12(rendersDir, `${name}.json`),
|
|
4569
5309
|
JSON.stringify(jsonOutput, null, 2),
|
|
4570
5310
|
"utf-8"
|
|
4571
5311
|
);
|
|
@@ -4592,8 +5332,8 @@ async function runBaseline(options = {}) {
|
|
|
4592
5332
|
const resolver = new TokenResolver2([]);
|
|
4593
5333
|
const engine = new ComplianceEngine2(resolver);
|
|
4594
5334
|
const batchReport = engine.auditBatch(computedStylesMap);
|
|
4595
|
-
|
|
4596
|
-
|
|
5335
|
+
writeFileSync7(
|
|
5336
|
+
resolve12(baselineDir, "compliance.json"),
|
|
4597
5337
|
JSON.stringify(batchReport, null, 2),
|
|
4598
5338
|
"utf-8"
|
|
4599
5339
|
);
|
|
@@ -4636,22 +5376,22 @@ function registerBaselineSubCommand(reportCmd) {
|
|
|
4636
5376
|
}
|
|
4637
5377
|
|
|
4638
5378
|
// src/report/diff.ts
|
|
4639
|
-
import { existsSync as
|
|
4640
|
-
import { resolve as
|
|
5379
|
+
import { existsSync as existsSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
|
|
5380
|
+
import { resolve as resolve13 } from "path";
|
|
4641
5381
|
import { generateManifest as generateManifest5 } from "@agent-scope/manifest";
|
|
4642
5382
|
import { BrowserPool as BrowserPool5, safeRender as safeRender4 } from "@agent-scope/render";
|
|
4643
5383
|
import { ComplianceEngine as ComplianceEngine3, TokenResolver as TokenResolver3 } from "@agent-scope/tokens";
|
|
4644
5384
|
var DEFAULT_BASELINE_DIR2 = ".reactscope/baseline";
|
|
4645
5385
|
function loadBaselineCompliance(baselineDir) {
|
|
4646
|
-
const compliancePath =
|
|
4647
|
-
if (!
|
|
4648
|
-
const raw = JSON.parse(
|
|
5386
|
+
const compliancePath = resolve13(baselineDir, "compliance.json");
|
|
5387
|
+
if (!existsSync11(compliancePath)) return null;
|
|
5388
|
+
const raw = JSON.parse(readFileSync10(compliancePath, "utf-8"));
|
|
4649
5389
|
return raw;
|
|
4650
5390
|
}
|
|
4651
5391
|
function loadBaselineRenderJson2(baselineDir, componentName) {
|
|
4652
|
-
const jsonPath =
|
|
4653
|
-
if (!
|
|
4654
|
-
return JSON.parse(
|
|
5392
|
+
const jsonPath = resolve13(baselineDir, "renders", `${componentName}.json`);
|
|
5393
|
+
if (!existsSync11(jsonPath)) return null;
|
|
5394
|
+
return JSON.parse(readFileSync10(jsonPath, "utf-8"));
|
|
4655
5395
|
}
|
|
4656
5396
|
var _pool5 = null;
|
|
4657
5397
|
async function getPool5(viewportWidth, viewportHeight) {
|
|
@@ -4672,8 +5412,17 @@ async function shutdownPool5() {
|
|
|
4672
5412
|
}
|
|
4673
5413
|
}
|
|
4674
5414
|
async function renderComponent3(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
5415
|
+
const PAD = 24;
|
|
4675
5416
|
const pool = await getPool5(viewportWidth, viewportHeight);
|
|
4676
|
-
const htmlHarness = await buildComponentHarness(
|
|
5417
|
+
const htmlHarness = await buildComponentHarness(
|
|
5418
|
+
filePath,
|
|
5419
|
+
componentName,
|
|
5420
|
+
props,
|
|
5421
|
+
viewportWidth,
|
|
5422
|
+
void 0,
|
|
5423
|
+
void 0,
|
|
5424
|
+
PAD
|
|
5425
|
+
);
|
|
4677
5426
|
const slot = await pool.acquire();
|
|
4678
5427
|
const { page } = slot;
|
|
4679
5428
|
try {
|
|
@@ -4713,7 +5462,6 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
|
|
|
4713
5462
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4714
5463
|
);
|
|
4715
5464
|
}
|
|
4716
|
-
const PAD = 24;
|
|
4717
5465
|
const MIN_W = 320;
|
|
4718
5466
|
const MIN_H = 200;
|
|
4719
5467
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4810,6 +5558,7 @@ function classifyComponent(entry, regressionThreshold) {
|
|
|
4810
5558
|
async function runDiff(options = {}) {
|
|
4811
5559
|
const {
|
|
4812
5560
|
baselineDir: baselineDirRaw = DEFAULT_BASELINE_DIR2,
|
|
5561
|
+
complianceTokens = [],
|
|
4813
5562
|
componentsGlob,
|
|
4814
5563
|
manifestPath,
|
|
4815
5564
|
viewportWidth = 375,
|
|
@@ -4818,19 +5567,19 @@ async function runDiff(options = {}) {
|
|
|
4818
5567
|
} = options;
|
|
4819
5568
|
const startTime = performance.now();
|
|
4820
5569
|
const rootDir = process.cwd();
|
|
4821
|
-
const baselineDir =
|
|
4822
|
-
if (!
|
|
5570
|
+
const baselineDir = resolve13(rootDir, baselineDirRaw);
|
|
5571
|
+
if (!existsSync11(baselineDir)) {
|
|
4823
5572
|
throw new Error(
|
|
4824
5573
|
`Baseline directory not found at "${baselineDir}". Run \`scope report baseline\` first to create a baseline snapshot.`
|
|
4825
5574
|
);
|
|
4826
5575
|
}
|
|
4827
|
-
const baselineManifestPath =
|
|
4828
|
-
if (!
|
|
5576
|
+
const baselineManifestPath = resolve13(baselineDir, "manifest.json");
|
|
5577
|
+
if (!existsSync11(baselineManifestPath)) {
|
|
4829
5578
|
throw new Error(
|
|
4830
5579
|
`Baseline manifest.json not found at "${baselineManifestPath}". The baseline directory may be incomplete \u2014 re-run \`scope report baseline\`.`
|
|
4831
5580
|
);
|
|
4832
5581
|
}
|
|
4833
|
-
const baselineManifest = JSON.parse(
|
|
5582
|
+
const baselineManifest = JSON.parse(readFileSync10(baselineManifestPath, "utf-8"));
|
|
4834
5583
|
const baselineCompliance = loadBaselineCompliance(baselineDir);
|
|
4835
5584
|
const baselineComponentNames = new Set(Object.keys(baselineManifest.components));
|
|
4836
5585
|
process.stderr.write(
|
|
@@ -4839,11 +5588,11 @@ async function runDiff(options = {}) {
|
|
|
4839
5588
|
);
|
|
4840
5589
|
let currentManifest;
|
|
4841
5590
|
if (manifestPath !== void 0) {
|
|
4842
|
-
const absPath =
|
|
4843
|
-
if (!
|
|
5591
|
+
const absPath = resolve13(rootDir, manifestPath);
|
|
5592
|
+
if (!existsSync11(absPath)) {
|
|
4844
5593
|
throw new Error(`Manifest not found at "${absPath}".`);
|
|
4845
5594
|
}
|
|
4846
|
-
currentManifest = JSON.parse(
|
|
5595
|
+
currentManifest = JSON.parse(readFileSync10(absPath, "utf-8"));
|
|
4847
5596
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4848
5597
|
`);
|
|
4849
5598
|
} else {
|
|
@@ -4876,7 +5625,7 @@ async function runDiff(options = {}) {
|
|
|
4876
5625
|
const renderOne = async (name) => {
|
|
4877
5626
|
const descriptor = currentManifest.components[name];
|
|
4878
5627
|
if (descriptor === void 0) return;
|
|
4879
|
-
const filePath =
|
|
5628
|
+
const filePath = resolve13(rootDir, descriptor.filePath);
|
|
4880
5629
|
const outcome = await safeRender4(
|
|
4881
5630
|
() => renderComponent3(filePath, name, {}, viewportWidth, viewportHeight),
|
|
4882
5631
|
{
|
|
@@ -4925,7 +5674,7 @@ async function runDiff(options = {}) {
|
|
|
4925
5674
|
if (isTTY() && total > 0) {
|
|
4926
5675
|
process.stderr.write("\n");
|
|
4927
5676
|
}
|
|
4928
|
-
const resolver = new TokenResolver3(
|
|
5677
|
+
const resolver = new TokenResolver3(complianceTokens);
|
|
4929
5678
|
const engine = new ComplianceEngine3(resolver);
|
|
4930
5679
|
const currentBatchReport = engine.auditBatch(computedStylesMap);
|
|
4931
5680
|
const entries = [];
|
|
@@ -5094,7 +5843,7 @@ function registerDiffSubCommand(reportCmd) {
|
|
|
5094
5843
|
regressionThreshold
|
|
5095
5844
|
});
|
|
5096
5845
|
if (opts.output !== void 0) {
|
|
5097
|
-
|
|
5846
|
+
writeFileSync8(opts.output, JSON.stringify(result, null, 2), "utf-8");
|
|
5098
5847
|
process.stderr.write(`Diff written to ${opts.output}
|
|
5099
5848
|
`);
|
|
5100
5849
|
}
|
|
@@ -5116,8 +5865,8 @@ function registerDiffSubCommand(reportCmd) {
|
|
|
5116
5865
|
}
|
|
5117
5866
|
|
|
5118
5867
|
// src/report/pr-comment.ts
|
|
5119
|
-
import { existsSync as
|
|
5120
|
-
import { resolve as
|
|
5868
|
+
import { existsSync as existsSync12, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
|
|
5869
|
+
import { resolve as resolve14 } from "path";
|
|
5121
5870
|
var STATUS_BADGE = {
|
|
5122
5871
|
added: "\u2705 added",
|
|
5123
5872
|
removed: "\u{1F5D1}\uFE0F removed",
|
|
@@ -5200,13 +5949,13 @@ function formatPrComment(diff) {
|
|
|
5200
5949
|
return lines.join("\n");
|
|
5201
5950
|
}
|
|
5202
5951
|
function loadDiffResult(filePath) {
|
|
5203
|
-
const abs =
|
|
5204
|
-
if (!
|
|
5952
|
+
const abs = resolve14(filePath);
|
|
5953
|
+
if (!existsSync12(abs)) {
|
|
5205
5954
|
throw new Error(`DiffResult file not found: ${abs}`);
|
|
5206
5955
|
}
|
|
5207
5956
|
let raw;
|
|
5208
5957
|
try {
|
|
5209
|
-
raw =
|
|
5958
|
+
raw = readFileSync11(abs, "utf-8");
|
|
5210
5959
|
} catch (err) {
|
|
5211
5960
|
throw new Error(
|
|
5212
5961
|
`Failed to read DiffResult file: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -5233,7 +5982,7 @@ function registerPrCommentSubCommand(reportCmd) {
|
|
|
5233
5982
|
const diff = loadDiffResult(opts.input);
|
|
5234
5983
|
const comment = formatPrComment(diff);
|
|
5235
5984
|
if (opts.output !== void 0) {
|
|
5236
|
-
|
|
5985
|
+
writeFileSync9(resolve14(opts.output), comment, "utf-8");
|
|
5237
5986
|
process.stderr.write(`PR comment written to ${opts.output}
|
|
5238
5987
|
`);
|
|
5239
5988
|
} else {
|
|
@@ -5528,11 +6277,180 @@ function buildStructuredReport(report) {
|
|
|
5528
6277
|
}
|
|
5529
6278
|
|
|
5530
6279
|
// src/site-commands.ts
|
|
5531
|
-
import {
|
|
6280
|
+
import {
|
|
6281
|
+
createReadStream,
|
|
6282
|
+
existsSync as existsSync13,
|
|
6283
|
+
watch as fsWatch,
|
|
6284
|
+
readFileSync as readFileSync12,
|
|
6285
|
+
statSync as statSync2,
|
|
6286
|
+
writeFileSync as writeFileSync10
|
|
6287
|
+
} from "fs";
|
|
6288
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
5532
6289
|
import { createServer } from "http";
|
|
5533
|
-
import { extname, join as
|
|
6290
|
+
import { extname, join as join6, resolve as resolve15 } from "path";
|
|
6291
|
+
import { generateManifest as generateManifest6 } from "@agent-scope/manifest";
|
|
6292
|
+
import { safeRender as safeRender5 } from "@agent-scope/render";
|
|
5534
6293
|
import { buildSite } from "@agent-scope/site";
|
|
5535
|
-
import { Command as
|
|
6294
|
+
import { Command as Command9 } from "commander";
|
|
6295
|
+
|
|
6296
|
+
// src/playground-bundler.ts
|
|
6297
|
+
import { dirname as dirname5 } from "path";
|
|
6298
|
+
import * as esbuild3 from "esbuild";
|
|
6299
|
+
async function buildPlaygroundHarness(filePath, componentName, projectCss, wrapperScript) {
|
|
6300
|
+
const bundledScript = await bundlePlaygroundIIFE(filePath, componentName);
|
|
6301
|
+
return wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript);
|
|
6302
|
+
}
|
|
6303
|
+
async function bundlePlaygroundIIFE(filePath, componentName) {
|
|
6304
|
+
const wrapperCode = (
|
|
6305
|
+
/* ts */
|
|
6306
|
+
`
|
|
6307
|
+
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
6308
|
+
import { createRoot } from "react-dom/client";
|
|
6309
|
+
import { createElement, Component as ReactComponent } from "react";
|
|
6310
|
+
|
|
6311
|
+
(function scopePlaygroundHarness() {
|
|
6312
|
+
var Target =
|
|
6313
|
+
__scopeMod["default"] ||
|
|
6314
|
+
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
6315
|
+
(Object.values(__scopeMod).find(
|
|
6316
|
+
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
6317
|
+
));
|
|
6318
|
+
|
|
6319
|
+
if (!Target) {
|
|
6320
|
+
document.getElementById("scope-root").innerHTML =
|
|
6321
|
+
'<p style="color:#dc2626;font-family:system-ui;font-size:13px">No renderable component found.</p>';
|
|
6322
|
+
return;
|
|
6323
|
+
}
|
|
6324
|
+
|
|
6325
|
+
// Error boundary to catch async render errors (React unmounts the whole
|
|
6326
|
+
// root when an error is uncaught \u2014 this keeps the error visible instead).
|
|
6327
|
+
var errorStyle = "color:#dc2626;font-family:system-ui;font-size:13px;padding:12px";
|
|
6328
|
+
class ScopeBoundary extends ReactComponent {
|
|
6329
|
+
constructor(p) { super(p); this.state = { error: null }; }
|
|
6330
|
+
static getDerivedStateFromError(err) { return { error: err }; }
|
|
6331
|
+
render() {
|
|
6332
|
+
if (this.state.error) {
|
|
6333
|
+
return createElement("pre", { style: errorStyle },
|
|
6334
|
+
"Render error: " + (this.state.error.message || String(this.state.error)));
|
|
6335
|
+
}
|
|
6336
|
+
return this.props.children;
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
|
|
6340
|
+
var rootEl = document.getElementById("scope-root");
|
|
6341
|
+
var root = createRoot(rootEl);
|
|
6342
|
+
var Wrapper = window.__SCOPE_WRAPPER__;
|
|
6343
|
+
|
|
6344
|
+
function render(props) {
|
|
6345
|
+
var inner = createElement(Target, props);
|
|
6346
|
+
if (Wrapper) inner = createElement(Wrapper, null, inner);
|
|
6347
|
+
root.render(createElement(ScopeBoundary, null, inner));
|
|
6348
|
+
}
|
|
6349
|
+
|
|
6350
|
+
// Render immediately with empty props
|
|
6351
|
+
render({});
|
|
6352
|
+
|
|
6353
|
+
// Listen for messages from the parent frame
|
|
6354
|
+
window.addEventListener("message", function(e) {
|
|
6355
|
+
if (!e.data) return;
|
|
6356
|
+
if (e.data.type === "scope-playground-props") {
|
|
6357
|
+
render(e.data.props || {});
|
|
6358
|
+
} else if (e.data.type === "scope-playground-theme") {
|
|
6359
|
+
document.documentElement.classList.toggle("dark", e.data.theme === "dark");
|
|
6360
|
+
}
|
|
6361
|
+
});
|
|
6362
|
+
|
|
6363
|
+
// Report content height changes to the parent frame
|
|
6364
|
+
var ro = new ResizeObserver(function() {
|
|
6365
|
+
var h = rootEl.scrollHeight;
|
|
6366
|
+
if (parent !== window) {
|
|
6367
|
+
parent.postMessage({ type: "scope-playground-height", height: h }, "*");
|
|
6368
|
+
}
|
|
6369
|
+
});
|
|
6370
|
+
ro.observe(rootEl);
|
|
6371
|
+
})();
|
|
6372
|
+
`
|
|
6373
|
+
);
|
|
6374
|
+
const result = await esbuild3.build({
|
|
6375
|
+
stdin: {
|
|
6376
|
+
contents: wrapperCode,
|
|
6377
|
+
resolveDir: dirname5(filePath),
|
|
6378
|
+
loader: "tsx",
|
|
6379
|
+
sourcefile: "__scope_playground__.tsx"
|
|
6380
|
+
},
|
|
6381
|
+
bundle: true,
|
|
6382
|
+
format: "iife",
|
|
6383
|
+
write: false,
|
|
6384
|
+
platform: "browser",
|
|
6385
|
+
jsx: "automatic",
|
|
6386
|
+
jsxImportSource: "react",
|
|
6387
|
+
target: "es2020",
|
|
6388
|
+
external: [],
|
|
6389
|
+
define: {
|
|
6390
|
+
"process.env.NODE_ENV": '"production"',
|
|
6391
|
+
global: "globalThis"
|
|
6392
|
+
},
|
|
6393
|
+
logLevel: "silent",
|
|
6394
|
+
banner: {
|
|
6395
|
+
js: "/* @agent-scope/cli playground harness */"
|
|
6396
|
+
},
|
|
6397
|
+
loader: {
|
|
6398
|
+
".css": "empty",
|
|
6399
|
+
".svg": "dataurl",
|
|
6400
|
+
".png": "dataurl",
|
|
6401
|
+
".jpg": "dataurl",
|
|
6402
|
+
".jpeg": "dataurl",
|
|
6403
|
+
".gif": "dataurl",
|
|
6404
|
+
".webp": "dataurl",
|
|
6405
|
+
".ttf": "dataurl",
|
|
6406
|
+
".woff": "dataurl",
|
|
6407
|
+
".woff2": "dataurl"
|
|
6408
|
+
}
|
|
6409
|
+
});
|
|
6410
|
+
if (result.errors.length > 0) {
|
|
6411
|
+
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
6412
|
+
throw new Error(`esbuild failed to bundle playground component:
|
|
6413
|
+
${msg}`);
|
|
6414
|
+
}
|
|
6415
|
+
const outputFile = result.outputFiles?.[0];
|
|
6416
|
+
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
6417
|
+
throw new Error("esbuild produced no playground output");
|
|
6418
|
+
}
|
|
6419
|
+
return outputFile.text;
|
|
6420
|
+
}
|
|
6421
|
+
function wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript) {
|
|
6422
|
+
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
6423
|
+
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
6424
|
+
</style>` : "";
|
|
6425
|
+
const wrapperScriptBlock = wrapperScript != null && wrapperScript.length > 0 ? `<script id="scope-wrapper-script">${wrapperScript}</script>` : "";
|
|
6426
|
+
return `<!DOCTYPE html>
|
|
6427
|
+
<html lang="en">
|
|
6428
|
+
<head>
|
|
6429
|
+
<meta charset="UTF-8" />
|
|
6430
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6431
|
+
<script>
|
|
6432
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
6433
|
+
// Prevent React DevTools from interfering with the embedded playground.
|
|
6434
|
+
// The hook causes render instability in same-origin iframes.
|
|
6435
|
+
delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
6436
|
+
</script>
|
|
6437
|
+
<style>
|
|
6438
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
6439
|
+
html, body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
|
|
6440
|
+
#scope-root { padding: 16px; min-width: 1px; min-height: 1px; }
|
|
6441
|
+
</style>
|
|
6442
|
+
${projectStyleBlock}
|
|
6443
|
+
<style>html, body { background: transparent !important; }</style>
|
|
6444
|
+
</head>
|
|
6445
|
+
<body>
|
|
6446
|
+
<div id="scope-root" data-reactscope-root></div>
|
|
6447
|
+
${wrapperScriptBlock}
|
|
6448
|
+
<script>${bundledScript}</script>
|
|
6449
|
+
</body>
|
|
6450
|
+
</html>`;
|
|
6451
|
+
}
|
|
6452
|
+
|
|
6453
|
+
// src/site-commands.ts
|
|
5536
6454
|
var MIME_TYPES = {
|
|
5537
6455
|
".html": "text/html; charset=utf-8",
|
|
5538
6456
|
".css": "text/css; charset=utf-8",
|
|
@@ -5544,20 +6462,431 @@ var MIME_TYPES = {
|
|
|
5544
6462
|
".svg": "image/svg+xml",
|
|
5545
6463
|
".ico": "image/x-icon"
|
|
5546
6464
|
};
|
|
6465
|
+
function slugify(name) {
|
|
6466
|
+
return name.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`).replace(/^-/, "").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
6467
|
+
}
|
|
6468
|
+
function loadGlobalCssFilesFromConfig2(cwd) {
|
|
6469
|
+
const configPath = resolve15(cwd, "reactscope.config.json");
|
|
6470
|
+
if (!existsSync13(configPath)) return [];
|
|
6471
|
+
try {
|
|
6472
|
+
const raw = readFileSync12(configPath, "utf-8");
|
|
6473
|
+
const cfg = JSON.parse(raw);
|
|
6474
|
+
return cfg.components?.wrappers?.globalCSS ?? [];
|
|
6475
|
+
} catch {
|
|
6476
|
+
return [];
|
|
6477
|
+
}
|
|
6478
|
+
}
|
|
6479
|
+
function loadIconPatternsFromConfig2(cwd) {
|
|
6480
|
+
const configPath = resolve15(cwd, "reactscope.config.json");
|
|
6481
|
+
if (!existsSync13(configPath)) return [];
|
|
6482
|
+
try {
|
|
6483
|
+
const raw = readFileSync12(configPath, "utf-8");
|
|
6484
|
+
const cfg = JSON.parse(raw);
|
|
6485
|
+
return cfg.icons?.patterns ?? [];
|
|
6486
|
+
} catch {
|
|
6487
|
+
return [];
|
|
6488
|
+
}
|
|
6489
|
+
}
|
|
6490
|
+
var LIVERELOAD_SCRIPT = `<script>(function(){var s=new EventSource("/__livereload");s.onmessage=function(e){if(e.data==="reload")location.reload()};s.onerror=function(){setTimeout(function(){location.reload()},2000)}})()</script>`;
|
|
6491
|
+
function injectLiveReloadScript(html) {
|
|
6492
|
+
const idx = html.lastIndexOf("</body>");
|
|
6493
|
+
if (idx >= 0) return html.slice(0, idx) + LIVERELOAD_SCRIPT + html.slice(idx);
|
|
6494
|
+
return html + LIVERELOAD_SCRIPT;
|
|
6495
|
+
}
|
|
6496
|
+
function loadWatchConfig(rootDir) {
|
|
6497
|
+
const configPath = resolve15(rootDir, "reactscope.config.json");
|
|
6498
|
+
if (!existsSync13(configPath)) return null;
|
|
6499
|
+
try {
|
|
6500
|
+
const raw = readFileSync12(configPath, "utf-8");
|
|
6501
|
+
const cfg = JSON.parse(raw);
|
|
6502
|
+
const result = {};
|
|
6503
|
+
const components = cfg.components;
|
|
6504
|
+
if (components && typeof components === "object") {
|
|
6505
|
+
if (Array.isArray(components.include)) result.include = components.include;
|
|
6506
|
+
if (Array.isArray(components.exclude)) result.exclude = components.exclude;
|
|
6507
|
+
}
|
|
6508
|
+
if (Array.isArray(cfg.internalPatterns))
|
|
6509
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
6510
|
+
if (Array.isArray(cfg.collections)) result.collections = cfg.collections;
|
|
6511
|
+
const icons = cfg.icons;
|
|
6512
|
+
if (icons && typeof icons === "object" && Array.isArray(icons.patterns)) {
|
|
6513
|
+
result.iconPatterns = icons.patterns;
|
|
6514
|
+
}
|
|
6515
|
+
return result;
|
|
6516
|
+
} catch {
|
|
6517
|
+
return null;
|
|
6518
|
+
}
|
|
6519
|
+
}
|
|
6520
|
+
function watchGlob(pattern, filePath) {
|
|
6521
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
6522
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/\u00a7GLOBSTAR\u00a7/g, ".*");
|
|
6523
|
+
return new RegExp(`^${regexStr}$`, "i").test(filePath);
|
|
6524
|
+
}
|
|
6525
|
+
function matchesWatchPatterns(filePath, include, exclude) {
|
|
6526
|
+
for (const pattern of exclude) {
|
|
6527
|
+
if (watchGlob(pattern, filePath)) return false;
|
|
6528
|
+
}
|
|
6529
|
+
for (const pattern of include) {
|
|
6530
|
+
if (watchGlob(pattern, filePath)) return true;
|
|
6531
|
+
}
|
|
6532
|
+
return false;
|
|
6533
|
+
}
|
|
6534
|
+
function findAffectedComponents(manifest, changedFiles, previousManifest) {
|
|
6535
|
+
const affected = /* @__PURE__ */ new Set();
|
|
6536
|
+
const normalised = changedFiles.map((f) => f.replace(/\\/g, "/"));
|
|
6537
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6538
|
+
const componentFile = descriptor.filePath.replace(/\\/g, "/");
|
|
6539
|
+
for (const changed of normalised) {
|
|
6540
|
+
if (componentFile === changed) {
|
|
6541
|
+
affected.add(name);
|
|
6542
|
+
break;
|
|
6543
|
+
}
|
|
6544
|
+
const scopeBase = changed.replace(/\.scope\.(ts|tsx|js|jsx)$/, "");
|
|
6545
|
+
const compBase = componentFile.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
6546
|
+
if (scopeBase !== changed && compBase === scopeBase) {
|
|
6547
|
+
affected.add(name);
|
|
6548
|
+
break;
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6551
|
+
}
|
|
6552
|
+
if (previousManifest) {
|
|
6553
|
+
const oldNames = new Set(Object.keys(previousManifest.components));
|
|
6554
|
+
for (const name of Object.keys(manifest.components)) {
|
|
6555
|
+
if (!oldNames.has(name)) affected.add(name);
|
|
6556
|
+
}
|
|
6557
|
+
}
|
|
6558
|
+
return [...affected];
|
|
6559
|
+
}
|
|
6560
|
+
async function renderComponentsForWatch(manifest, componentNames, rootDir, inputDir) {
|
|
6561
|
+
if (componentNames.length === 0) return;
|
|
6562
|
+
const rendersDir = join6(inputDir, "renders");
|
|
6563
|
+
await mkdir(rendersDir, { recursive: true });
|
|
6564
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6565
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6566
|
+
const complianceStylesPath = join6(inputDir, "compliance-styles.json");
|
|
6567
|
+
let complianceStyles = {};
|
|
6568
|
+
if (existsSync13(complianceStylesPath)) {
|
|
6569
|
+
try {
|
|
6570
|
+
complianceStyles = JSON.parse(readFileSync12(complianceStylesPath, "utf-8"));
|
|
6571
|
+
} catch {
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
for (const name of componentNames) {
|
|
6575
|
+
const descriptor = manifest.components[name];
|
|
6576
|
+
if (!descriptor) continue;
|
|
6577
|
+
const filePath = resolve15(rootDir, descriptor.filePath);
|
|
6578
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
6579
|
+
let scopeData = null;
|
|
6580
|
+
try {
|
|
6581
|
+
scopeData = await loadScopeFileForComponent(filePath);
|
|
6582
|
+
} catch {
|
|
6583
|
+
}
|
|
6584
|
+
const scenarioEntries = scopeData ? Object.entries(scopeData.scenarios) : [];
|
|
6585
|
+
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
6586
|
+
const renderProps = defaultEntry?.[1] ?? {};
|
|
6587
|
+
let wrapperScript;
|
|
6588
|
+
try {
|
|
6589
|
+
wrapperScript = scopeData?.hasWrapper ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
6590
|
+
} catch {
|
|
6591
|
+
}
|
|
6592
|
+
const renderer = buildRenderer(
|
|
6593
|
+
filePath,
|
|
6594
|
+
name,
|
|
6595
|
+
375,
|
|
6596
|
+
812,
|
|
6597
|
+
cssFiles,
|
|
6598
|
+
rootDir,
|
|
6599
|
+
wrapperScript,
|
|
6600
|
+
isIcon
|
|
6601
|
+
);
|
|
6602
|
+
const outcome = await safeRender5(
|
|
6603
|
+
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
6604
|
+
{
|
|
6605
|
+
props: renderProps,
|
|
6606
|
+
sourceLocation: { file: descriptor.filePath, line: descriptor.loc.start, column: 0 }
|
|
6607
|
+
}
|
|
6608
|
+
);
|
|
6609
|
+
if (outcome.crashed) {
|
|
6610
|
+
process.stderr.write(` \u2717 ${name}: ${outcome.error.message}
|
|
6611
|
+
`);
|
|
6612
|
+
continue;
|
|
6613
|
+
}
|
|
6614
|
+
const result = outcome.result;
|
|
6615
|
+
if (!isIcon) {
|
|
6616
|
+
writeFileSync10(join6(rendersDir, `${name}.png`), result.screenshot);
|
|
6617
|
+
}
|
|
6618
|
+
const renderJson = formatRenderJson(name, renderProps, result);
|
|
6619
|
+
const extResult = result;
|
|
6620
|
+
if (isIcon && extResult.svgContent) {
|
|
6621
|
+
renderJson.svgContent = extResult.svgContent;
|
|
6622
|
+
delete renderJson.screenshot;
|
|
6623
|
+
}
|
|
6624
|
+
writeFileSync10(join6(rendersDir, `${name}.json`), JSON.stringify(renderJson, null, 2));
|
|
6625
|
+
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
6626
|
+
const compStyles = {
|
|
6627
|
+
colors: {},
|
|
6628
|
+
spacing: {},
|
|
6629
|
+
typography: {},
|
|
6630
|
+
borders: {},
|
|
6631
|
+
shadows: {}
|
|
6632
|
+
};
|
|
6633
|
+
for (const [prop, val] of Object.entries(rawStyles)) {
|
|
6634
|
+
if (!val || val === "none" || val === "") continue;
|
|
6635
|
+
const lower = prop.toLowerCase();
|
|
6636
|
+
if (lower.includes("color") || lower.includes("background")) {
|
|
6637
|
+
compStyles.colors[prop] = val;
|
|
6638
|
+
} else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
|
|
6639
|
+
compStyles.spacing[prop] = val;
|
|
6640
|
+
} else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
|
|
6641
|
+
compStyles.typography[prop] = val;
|
|
6642
|
+
} else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
|
|
6643
|
+
compStyles.borders[prop] = val;
|
|
6644
|
+
} else if (lower.includes("shadow")) {
|
|
6645
|
+
compStyles.shadows[prop] = val;
|
|
6646
|
+
}
|
|
6647
|
+
}
|
|
6648
|
+
complianceStyles[name] = compStyles;
|
|
6649
|
+
process.stderr.write(` \u2713 ${name} (${result.renderTimeMs.toFixed(0)}ms)
|
|
6650
|
+
`);
|
|
6651
|
+
}
|
|
6652
|
+
await shutdownPool3();
|
|
6653
|
+
writeFileSync10(complianceStylesPath, JSON.stringify(complianceStyles, null, 2), "utf-8");
|
|
6654
|
+
}
|
|
6655
|
+
async function watchRebuildSite(inputDir, outputDir, title, basePath) {
|
|
6656
|
+
const rootDir = process.cwd();
|
|
6657
|
+
await generatePlaygrounds(inputDir, outputDir);
|
|
6658
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6659
|
+
let tokenFilePath;
|
|
6660
|
+
const autoPath = resolve15(rootDir, "reactscope.tokens.json");
|
|
6661
|
+
if (existsSync13(autoPath)) tokenFilePath = autoPath;
|
|
6662
|
+
let compliancePath;
|
|
6663
|
+
const crPath = join6(inputDir, "compliance-report.json");
|
|
6664
|
+
if (existsSync13(crPath)) compliancePath = crPath;
|
|
6665
|
+
await buildSite({
|
|
6666
|
+
inputDir,
|
|
6667
|
+
outputDir,
|
|
6668
|
+
basePath,
|
|
6669
|
+
...compliancePath && { compliancePath },
|
|
6670
|
+
...tokenFilePath && { tokenFilePath },
|
|
6671
|
+
title,
|
|
6672
|
+
iconPatterns
|
|
6673
|
+
});
|
|
6674
|
+
}
|
|
6675
|
+
function findStaleComponents(manifest, previousManifest, rendersDir) {
|
|
6676
|
+
const stale = [];
|
|
6677
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6678
|
+
const jsonPath = join6(rendersDir, `${name}.json`);
|
|
6679
|
+
if (!existsSync13(jsonPath)) {
|
|
6680
|
+
stale.push(name);
|
|
6681
|
+
continue;
|
|
6682
|
+
}
|
|
6683
|
+
if (!previousManifest) continue;
|
|
6684
|
+
const prev = previousManifest.components[name];
|
|
6685
|
+
if (!prev) {
|
|
6686
|
+
stale.push(name);
|
|
6687
|
+
continue;
|
|
6688
|
+
}
|
|
6689
|
+
if (JSON.stringify(prev) !== JSON.stringify(descriptor)) {
|
|
6690
|
+
stale.push(name);
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
return stale;
|
|
6694
|
+
}
|
|
6695
|
+
async function runFullBuild(rootDir, inputDir, outputDir, title, basePath) {
|
|
6696
|
+
process.stderr.write("[watch] Starting\u2026\n");
|
|
6697
|
+
const config = loadWatchConfig(rootDir);
|
|
6698
|
+
const manifestPath = join6(inputDir, "manifest.json");
|
|
6699
|
+
let previousManifest = null;
|
|
6700
|
+
if (existsSync13(manifestPath)) {
|
|
6701
|
+
try {
|
|
6702
|
+
previousManifest = JSON.parse(readFileSync12(manifestPath, "utf-8"));
|
|
6703
|
+
} catch {
|
|
6704
|
+
}
|
|
6705
|
+
}
|
|
6706
|
+
process.stderr.write("[watch] Generating manifest\u2026\n");
|
|
6707
|
+
const manifest = await generateManifest6({
|
|
6708
|
+
rootDir,
|
|
6709
|
+
...config?.include && { include: config.include },
|
|
6710
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6711
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6712
|
+
...config?.collections && { collections: config.collections },
|
|
6713
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6714
|
+
});
|
|
6715
|
+
await mkdir(inputDir, { recursive: true });
|
|
6716
|
+
writeFileSync10(join6(inputDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
|
6717
|
+
const count = Object.keys(manifest.components).length;
|
|
6718
|
+
process.stderr.write(`[watch] Found ${count} components
|
|
6719
|
+
`);
|
|
6720
|
+
const rendersDir = join6(inputDir, "renders");
|
|
6721
|
+
const stale = findStaleComponents(manifest, previousManifest, rendersDir);
|
|
6722
|
+
if (stale.length > 0) {
|
|
6723
|
+
process.stderr.write(
|
|
6724
|
+
`[watch] Rendering ${stale.length} component(s) (${count - stale.length} already up-to-date)
|
|
6725
|
+
`
|
|
6726
|
+
);
|
|
6727
|
+
await renderComponentsForWatch(manifest, stale, rootDir, inputDir);
|
|
6728
|
+
} else {
|
|
6729
|
+
process.stderr.write("[watch] All renders up-to-date, skipping render step\n");
|
|
6730
|
+
}
|
|
6731
|
+
process.stderr.write("[watch] Building site\u2026\n");
|
|
6732
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6733
|
+
process.stderr.write("[watch] Ready\n");
|
|
6734
|
+
return manifest;
|
|
6735
|
+
}
|
|
6736
|
+
function startFileWatcher(opts) {
|
|
6737
|
+
const { rootDir, inputDir, outputDir, title, basePath, notifyReload } = opts;
|
|
6738
|
+
let previousManifest = opts.previousManifest;
|
|
6739
|
+
const config = loadWatchConfig(rootDir);
|
|
6740
|
+
const includePatterns = config?.include ?? ["src/**/*.tsx", "src/**/*.ts"];
|
|
6741
|
+
const excludePatterns = config?.exclude ?? [
|
|
6742
|
+
"**/node_modules/**",
|
|
6743
|
+
"**/*.test.*",
|
|
6744
|
+
"**/*.spec.*",
|
|
6745
|
+
"**/dist/**",
|
|
6746
|
+
"**/*.d.ts"
|
|
6747
|
+
];
|
|
6748
|
+
let debounceTimer = null;
|
|
6749
|
+
const pendingFiles = /* @__PURE__ */ new Set();
|
|
6750
|
+
let isRunning = false;
|
|
6751
|
+
const IGNORE_PREFIXES = ["node_modules/", ".reactscope/", "dist/", ".git/", ".next/", ".turbo/"];
|
|
6752
|
+
const handleChange = async () => {
|
|
6753
|
+
if (isRunning) return;
|
|
6754
|
+
isRunning = true;
|
|
6755
|
+
const changedFiles = [...pendingFiles];
|
|
6756
|
+
pendingFiles.clear();
|
|
6757
|
+
try {
|
|
6758
|
+
process.stderr.write(`
|
|
6759
|
+
[watch] ${changedFiles.length} file(s) changed
|
|
6760
|
+
`);
|
|
6761
|
+
process.stderr.write("[watch] Regenerating manifest\u2026\n");
|
|
6762
|
+
const newManifest = await generateManifest6({
|
|
6763
|
+
rootDir,
|
|
6764
|
+
...config?.include && { include: config.include },
|
|
6765
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6766
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6767
|
+
...config?.collections && { collections: config.collections },
|
|
6768
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6769
|
+
});
|
|
6770
|
+
writeFileSync10(join6(inputDir, "manifest.json"), JSON.stringify(newManifest, null, 2), "utf-8");
|
|
6771
|
+
const affected = findAffectedComponents(newManifest, changedFiles, previousManifest);
|
|
6772
|
+
if (affected.length > 0) {
|
|
6773
|
+
process.stderr.write(`[watch] Re-rendering: ${affected.join(", ")}
|
|
6774
|
+
`);
|
|
6775
|
+
await renderComponentsForWatch(newManifest, affected, rootDir, inputDir);
|
|
6776
|
+
} else {
|
|
6777
|
+
process.stderr.write("[watch] No components directly affected\n");
|
|
6778
|
+
}
|
|
6779
|
+
process.stderr.write("[watch] Rebuilding site\u2026\n");
|
|
6780
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6781
|
+
previousManifest = newManifest;
|
|
6782
|
+
process.stderr.write("[watch] Done\n");
|
|
6783
|
+
notifyReload();
|
|
6784
|
+
} catch (err) {
|
|
6785
|
+
process.stderr.write(`[watch] Error: ${err instanceof Error ? err.message : String(err)}
|
|
6786
|
+
`);
|
|
6787
|
+
} finally {
|
|
6788
|
+
isRunning = false;
|
|
6789
|
+
if (pendingFiles.size > 0) {
|
|
6790
|
+
handleChange();
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6793
|
+
};
|
|
6794
|
+
const onFileChange = (_eventType, filename) => {
|
|
6795
|
+
if (!filename) return;
|
|
6796
|
+
const normalised = filename.replace(/\\/g, "/");
|
|
6797
|
+
for (const prefix of IGNORE_PREFIXES) {
|
|
6798
|
+
if (normalised.startsWith(prefix)) return;
|
|
6799
|
+
}
|
|
6800
|
+
if (!matchesWatchPatterns(normalised, includePatterns, excludePatterns)) return;
|
|
6801
|
+
pendingFiles.add(normalised);
|
|
6802
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
6803
|
+
debounceTimer = setTimeout(() => {
|
|
6804
|
+
debounceTimer = null;
|
|
6805
|
+
handleChange();
|
|
6806
|
+
}, 500);
|
|
6807
|
+
};
|
|
6808
|
+
try {
|
|
6809
|
+
fsWatch(rootDir, { recursive: true }, onFileChange);
|
|
6810
|
+
process.stderr.write(`[watch] Watching for changes (${includePatterns.join(", ")})
|
|
6811
|
+
`);
|
|
6812
|
+
} catch (err) {
|
|
6813
|
+
process.stderr.write(
|
|
6814
|
+
`[watch] Warning: Could not start watcher: ${err instanceof Error ? err.message : String(err)}
|
|
6815
|
+
`
|
|
6816
|
+
);
|
|
6817
|
+
}
|
|
6818
|
+
}
|
|
6819
|
+
async function generatePlaygrounds(inputDir, outputDir) {
|
|
6820
|
+
const manifestPath = join6(inputDir, "manifest.json");
|
|
6821
|
+
const raw = readFileSync12(manifestPath, "utf-8");
|
|
6822
|
+
const manifest = JSON.parse(raw);
|
|
6823
|
+
const rootDir = process.cwd();
|
|
6824
|
+
const componentNames = Object.keys(manifest.components);
|
|
6825
|
+
if (componentNames.length === 0) return [];
|
|
6826
|
+
const playgroundDir = join6(outputDir, "playground");
|
|
6827
|
+
await mkdir(playgroundDir, { recursive: true });
|
|
6828
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6829
|
+
const projectCss = await loadGlobalCss(cssFiles, rootDir) ?? void 0;
|
|
6830
|
+
let succeeded = 0;
|
|
6831
|
+
const failures = [];
|
|
6832
|
+
const allDefaults = {};
|
|
6833
|
+
for (const name of componentNames) {
|
|
6834
|
+
const descriptor = manifest.components[name];
|
|
6835
|
+
if (!descriptor) continue;
|
|
6836
|
+
const filePath = resolve15(rootDir, descriptor.filePath);
|
|
6837
|
+
const slug = slugify(name);
|
|
6838
|
+
try {
|
|
6839
|
+
const scopeData = await loadScopeFileForComponent(filePath);
|
|
6840
|
+
if (scopeData) {
|
|
6841
|
+
const defaultScenario = scopeData.scenarios.default ?? Object.values(scopeData.scenarios)[0];
|
|
6842
|
+
if (defaultScenario) allDefaults[name] = defaultScenario;
|
|
6843
|
+
}
|
|
6844
|
+
} catch {
|
|
6845
|
+
}
|
|
6846
|
+
try {
|
|
6847
|
+
const html = await buildPlaygroundHarness(filePath, name, projectCss);
|
|
6848
|
+
await writeFile(join6(playgroundDir, `${slug}.html`), html, "utf-8");
|
|
6849
|
+
succeeded++;
|
|
6850
|
+
} catch (err) {
|
|
6851
|
+
process.stderr.write(
|
|
6852
|
+
`[scope/site] \u26A0 playground skip: ${name} \u2014 ${err instanceof Error ? err.message : String(err)}
|
|
6853
|
+
`
|
|
6854
|
+
);
|
|
6855
|
+
failures.push({
|
|
6856
|
+
component: name,
|
|
6857
|
+
stage: "playground",
|
|
6858
|
+
message: err instanceof Error ? err.message : String(err),
|
|
6859
|
+
outputPath: join6(playgroundDir, `${slug}.html`)
|
|
6860
|
+
});
|
|
6861
|
+
}
|
|
6862
|
+
}
|
|
6863
|
+
await writeFile(
|
|
6864
|
+
join6(inputDir, "playground-defaults.json"),
|
|
6865
|
+
JSON.stringify(allDefaults, null, 2),
|
|
6866
|
+
"utf-8"
|
|
6867
|
+
);
|
|
6868
|
+
process.stderr.write(
|
|
6869
|
+
`[scope/site] Playgrounds: ${succeeded} built${failures.length > 0 ? `, ${failures.length} failed` : ""}
|
|
6870
|
+
`
|
|
6871
|
+
);
|
|
6872
|
+
return failures;
|
|
6873
|
+
}
|
|
5547
6874
|
function registerBuild(siteCmd) {
|
|
5548
|
-
siteCmd.command("build").description(
|
|
6875
|
+
siteCmd.command("build").description(
|
|
6876
|
+
'Build the static HTML site from manifest + render outputs.\n\nINPUT DIRECTORY (.reactscope/ by default) must contain:\n manifest.json component registry\n renders/ screenshots and render.json files from `scope render all`\n\nOPTIONAL:\n --compliance <path> include token compliance scores on detail pages\n --base-path <path> set if deploying to a subdirectory (e.g. /ui-docs)\n\nExamples:\n scope site build\n scope site build --title "Design System" -o .reactscope/site\n scope site build --compliance .reactscope/compliance-report.json\n scope site build --tokens reactscope.tokens.json\n scope site build --base-path /ui'
|
|
6877
|
+
).option("-i, --input <path>", "Path to .reactscope input directory", ".reactscope").option("-o, --output <path>", "Output directory for generated site", ".reactscope/site").option("--base-path <path>", "Base URL path prefix for subdirectory deployment", "/").option("--compliance <path>", "Path to compliance batch report JSON").option("--tokens <path>", "Path to reactscope.tokens.json (enables token browser page)").option("--title <text>", "Site title", "Scope \u2014 Component Gallery").action(
|
|
5549
6878
|
async (opts) => {
|
|
5550
6879
|
try {
|
|
5551
|
-
const inputDir =
|
|
5552
|
-
const outputDir =
|
|
5553
|
-
if (!
|
|
6880
|
+
const inputDir = resolve15(process.cwd(), opts.input);
|
|
6881
|
+
const outputDir = resolve15(process.cwd(), opts.output);
|
|
6882
|
+
if (!existsSync13(inputDir)) {
|
|
5554
6883
|
throw new Error(
|
|
5555
6884
|
`Input directory not found: ${inputDir}
|
|
5556
6885
|
Run \`scope manifest generate\` and \`scope render\` first.`
|
|
5557
6886
|
);
|
|
5558
6887
|
}
|
|
5559
|
-
const manifestPath =
|
|
5560
|
-
if (!
|
|
6888
|
+
const manifestPath = join6(inputDir, "manifest.json");
|
|
6889
|
+
if (!existsSync13(manifestPath)) {
|
|
5561
6890
|
throw new Error(
|
|
5562
6891
|
`Manifest not found at ${manifestPath}
|
|
5563
6892
|
Run \`scope manifest generate\` first.`
|
|
@@ -5565,21 +6894,61 @@ Run \`scope manifest generate\` first.`
|
|
|
5565
6894
|
}
|
|
5566
6895
|
process.stderr.write(`Building site from ${inputDir}\u2026
|
|
5567
6896
|
`);
|
|
6897
|
+
process.stderr.write("Bundling playgrounds\u2026\n");
|
|
6898
|
+
const failures = await generatePlaygrounds(inputDir, outputDir);
|
|
6899
|
+
const iconPatterns = loadIconPatternsFromConfig2(process.cwd());
|
|
6900
|
+
let tokenFilePath = opts.tokens ? resolve15(process.cwd(), opts.tokens) : void 0;
|
|
6901
|
+
if (tokenFilePath === void 0) {
|
|
6902
|
+
const autoPath = resolve15(process.cwd(), "reactscope.tokens.json");
|
|
6903
|
+
if (existsSync13(autoPath)) {
|
|
6904
|
+
tokenFilePath = autoPath;
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
5568
6907
|
await buildSite({
|
|
5569
6908
|
inputDir,
|
|
5570
6909
|
outputDir,
|
|
5571
6910
|
basePath: opts.basePath,
|
|
5572
6911
|
...opts.compliance !== void 0 && {
|
|
5573
|
-
compliancePath:
|
|
6912
|
+
compliancePath: resolve15(process.cwd(), opts.compliance)
|
|
5574
6913
|
},
|
|
5575
|
-
|
|
6914
|
+
...tokenFilePath !== void 0 && { tokenFilePath },
|
|
6915
|
+
title: opts.title,
|
|
6916
|
+
iconPatterns
|
|
6917
|
+
});
|
|
6918
|
+
const manifest = JSON.parse(readFileSync12(manifestPath, "utf-8"));
|
|
6919
|
+
const componentCount = Object.keys(manifest.components).length;
|
|
6920
|
+
const generatedPlaygroundCount = componentCount === 0 ? 0 : statSync2(join6(outputDir, "playground")).isDirectory() ? componentCount - failures.length : 0;
|
|
6921
|
+
const siteFailures = [...failures];
|
|
6922
|
+
if (componentCount === 0) {
|
|
6923
|
+
siteFailures.push({
|
|
6924
|
+
component: "*",
|
|
6925
|
+
stage: "site",
|
|
6926
|
+
message: "Manifest contains zero components; generated site is structurally degraded.",
|
|
6927
|
+
outputPath: manifestPath
|
|
6928
|
+
});
|
|
6929
|
+
} else if (generatedPlaygroundCount === 0) {
|
|
6930
|
+
siteFailures.push({
|
|
6931
|
+
component: "*",
|
|
6932
|
+
stage: "site",
|
|
6933
|
+
message: "No playground pages were generated successfully; site build is degraded and should not be treated as green.",
|
|
6934
|
+
outputPath: join6(outputDir, "playground")
|
|
6935
|
+
});
|
|
6936
|
+
}
|
|
6937
|
+
const summaryPath = writeRunSummary({
|
|
6938
|
+
command: "scope site build",
|
|
6939
|
+
status: siteFailures.length > 0 ? "failed" : "success",
|
|
6940
|
+
outputPaths: [outputDir, join6(outputDir, "index.html")],
|
|
6941
|
+
failures: siteFailures
|
|
5576
6942
|
});
|
|
5577
6943
|
process.stderr.write(`Site written to ${outputDir}
|
|
6944
|
+
`);
|
|
6945
|
+
process.stderr.write(`[scope/site] Run summary written to ${summaryPath}
|
|
5578
6946
|
`);
|
|
5579
6947
|
process.stdout.write(`${outputDir}
|
|
5580
6948
|
`);
|
|
6949
|
+
if (siteFailures.length > 0) process.exit(1);
|
|
5581
6950
|
} catch (err) {
|
|
5582
|
-
process.stderr.write(
|
|
6951
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
5583
6952
|
`);
|
|
5584
6953
|
process.exit(1);
|
|
5585
6954
|
}
|
|
@@ -5587,71 +6956,143 @@ Run \`scope manifest generate\` first.`
|
|
|
5587
6956
|
);
|
|
5588
6957
|
}
|
|
5589
6958
|
function registerServe(siteCmd) {
|
|
5590
|
-
siteCmd.command("serve").description(
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
const
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
5609
|
-
res.end("Forbidden");
|
|
5610
|
-
return;
|
|
6959
|
+
siteCmd.command("serve").description(
|
|
6960
|
+
"Start a local HTTP server for the built site directory.\n\nRun `scope site build` first, or use --watch to auto-rebuild on changes.\nCtrl+C to stop.\n\nExamples:\n scope site serve\n scope site serve --port 8080\n scope site serve --dir ./my-site-output\n scope site serve --watch"
|
|
6961
|
+
).option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").option("-w, --watch", "Watch source files and rebuild on changes").option(
|
|
6962
|
+
"-i, --input <path>",
|
|
6963
|
+
"Input directory for .reactscope data (watch mode)",
|
|
6964
|
+
".reactscope"
|
|
6965
|
+
).option("--title <text>", "Site title (watch mode)", "Scope \u2014 Component Gallery").option("--base-path <path>", "Base URL path prefix (watch mode)", "/").action(
|
|
6966
|
+
async (opts) => {
|
|
6967
|
+
try {
|
|
6968
|
+
let notifyReload2 = function() {
|
|
6969
|
+
for (const client of sseClients) {
|
|
6970
|
+
client.write("data: reload\n\n");
|
|
6971
|
+
}
|
|
6972
|
+
};
|
|
6973
|
+
var notifyReload = notifyReload2;
|
|
6974
|
+
const port = Number.parseInt(opts.port, 10);
|
|
6975
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
6976
|
+
throw new Error(`Invalid port: ${opts.port}`);
|
|
5611
6977
|
}
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
return;
|
|
6978
|
+
const serveDir = resolve15(process.cwd(), opts.dir);
|
|
6979
|
+
const watchMode = opts.watch === true;
|
|
6980
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
6981
|
+
if (watchMode) {
|
|
6982
|
+
await mkdir(serveDir, { recursive: true });
|
|
5618
6983
|
}
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
6984
|
+
if (!watchMode && !existsSync13(serveDir)) {
|
|
6985
|
+
throw new Error(
|
|
6986
|
+
`Serve directory not found: ${serveDir}
|
|
6987
|
+
Run \`scope site build\` first.`
|
|
6988
|
+
);
|
|
5624
6989
|
}
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
6990
|
+
const server = createServer((req, res) => {
|
|
6991
|
+
const rawUrl = req.url ?? "/";
|
|
6992
|
+
const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
|
|
6993
|
+
if (watchMode && urlPath === "/__livereload") {
|
|
6994
|
+
res.writeHead(200, {
|
|
6995
|
+
"Content-Type": "text/event-stream",
|
|
6996
|
+
"Cache-Control": "no-cache",
|
|
6997
|
+
Connection: "keep-alive",
|
|
6998
|
+
"Access-Control-Allow-Origin": "*"
|
|
6999
|
+
});
|
|
7000
|
+
res.write("data: connected\n\n");
|
|
7001
|
+
sseClients.add(res);
|
|
7002
|
+
req.on("close", () => sseClients.delete(res));
|
|
7003
|
+
return;
|
|
7004
|
+
}
|
|
7005
|
+
const filePath = join6(
|
|
7006
|
+
serveDir,
|
|
7007
|
+
urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath
|
|
7008
|
+
);
|
|
7009
|
+
if (!filePath.startsWith(serveDir)) {
|
|
7010
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
7011
|
+
res.end("Forbidden");
|
|
7012
|
+
return;
|
|
7013
|
+
}
|
|
7014
|
+
if (existsSync13(filePath) && statSync2(filePath).isFile()) {
|
|
7015
|
+
const ext = extname(filePath).toLowerCase();
|
|
7016
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
7017
|
+
if (watchMode && ext === ".html") {
|
|
7018
|
+
const html = injectLiveReloadScript(readFileSync12(filePath, "utf-8"));
|
|
7019
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
7020
|
+
res.end(html);
|
|
7021
|
+
return;
|
|
7022
|
+
}
|
|
7023
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
7024
|
+
createReadStream(filePath).pipe(res);
|
|
7025
|
+
return;
|
|
7026
|
+
}
|
|
7027
|
+
const htmlPath = `${filePath}.html`;
|
|
7028
|
+
if (existsSync13(htmlPath) && statSync2(htmlPath).isFile()) {
|
|
7029
|
+
if (watchMode) {
|
|
7030
|
+
const html = injectLiveReloadScript(readFileSync12(htmlPath, "utf-8"));
|
|
7031
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
7032
|
+
res.end(html);
|
|
7033
|
+
return;
|
|
7034
|
+
}
|
|
7035
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
7036
|
+
createReadStream(htmlPath).pipe(res);
|
|
7037
|
+
return;
|
|
7038
|
+
}
|
|
7039
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
7040
|
+
res.end(`Not found: ${urlPath}`);
|
|
7041
|
+
});
|
|
7042
|
+
server.listen(port, () => {
|
|
7043
|
+
process.stderr.write(`Scope site running at http://localhost:${port}
|
|
5630
7044
|
`);
|
|
5631
|
-
|
|
7045
|
+
process.stderr.write(`Serving ${serveDir}
|
|
5632
7046
|
`);
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
7047
|
+
if (watchMode) {
|
|
7048
|
+
process.stderr.write(
|
|
7049
|
+
"Watch mode enabled \u2014 source changes trigger rebuild + browser reload\n"
|
|
7050
|
+
);
|
|
7051
|
+
}
|
|
7052
|
+
process.stderr.write("Press Ctrl+C to stop.\n");
|
|
7053
|
+
});
|
|
7054
|
+
server.on("error", (err) => {
|
|
7055
|
+
if (err.code === "EADDRINUSE") {
|
|
7056
|
+
process.stderr.write(`Error: Port ${port} is already in use.
|
|
5638
7057
|
`);
|
|
5639
|
-
|
|
5640
|
-
|
|
7058
|
+
} else {
|
|
7059
|
+
process.stderr.write(`Server error: ${err.message}
|
|
5641
7060
|
`);
|
|
7061
|
+
}
|
|
7062
|
+
process.exit(1);
|
|
7063
|
+
});
|
|
7064
|
+
if (watchMode) {
|
|
7065
|
+
const rootDir = process.cwd();
|
|
7066
|
+
const inputDir = resolve15(rootDir, opts.input);
|
|
7067
|
+
const initialManifest = await runFullBuild(
|
|
7068
|
+
rootDir,
|
|
7069
|
+
inputDir,
|
|
7070
|
+
serveDir,
|
|
7071
|
+
opts.title,
|
|
7072
|
+
opts.basePath
|
|
7073
|
+
);
|
|
7074
|
+
notifyReload2();
|
|
7075
|
+
startFileWatcher({
|
|
7076
|
+
rootDir,
|
|
7077
|
+
inputDir,
|
|
7078
|
+
outputDir: serveDir,
|
|
7079
|
+
title: opts.title,
|
|
7080
|
+
basePath: opts.basePath,
|
|
7081
|
+
previousManifest: initialManifest,
|
|
7082
|
+
notifyReload: notifyReload2
|
|
7083
|
+
});
|
|
5642
7084
|
}
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
} catch (err) {
|
|
5646
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
7085
|
+
} catch (err) {
|
|
7086
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
5647
7087
|
`);
|
|
5648
|
-
|
|
7088
|
+
process.exit(1);
|
|
7089
|
+
}
|
|
5649
7090
|
}
|
|
5650
|
-
|
|
7091
|
+
);
|
|
5651
7092
|
}
|
|
5652
7093
|
function createSiteCommand() {
|
|
5653
|
-
const siteCmd = new
|
|
5654
|
-
|
|
7094
|
+
const siteCmd = new Command9("site").description(
|
|
7095
|
+
'Build and serve the static HTML component gallery site.\n\nPREREQUISITES:\n scope manifest generate (manifest.json)\n scope render all (renders/ + compliance-styles.json)\n\nSITE CONTENTS:\n /index.html component gallery with screenshots + metadata\n /<component>/index.html detail page: props, renders, matrix, X-Ray, compliance\n\nExamples:\n scope site build && scope site serve\n scope site build --title "Acme UI" --compliance .reactscope/compliance-report.json\n scope site serve --port 8080'
|
|
5655
7096
|
);
|
|
5656
7097
|
registerBuild(siteCmd);
|
|
5657
7098
|
registerServe(siteCmd);
|
|
@@ -5659,8 +7100,8 @@ function createSiteCommand() {
|
|
|
5659
7100
|
}
|
|
5660
7101
|
|
|
5661
7102
|
// src/tokens/commands.ts
|
|
5662
|
-
import { existsSync as
|
|
5663
|
-
import { resolve as
|
|
7103
|
+
import { existsSync as existsSync17, readFileSync as readFileSync16 } from "fs";
|
|
7104
|
+
import { resolve as resolve20 } from "path";
|
|
5664
7105
|
import {
|
|
5665
7106
|
parseTokenFileSync as parseTokenFileSync2,
|
|
5666
7107
|
TokenParseError,
|
|
@@ -5668,26 +7109,26 @@ import {
|
|
|
5668
7109
|
TokenValidationError,
|
|
5669
7110
|
validateTokenFile
|
|
5670
7111
|
} from "@agent-scope/tokens";
|
|
5671
|
-
import { Command as
|
|
7112
|
+
import { Command as Command11 } from "commander";
|
|
5672
7113
|
|
|
5673
7114
|
// src/tokens/compliance.ts
|
|
5674
|
-
import { existsSync as
|
|
5675
|
-
import { resolve as
|
|
7115
|
+
import { existsSync as existsSync14, readFileSync as readFileSync13, writeFileSync as writeFileSync11 } from "fs";
|
|
7116
|
+
import { resolve as resolve16 } from "path";
|
|
5676
7117
|
import {
|
|
5677
7118
|
ComplianceEngine as ComplianceEngine4,
|
|
5678
7119
|
TokenResolver as TokenResolver4
|
|
5679
7120
|
} from "@agent-scope/tokens";
|
|
5680
7121
|
var DEFAULT_STYLES_PATH = ".reactscope/compliance-styles.json";
|
|
5681
7122
|
function loadStylesFile(stylesPath) {
|
|
5682
|
-
const absPath =
|
|
5683
|
-
if (!
|
|
7123
|
+
const absPath = resolve16(process.cwd(), stylesPath);
|
|
7124
|
+
if (!existsSync14(absPath)) {
|
|
5684
7125
|
throw new Error(
|
|
5685
7126
|
`Compliance styles file not found at ${absPath}.
|
|
5686
7127
|
Run \`scope render all\` first to generate component styles, or use --styles to specify a path.
|
|
5687
7128
|
Expected format: { "ComponentName": { colors: {}, spacing: {}, typography: {}, borders: {}, shadows: {} } }`
|
|
5688
7129
|
);
|
|
5689
7130
|
}
|
|
5690
|
-
const raw =
|
|
7131
|
+
const raw = readFileSync13(absPath, "utf-8");
|
|
5691
7132
|
let parsed;
|
|
5692
7133
|
try {
|
|
5693
7134
|
parsed = JSON.parse(raw);
|
|
@@ -5715,11 +7156,11 @@ function categoryForProperty(property) {
|
|
|
5715
7156
|
}
|
|
5716
7157
|
function buildCategorySummary(batch) {
|
|
5717
7158
|
const cats = {
|
|
5718
|
-
color: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5719
|
-
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5720
|
-
typography: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5721
|
-
border: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5722
|
-
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
7159
|
+
color: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7160
|
+
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7161
|
+
typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7162
|
+
border: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7163
|
+
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 }
|
|
5723
7164
|
};
|
|
5724
7165
|
for (const report of Object.values(batch.components)) {
|
|
5725
7166
|
for (const [property, result] of Object.entries(report.properties)) {
|
|
@@ -5735,7 +7176,7 @@ function buildCategorySummary(batch) {
|
|
|
5735
7176
|
}
|
|
5736
7177
|
}
|
|
5737
7178
|
for (const summary of Object.values(cats)) {
|
|
5738
|
-
summary.compliance = summary.total === 0 ?
|
|
7179
|
+
summary.compliance = summary.total === 0 ? 0 : summary.onSystem / summary.total;
|
|
5739
7180
|
}
|
|
5740
7181
|
return cats;
|
|
5741
7182
|
}
|
|
@@ -5776,6 +7217,11 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5776
7217
|
const lines = [];
|
|
5777
7218
|
const thresholdLabel = threshold !== void 0 ? pct >= threshold ? " \u2713 (pass)" : ` \u2717 (below threshold ${threshold}%)` : "";
|
|
5778
7219
|
lines.push(`Overall compliance score: ${pct}%${thresholdLabel}`);
|
|
7220
|
+
if (batch.totalProperties === 0) {
|
|
7221
|
+
lines.push(
|
|
7222
|
+
"No CSS properties were audited; run `scope render all` and inspect .reactscope/compliance-styles.json before treating compliance as green."
|
|
7223
|
+
);
|
|
7224
|
+
}
|
|
5779
7225
|
lines.push("");
|
|
5780
7226
|
const cats = buildCategorySummary(batch);
|
|
5781
7227
|
const catEntries = Object.entries(cats).filter(([, s]) => s.total > 0);
|
|
@@ -5809,76 +7255,122 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5809
7255
|
return lines.join("\n");
|
|
5810
7256
|
}
|
|
5811
7257
|
function registerCompliance(tokensCmd) {
|
|
5812
|
-
tokensCmd.command("compliance").description(
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
7258
|
+
tokensCmd.command("compliance").description(
|
|
7259
|
+
"Compute a token compliance score across all rendered components.\n\nCompares computed CSS values from .reactscope/compliance-styles.json\nagainst the token file \u2014 reports what % of style values are on-token.\n\nPREREQUISITES:\n scope render all must have run first (produces compliance-styles.json)\n\nSCORING:\n compliant value exactly matches a token\n near-match value is within tolerance of a token (e.g. close color)\n off-token value not found in token file\n\nEXIT CODES:\n 0 compliance >= threshold (or no --threshold set)\n 1 compliance < threshold\n\nExamples:\n scope tokens compliance\n scope tokens compliance --threshold 90\n scope tokens compliance --format json | jq '.summary'\n scope tokens compliance --out .reactscope/compliance-report.json\n scope tokens compliance --styles ./custom/compliance-styles.json"
|
|
7260
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
|
|
7261
|
+
"--out <path>",
|
|
7262
|
+
"Write JSON report to file (for use with scope site build --compliance)"
|
|
7263
|
+
).option("--threshold <n>", "Exit code 1 if compliance score is below this percentage (0-100)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
|
|
7264
|
+
(opts) => {
|
|
7265
|
+
try {
|
|
7266
|
+
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
7267
|
+
const { tokens } = loadTokens(tokenFilePath);
|
|
7268
|
+
const resolver = new TokenResolver4(tokens);
|
|
7269
|
+
const engine = new ComplianceEngine4(resolver);
|
|
7270
|
+
const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
|
|
7271
|
+
const stylesFile = loadStylesFile(stylesPath);
|
|
7272
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
7273
|
+
for (const [name, styles] of Object.entries(stylesFile)) {
|
|
7274
|
+
componentMap.set(name, styles);
|
|
7275
|
+
}
|
|
7276
|
+
if (componentMap.size === 0) {
|
|
7277
|
+
process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
|
|
5826
7278
|
`);
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
7279
|
+
}
|
|
7280
|
+
const batch = engine.auditBatch(componentMap);
|
|
7281
|
+
const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
|
|
7282
|
+
const failures = [];
|
|
7283
|
+
if (batch.totalProperties === 0) {
|
|
7284
|
+
failures.push({
|
|
7285
|
+
component: "*",
|
|
7286
|
+
stage: "compliance",
|
|
7287
|
+
message: `No CSS properties were audited from ${stylesPath}; refusing to report silent success.`,
|
|
7288
|
+
outputPath: stylesPath
|
|
7289
|
+
});
|
|
7290
|
+
} else if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
7291
|
+
failures.push({
|
|
7292
|
+
component: "*",
|
|
7293
|
+
stage: "compliance",
|
|
7294
|
+
message: `Compliance ${Math.round(batch.aggregateCompliance * 100)}% is below threshold ${threshold}%.`,
|
|
7295
|
+
outputPath: opts.out ?? ".reactscope/compliance-report.json"
|
|
7296
|
+
});
|
|
7297
|
+
}
|
|
7298
|
+
if (opts.out !== void 0) {
|
|
7299
|
+
const outPath = resolve16(process.cwd(), opts.out);
|
|
7300
|
+
writeFileSync11(outPath, JSON.stringify(batch, null, 2), "utf-8");
|
|
7301
|
+
process.stderr.write(`Compliance report written to ${outPath}
|
|
5833
7302
|
`);
|
|
5834
|
-
|
|
5835
|
-
|
|
7303
|
+
}
|
|
7304
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
|
|
7305
|
+
if (useJson) {
|
|
7306
|
+
process.stdout.write(`${JSON.stringify(batch, null, 2)}
|
|
7307
|
+
`);
|
|
7308
|
+
} else {
|
|
7309
|
+
process.stdout.write(`${formatComplianceReport(batch, threshold)}
|
|
7310
|
+
`);
|
|
7311
|
+
}
|
|
7312
|
+
const summaryPath = writeRunSummary({
|
|
7313
|
+
command: "scope tokens compliance",
|
|
7314
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
7315
|
+
outputPaths: [opts.out ?? ".reactscope/compliance-report.json", stylesPath],
|
|
7316
|
+
compliance: {
|
|
7317
|
+
auditedProperties: batch.totalProperties,
|
|
7318
|
+
onSystemProperties: batch.totalOnSystem,
|
|
7319
|
+
offSystemProperties: batch.totalOffSystem,
|
|
7320
|
+
score: Math.round(batch.aggregateCompliance * 100),
|
|
7321
|
+
threshold
|
|
7322
|
+
},
|
|
7323
|
+
failures
|
|
7324
|
+
});
|
|
7325
|
+
process.stderr.write(`[scope/tokens] Run summary written to ${summaryPath}
|
|
7326
|
+
`);
|
|
7327
|
+
if (failures.length > 0) {
|
|
7328
|
+
process.exit(1);
|
|
7329
|
+
}
|
|
7330
|
+
} catch (err) {
|
|
7331
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5836
7332
|
`);
|
|
5837
|
-
}
|
|
5838
|
-
if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
5839
7333
|
process.exit(1);
|
|
5840
7334
|
}
|
|
5841
|
-
} catch (err) {
|
|
5842
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5843
|
-
`);
|
|
5844
|
-
process.exit(1);
|
|
5845
7335
|
}
|
|
5846
|
-
|
|
7336
|
+
);
|
|
5847
7337
|
}
|
|
5848
7338
|
|
|
5849
7339
|
// src/tokens/export.ts
|
|
5850
|
-
import { existsSync as
|
|
5851
|
-
import { resolve as
|
|
7340
|
+
import { existsSync as existsSync15, readFileSync as readFileSync14, writeFileSync as writeFileSync12 } from "fs";
|
|
7341
|
+
import { resolve as resolve17 } from "path";
|
|
5852
7342
|
import {
|
|
5853
7343
|
exportTokens,
|
|
5854
7344
|
parseTokenFileSync,
|
|
5855
7345
|
ThemeResolver,
|
|
5856
7346
|
TokenResolver as TokenResolver5
|
|
5857
7347
|
} from "@agent-scope/tokens";
|
|
5858
|
-
import { Command as
|
|
7348
|
+
import { Command as Command10 } from "commander";
|
|
5859
7349
|
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
5860
7350
|
var CONFIG_FILE = "reactscope.config.json";
|
|
5861
7351
|
var SUPPORTED_FORMATS = ["css", "ts", "scss", "tailwind", "flat-json", "figma"];
|
|
5862
7352
|
function resolveTokenFilePath2(fileFlag) {
|
|
5863
7353
|
if (fileFlag !== void 0) {
|
|
5864
|
-
return
|
|
7354
|
+
return resolve17(process.cwd(), fileFlag);
|
|
5865
7355
|
}
|
|
5866
|
-
const configPath =
|
|
5867
|
-
if (
|
|
7356
|
+
const configPath = resolve17(process.cwd(), CONFIG_FILE);
|
|
7357
|
+
if (existsSync15(configPath)) {
|
|
5868
7358
|
try {
|
|
5869
|
-
const raw =
|
|
7359
|
+
const raw = readFileSync14(configPath, "utf-8");
|
|
5870
7360
|
const config = JSON.parse(raw);
|
|
5871
7361
|
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
5872
7362
|
const file = config.tokens.file;
|
|
5873
|
-
return
|
|
7363
|
+
return resolve17(process.cwd(), file);
|
|
5874
7364
|
}
|
|
5875
7365
|
} catch {
|
|
5876
7366
|
}
|
|
5877
7367
|
}
|
|
5878
|
-
return
|
|
7368
|
+
return resolve17(process.cwd(), DEFAULT_TOKEN_FILE);
|
|
5879
7369
|
}
|
|
5880
7370
|
function createTokensExportCommand() {
|
|
5881
|
-
return new
|
|
7371
|
+
return new Command10("export").description(
|
|
7372
|
+
'Export design tokens to CSS variables, TypeScript, SCSS, Tailwind config, Figma, or flat JSON.\n\nFORMATS:\n css CSS custom properties (:root { --color-primary-500: #3b82f6; })\n scss SCSS variables ($color-primary-500: #3b82f6;)\n ts TypeScript const export (export const tokens = {...})\n tailwind Tailwind theme.extend block (paste into tailwind.config.js)\n flat-json Flat { path: value } map (useful for tooling integration)\n figma Figma Tokens plugin format\n\nExamples:\n scope tokens export --format css --out src/tokens.css\n scope tokens export --format css --prefix brand --selector ":root, [data-theme]"\n scope tokens export --format tailwind --out tailwind-tokens.js\n scope tokens export --format ts --out src/tokens.ts\n scope tokens export --format css --theme dark --out dark-tokens.css'
|
|
7373
|
+
).requiredOption("--format <fmt>", `Output format: ${SUPPORTED_FORMATS.join(", ")}`).option("--file <path>", "Path to token file (overrides config)").option("--out <path>", "Write output to file instead of stdout").option("--prefix <prefix>", "CSS/SCSS: prefix for variable names (e.g. 'scope')").option("--selector <selector>", "CSS: custom root selector (default: ':root')").option(
|
|
5882
7374
|
"--theme <name>",
|
|
5883
7375
|
"Include theme overrides for the named theme (applies to css, ts, scss, tailwind, figma)"
|
|
5884
7376
|
).action(
|
|
@@ -5904,13 +7396,13 @@ Supported formats: ${SUPPORTED_FORMATS.join(", ")}
|
|
|
5904
7396
|
const format = opts.format;
|
|
5905
7397
|
try {
|
|
5906
7398
|
const filePath = resolveTokenFilePath2(opts.file);
|
|
5907
|
-
if (!
|
|
7399
|
+
if (!existsSync15(filePath)) {
|
|
5908
7400
|
throw new Error(
|
|
5909
7401
|
`Token file not found at ${filePath}.
|
|
5910
7402
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
5911
7403
|
);
|
|
5912
7404
|
}
|
|
5913
|
-
const raw =
|
|
7405
|
+
const raw = readFileSync14(filePath, "utf-8");
|
|
5914
7406
|
const { tokens, rawFile } = parseTokenFileSync(raw);
|
|
5915
7407
|
let themesMap;
|
|
5916
7408
|
if (opts.theme !== void 0) {
|
|
@@ -5949,8 +7441,8 @@ Available themes: ${themeNames.join(", ")}`
|
|
|
5949
7441
|
themes: themesMap
|
|
5950
7442
|
});
|
|
5951
7443
|
if (opts.out !== void 0) {
|
|
5952
|
-
const outPath =
|
|
5953
|
-
|
|
7444
|
+
const outPath = resolve17(process.cwd(), opts.out);
|
|
7445
|
+
writeFileSync12(outPath, output, "utf-8");
|
|
5954
7446
|
process.stderr.write(`Exported ${tokens.length} tokens to ${outPath}
|
|
5955
7447
|
`);
|
|
5956
7448
|
} else {
|
|
@@ -6025,7 +7517,9 @@ function formatImpactSummary(report) {
|
|
|
6025
7517
|
return `\u2192 ${parts.join(", ")}`;
|
|
6026
7518
|
}
|
|
6027
7519
|
function registerImpact(tokensCmd) {
|
|
6028
|
-
tokensCmd.command("impact <path>").description(
|
|
7520
|
+
tokensCmd.command("impact <path>").description(
|
|
7521
|
+
"List every component and CSS element that uses a given token.\nUse this to understand the blast radius before changing a token value.\n\nPREREQUISITE: scope render all (populates compliance-styles.json)\n\nExamples:\n scope tokens impact color.primary.500\n scope tokens impact spacing.4 --format json\n scope tokens impact font.size.base | grep Button"
|
|
7522
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH2})`).option("--new-value <value>", "Proposed new value \u2014 report visual severity of the change").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
|
|
6029
7523
|
(tokenPath, opts) => {
|
|
6030
7524
|
try {
|
|
6031
7525
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -6063,23 +7557,251 @@ ${formatImpactSummary(report)}
|
|
|
6063
7557
|
);
|
|
6064
7558
|
}
|
|
6065
7559
|
|
|
7560
|
+
// src/tokens/init.ts
|
|
7561
|
+
import { existsSync as existsSync16, readFileSync as readFileSync15, writeFileSync as writeFileSync13 } from "fs";
|
|
7562
|
+
import { resolve as resolve18 } from "path";
|
|
7563
|
+
var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
|
|
7564
|
+
var CONFIG_FILE2 = "reactscope.config.json";
|
|
7565
|
+
function resolveOutputPath(fileFlag) {
|
|
7566
|
+
if (fileFlag !== void 0) {
|
|
7567
|
+
return resolve18(process.cwd(), fileFlag);
|
|
7568
|
+
}
|
|
7569
|
+
const configPath = resolve18(process.cwd(), CONFIG_FILE2);
|
|
7570
|
+
if (existsSync16(configPath)) {
|
|
7571
|
+
try {
|
|
7572
|
+
const raw = readFileSync15(configPath, "utf-8");
|
|
7573
|
+
const config = JSON.parse(raw);
|
|
7574
|
+
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
7575
|
+
const file = config.tokens.file;
|
|
7576
|
+
return resolve18(process.cwd(), file);
|
|
7577
|
+
}
|
|
7578
|
+
} catch {
|
|
7579
|
+
}
|
|
7580
|
+
}
|
|
7581
|
+
return resolve18(process.cwd(), DEFAULT_TOKEN_FILE2);
|
|
7582
|
+
}
|
|
7583
|
+
var CSS_VAR_RE = /--([\w-]+)\s*:\s*([^;]+)/g;
|
|
7584
|
+
var HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
7585
|
+
var COLOR_FN_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(/;
|
|
7586
|
+
var DIMENSION_RE = /^-?\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|ch|ex|cap|lh|dvh|svh|lvh)$/;
|
|
7587
|
+
var DURATION_RE = /^-?\d+(?:\.\d+)?(?:ms|s)$/;
|
|
7588
|
+
var FONT_FAMILY_RE = /^["']|,\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui)/;
|
|
7589
|
+
var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
7590
|
+
var CUBIC_BEZIER_RE = /^cubic-bezier\(/;
|
|
7591
|
+
var SHADOW_RE = /^\d.*(?:px|rem|em)\s+(?:#|rgba?|hsla?|oklch|oklab)/i;
|
|
7592
|
+
function inferTokenType(value) {
|
|
7593
|
+
const v = value.trim();
|
|
7594
|
+
if (HEX_COLOR_RE.test(v) || COLOR_FN_RE.test(v)) return "color";
|
|
7595
|
+
if (DURATION_RE.test(v)) return "duration";
|
|
7596
|
+
if (DIMENSION_RE.test(v)) return "dimension";
|
|
7597
|
+
if (FONT_FAMILY_RE.test(v)) return "fontFamily";
|
|
7598
|
+
if (CUBIC_BEZIER_RE.test(v)) return "cubicBezier";
|
|
7599
|
+
if (SHADOW_RE.test(v)) return "shadow";
|
|
7600
|
+
if (NUMBER_RE.test(v)) return "number";
|
|
7601
|
+
return "color";
|
|
7602
|
+
}
|
|
7603
|
+
function setNestedToken(root, segments, value, type) {
|
|
7604
|
+
let node = root;
|
|
7605
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
7606
|
+
const seg = segments[i];
|
|
7607
|
+
if (seg === void 0) continue;
|
|
7608
|
+
if (!(seg in node) || typeof node[seg] !== "object" || node[seg] === null) {
|
|
7609
|
+
node[seg] = {};
|
|
7610
|
+
}
|
|
7611
|
+
node = node[seg];
|
|
7612
|
+
}
|
|
7613
|
+
const leaf = segments[segments.length - 1];
|
|
7614
|
+
if (leaf === void 0) return;
|
|
7615
|
+
node[leaf] = { value, type };
|
|
7616
|
+
}
|
|
7617
|
+
function extractBlockBody(css, openBrace) {
|
|
7618
|
+
let depth = 0;
|
|
7619
|
+
let end = -1;
|
|
7620
|
+
for (let i = openBrace; i < css.length; i++) {
|
|
7621
|
+
if (css[i] === "{") depth++;
|
|
7622
|
+
else if (css[i] === "}") {
|
|
7623
|
+
depth--;
|
|
7624
|
+
if (depth === 0) {
|
|
7625
|
+
end = i;
|
|
7626
|
+
break;
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
}
|
|
7630
|
+
if (end === -1) return "";
|
|
7631
|
+
return css.slice(openBrace + 1, end);
|
|
7632
|
+
}
|
|
7633
|
+
function parseScopedBlocks(css) {
|
|
7634
|
+
const blocks = [];
|
|
7635
|
+
const blockRe = /(?::root|@theme(?:\s+inline)?|\.dark\.high-contrast|\.dark)\s*\{/g;
|
|
7636
|
+
let match = blockRe.exec(css);
|
|
7637
|
+
while (match !== null) {
|
|
7638
|
+
const selector = match[0];
|
|
7639
|
+
const braceIdx = css.indexOf("{", match.index);
|
|
7640
|
+
if (braceIdx === -1) {
|
|
7641
|
+
match = blockRe.exec(css);
|
|
7642
|
+
continue;
|
|
7643
|
+
}
|
|
7644
|
+
const body = extractBlockBody(css, braceIdx);
|
|
7645
|
+
let scope;
|
|
7646
|
+
if (selector.includes(".dark.high-contrast")) scope = "dark-high-contrast";
|
|
7647
|
+
else if (selector.includes(".dark")) scope = "dark";
|
|
7648
|
+
else if (selector.includes("@theme")) scope = "theme";
|
|
7649
|
+
else scope = "root";
|
|
7650
|
+
blocks.push({ scope, body });
|
|
7651
|
+
match = blockRe.exec(css);
|
|
7652
|
+
}
|
|
7653
|
+
return blocks;
|
|
7654
|
+
}
|
|
7655
|
+
function extractVarsFromBody(body) {
|
|
7656
|
+
const results = [];
|
|
7657
|
+
for (const m of body.matchAll(CSS_VAR_RE)) {
|
|
7658
|
+
const name = m[1];
|
|
7659
|
+
const value = m[2]?.trim();
|
|
7660
|
+
if (name === void 0 || value === void 0 || value.length === 0) continue;
|
|
7661
|
+
if (value.startsWith("var(") || value.startsWith("calc(")) continue;
|
|
7662
|
+
results.push({ name, value });
|
|
7663
|
+
}
|
|
7664
|
+
return results;
|
|
7665
|
+
}
|
|
7666
|
+
function extractCSSCustomProperties(tokenSources) {
|
|
7667
|
+
const cssSources = tokenSources.filter(
|
|
7668
|
+
(s) => s.kind === "css-custom-properties" || s.kind === "tailwind-v4-theme"
|
|
7669
|
+
);
|
|
7670
|
+
if (cssSources.length === 0) return null;
|
|
7671
|
+
const tokens = {};
|
|
7672
|
+
const themes = {};
|
|
7673
|
+
let found = false;
|
|
7674
|
+
for (const source of cssSources) {
|
|
7675
|
+
try {
|
|
7676
|
+
if (source.path.includes("compiled") || source.path.includes(".min.")) continue;
|
|
7677
|
+
const raw = readFileSync15(source.path, "utf-8");
|
|
7678
|
+
const blocks = parseScopedBlocks(raw);
|
|
7679
|
+
for (const block of blocks) {
|
|
7680
|
+
const vars = extractVarsFromBody(block.body);
|
|
7681
|
+
for (const { name, value } of vars) {
|
|
7682
|
+
const segments = name.split("-").filter(Boolean);
|
|
7683
|
+
if (segments.length === 0) continue;
|
|
7684
|
+
if (block.scope === "root" || block.scope === "theme") {
|
|
7685
|
+
const type = inferTokenType(value);
|
|
7686
|
+
setNestedToken(tokens, segments, value, type);
|
|
7687
|
+
found = true;
|
|
7688
|
+
} else {
|
|
7689
|
+
const themeName = block.scope;
|
|
7690
|
+
if (!themes[themeName]) themes[themeName] = {};
|
|
7691
|
+
const path = segments.join(".");
|
|
7692
|
+
themes[themeName][path] = value;
|
|
7693
|
+
found = true;
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
}
|
|
7697
|
+
} catch {
|
|
7698
|
+
}
|
|
7699
|
+
}
|
|
7700
|
+
return found ? { tokens, themes } : null;
|
|
7701
|
+
}
|
|
7702
|
+
function registerTokensInit(tokensCmd) {
|
|
7703
|
+
tokensCmd.command("init").description(
|
|
7704
|
+
"Detect design-token sources in the project and generate a token file.\n\nDETECTED SOURCES:\n - Tailwind config (colors, spacing, fontFamily, borderRadius)\n - CSS custom properties (:root { --color-primary: #0070f3; })\n\nWill not overwrite an existing token file unless --force is passed.\n\nOUTPUT PATH RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nExamples:\n scope tokens init\n scope tokens init --force\n scope tokens init --file tokens/brand.json\n scope tokens init --force --file custom-tokens.json"
|
|
7705
|
+
).option("--file <path>", "Output path for the token file (overrides config)").option("--force", "Overwrite existing token file", false).action((opts) => {
|
|
7706
|
+
try {
|
|
7707
|
+
const outPath = resolveOutputPath(opts.file);
|
|
7708
|
+
if (existsSync16(outPath) && !opts.force) {
|
|
7709
|
+
process.stderr.write(
|
|
7710
|
+
`Token file already exists at ${outPath}.
|
|
7711
|
+
Run with --force to overwrite.
|
|
7712
|
+
`
|
|
7713
|
+
);
|
|
7714
|
+
process.exit(1);
|
|
7715
|
+
}
|
|
7716
|
+
const rootDir = process.cwd();
|
|
7717
|
+
const detected = detectProject(rootDir);
|
|
7718
|
+
const tailwindTokens = extractTailwindTokens(detected.tokenSources);
|
|
7719
|
+
const cssResult = extractCSSCustomProperties(detected.tokenSources);
|
|
7720
|
+
const mergedTokens = {};
|
|
7721
|
+
const mergedThemes = {};
|
|
7722
|
+
if (tailwindTokens !== null) {
|
|
7723
|
+
Object.assign(mergedTokens, tailwindTokens);
|
|
7724
|
+
}
|
|
7725
|
+
if (cssResult !== null) {
|
|
7726
|
+
for (const [key, value] of Object.entries(cssResult.tokens)) {
|
|
7727
|
+
if (!(key in mergedTokens)) {
|
|
7728
|
+
mergedTokens[key] = value;
|
|
7729
|
+
}
|
|
7730
|
+
}
|
|
7731
|
+
for (const [themeName, overrides] of Object.entries(cssResult.themes)) {
|
|
7732
|
+
if (!mergedThemes[themeName]) mergedThemes[themeName] = {};
|
|
7733
|
+
Object.assign(mergedThemes[themeName], overrides);
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
const tokenFile = {
|
|
7737
|
+
$schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
|
|
7738
|
+
version: "1.0.0",
|
|
7739
|
+
meta: {
|
|
7740
|
+
name: "Design Tokens",
|
|
7741
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
7742
|
+
},
|
|
7743
|
+
tokens: mergedTokens
|
|
7744
|
+
};
|
|
7745
|
+
if (Object.keys(mergedThemes).length > 0) {
|
|
7746
|
+
tokenFile.themes = mergedThemes;
|
|
7747
|
+
}
|
|
7748
|
+
writeFileSync13(outPath, `${JSON.stringify(tokenFile, null, 2)}
|
|
7749
|
+
`);
|
|
7750
|
+
const tokenGroupCount = Object.keys(mergedTokens).length;
|
|
7751
|
+
const themeNames = Object.keys(mergedThemes);
|
|
7752
|
+
if (detected.tokenSources.length > 0) {
|
|
7753
|
+
process.stdout.write("Detected token sources:\n");
|
|
7754
|
+
for (const source of detected.tokenSources) {
|
|
7755
|
+
process.stdout.write(` ${source.kind}: ${source.path}
|
|
7756
|
+
`);
|
|
7757
|
+
}
|
|
7758
|
+
process.stdout.write("\n");
|
|
7759
|
+
}
|
|
7760
|
+
if (tokenGroupCount > 0) {
|
|
7761
|
+
process.stdout.write(`Extracted ${tokenGroupCount} token group(s) \u2192 ${outPath}
|
|
7762
|
+
`);
|
|
7763
|
+
if (themeNames.length > 0) {
|
|
7764
|
+
for (const name of themeNames) {
|
|
7765
|
+
const count = Object.keys(mergedThemes[name] ?? {}).length;
|
|
7766
|
+
process.stdout.write(` theme "${name}": ${count} override(s)
|
|
7767
|
+
`);
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7770
|
+
} else {
|
|
7771
|
+
process.stdout.write(
|
|
7772
|
+
`No token sources detected. Created empty token file \u2192 ${outPath}
|
|
7773
|
+
Add tokens manually or re-run after configuring a design system.
|
|
7774
|
+
`
|
|
7775
|
+
);
|
|
7776
|
+
}
|
|
7777
|
+
} catch (err) {
|
|
7778
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
7779
|
+
`);
|
|
7780
|
+
process.exit(1);
|
|
7781
|
+
}
|
|
7782
|
+
});
|
|
7783
|
+
}
|
|
7784
|
+
|
|
6066
7785
|
// src/tokens/preview.ts
|
|
6067
|
-
import { mkdirSync as
|
|
6068
|
-
import { resolve as
|
|
7786
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync14 } from "fs";
|
|
7787
|
+
import { resolve as resolve19 } from "path";
|
|
6069
7788
|
import { BrowserPool as BrowserPool6, SpriteSheetGenerator } from "@agent-scope/render";
|
|
6070
7789
|
import { ComplianceEngine as ComplianceEngine6, ImpactAnalyzer as ImpactAnalyzer2, TokenResolver as TokenResolver7 } from "@agent-scope/tokens";
|
|
6071
7790
|
var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
|
|
6072
7791
|
var DEFAULT_MANIFEST_PATH = ".reactscope/manifest.json";
|
|
6073
7792
|
var DEFAULT_OUTPUT_DIR2 = ".reactscope/previews";
|
|
6074
7793
|
async function renderComponentWithCssOverride(filePath, componentName, cssOverride, vpWidth, vpHeight, timeoutMs) {
|
|
7794
|
+
const PAD = 16;
|
|
6075
7795
|
const htmlHarness = await buildComponentHarness(
|
|
6076
7796
|
filePath,
|
|
6077
7797
|
componentName,
|
|
6078
7798
|
{},
|
|
6079
7799
|
// no props
|
|
6080
7800
|
vpWidth,
|
|
6081
|
-
cssOverride
|
|
7801
|
+
cssOverride,
|
|
6082
7802
|
// injected as <style>
|
|
7803
|
+
void 0,
|
|
7804
|
+
PAD
|
|
6083
7805
|
);
|
|
6084
7806
|
const pool = new BrowserPool6({
|
|
6085
7807
|
size: { browsers: 1, pagesPerBrowser: 1 },
|
|
@@ -6100,7 +7822,6 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
6100
7822
|
);
|
|
6101
7823
|
const rootLocator = page.locator("[data-reactscope-root]");
|
|
6102
7824
|
const bb = await rootLocator.boundingBox();
|
|
6103
|
-
const PAD = 16;
|
|
6104
7825
|
const MIN_W = 320;
|
|
6105
7826
|
const MIN_H = 120;
|
|
6106
7827
|
const clipX = Math.max(0, (bb?.x ?? 0) - PAD);
|
|
@@ -6120,7 +7841,9 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
6120
7841
|
}
|
|
6121
7842
|
}
|
|
6122
7843
|
function registerPreview(tokensCmd) {
|
|
6123
|
-
tokensCmd.command("preview <path>").description(
|
|
7844
|
+
tokensCmd.command("preview <path>").description(
|
|
7845
|
+
'Render before/after screenshots of all components affected by a token change.\nUseful for visual review before committing a token value update.\n\nPREREQUISITE: scope render all (provides baseline renders)\n\nExamples:\n scope tokens preview color.primary.500\n scope tokens preview color.primary.500 --new-value "#2563eb" -o preview.png'
|
|
7846
|
+
).requiredOption("--new-value <value>", "The proposed new resolved value for the token").option("--sprite", "Output a PNG sprite sheet (default when TTY)", false).option("-o, --output <path>", "Output PNG path (default: .reactscope/previews/<token>.png)").option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH3})`).option("--manifest <path>", "Path to manifest.json", DEFAULT_MANIFEST_PATH).option("--format <fmt>", "Output format: json or text (default: auto-detect)").option("--timeout <ms>", "Browser timeout per render (ms)", "10000").option("--viewport-width <px>", "Viewport width in pixels", "1280").option("--viewport-height <px>", "Viewport height in pixels", "720").action(
|
|
6124
7847
|
async (tokenPath, opts) => {
|
|
6125
7848
|
try {
|
|
6126
7849
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -6246,10 +7969,10 @@ function registerPreview(tokensCmd) {
|
|
|
6246
7969
|
});
|
|
6247
7970
|
const spriteResult = await generator.generate(matrixResult);
|
|
6248
7971
|
const tokenLabel = tokenPath.replace(/\./g, "-");
|
|
6249
|
-
const outputPath = opts.output ??
|
|
6250
|
-
const outputDir =
|
|
6251
|
-
|
|
6252
|
-
|
|
7972
|
+
const outputPath = opts.output ?? resolve19(process.cwd(), DEFAULT_OUTPUT_DIR2, `preview-${tokenLabel}.png`);
|
|
7973
|
+
const outputDir = resolve19(outputPath, "..");
|
|
7974
|
+
mkdirSync7(outputDir, { recursive: true });
|
|
7975
|
+
writeFileSync14(outputPath, spriteResult.png);
|
|
6253
7976
|
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
|
|
6254
7977
|
if (useJson) {
|
|
6255
7978
|
process.stdout.write(
|
|
@@ -6287,8 +8010,8 @@ function registerPreview(tokensCmd) {
|
|
|
6287
8010
|
}
|
|
6288
8011
|
|
|
6289
8012
|
// src/tokens/commands.ts
|
|
6290
|
-
var
|
|
6291
|
-
var
|
|
8013
|
+
var DEFAULT_TOKEN_FILE3 = "reactscope.tokens.json";
|
|
8014
|
+
var CONFIG_FILE3 = "reactscope.config.json";
|
|
6292
8015
|
function isTTY2() {
|
|
6293
8016
|
return process.stdout.isTTY === true;
|
|
6294
8017
|
}
|
|
@@ -6308,30 +8031,30 @@ function buildTable2(headers, rows) {
|
|
|
6308
8031
|
}
|
|
6309
8032
|
function resolveTokenFilePath(fileFlag) {
|
|
6310
8033
|
if (fileFlag !== void 0) {
|
|
6311
|
-
return
|
|
8034
|
+
return resolve20(process.cwd(), fileFlag);
|
|
6312
8035
|
}
|
|
6313
|
-
const configPath =
|
|
6314
|
-
if (
|
|
8036
|
+
const configPath = resolve20(process.cwd(), CONFIG_FILE3);
|
|
8037
|
+
if (existsSync17(configPath)) {
|
|
6315
8038
|
try {
|
|
6316
|
-
const raw =
|
|
8039
|
+
const raw = readFileSync16(configPath, "utf-8");
|
|
6317
8040
|
const config = JSON.parse(raw);
|
|
6318
8041
|
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
6319
8042
|
const file = config.tokens.file;
|
|
6320
|
-
return
|
|
8043
|
+
return resolve20(process.cwd(), file);
|
|
6321
8044
|
}
|
|
6322
8045
|
} catch {
|
|
6323
8046
|
}
|
|
6324
8047
|
}
|
|
6325
|
-
return
|
|
8048
|
+
return resolve20(process.cwd(), DEFAULT_TOKEN_FILE3);
|
|
6326
8049
|
}
|
|
6327
8050
|
function loadTokens(absPath) {
|
|
6328
|
-
if (!
|
|
8051
|
+
if (!existsSync17(absPath)) {
|
|
6329
8052
|
throw new Error(
|
|
6330
8053
|
`Token file not found at ${absPath}.
|
|
6331
8054
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
6332
8055
|
);
|
|
6333
8056
|
}
|
|
6334
|
-
const raw =
|
|
8057
|
+
const raw = readFileSync16(absPath, "utf-8");
|
|
6335
8058
|
return parseTokenFileSync2(raw);
|
|
6336
8059
|
}
|
|
6337
8060
|
function getRawValue(node, segments) {
|
|
@@ -6367,7 +8090,9 @@ function buildResolutionChain(startPath, rawTokens) {
|
|
|
6367
8090
|
return chain;
|
|
6368
8091
|
}
|
|
6369
8092
|
function registerGet2(tokensCmd) {
|
|
6370
|
-
tokensCmd.command("get <path>").description(
|
|
8093
|
+
tokensCmd.command("get <path>").description(
|
|
8094
|
+
"Resolve a token path and print its final computed value.\nFollows all {ref} chains to the raw value.\n\nExamples:\n scope tokens get color.primary.500\n scope tokens get spacing.4 --format json\n scope tokens get font.size.base --file ./tokens/brand.json"
|
|
8095
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
6371
8096
|
try {
|
|
6372
8097
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6373
8098
|
const { tokens } = loadTokens(filePath);
|
|
@@ -6392,7 +8117,18 @@ function registerGet2(tokensCmd) {
|
|
|
6392
8117
|
});
|
|
6393
8118
|
}
|
|
6394
8119
|
function registerList2(tokensCmd) {
|
|
6395
|
-
tokensCmd.command("list [category]").description(
|
|
8120
|
+
tokensCmd.command("list [category]").description(
|
|
8121
|
+
`List all tokens, optionally filtered by category prefix or type.
|
|
8122
|
+
|
|
8123
|
+
CATEGORY: top-level token namespace (e.g. "color", "spacing", "typography")
|
|
8124
|
+
TYPE: token value type \u2014 color | spacing | typography | shadow | radius | opacity
|
|
8125
|
+
|
|
8126
|
+
Examples:
|
|
8127
|
+
scope tokens list
|
|
8128
|
+
scope tokens list color
|
|
8129
|
+
scope tokens list --type spacing
|
|
8130
|
+
scope tokens list color --format json | jq '.[].path'`
|
|
8131
|
+
).option("--type <type>", "Filter by token type (color, dimension, fontFamily, etc.)").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
6396
8132
|
(category, opts) => {
|
|
6397
8133
|
try {
|
|
6398
8134
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6422,7 +8158,9 @@ function registerList2(tokensCmd) {
|
|
|
6422
8158
|
);
|
|
6423
8159
|
}
|
|
6424
8160
|
function registerSearch(tokensCmd) {
|
|
6425
|
-
tokensCmd.command("search <value>").description(
|
|
8161
|
+
tokensCmd.command("search <value>").description(
|
|
8162
|
+
'Find the token(s) whose computed value matches the given raw value.\nSupports fuzzy color matching (hex \u2194 rgb \u2194 hsl equivalence).\n\nExamples:\n scope tokens search "#3b82f6"\n scope tokens search "16px"\n scope tokens search "rgb(59, 130, 246)" # fuzzy-matches #3b82f6'
|
|
8163
|
+
).option("--type <type>", "Restrict search to a specific token type").option("--fuzzy", "Return nearest match even if no exact match exists", false).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
6426
8164
|
(value, opts) => {
|
|
6427
8165
|
try {
|
|
6428
8166
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6505,7 +8243,9 @@ Tip: use --fuzzy for nearest-match search.
|
|
|
6505
8243
|
);
|
|
6506
8244
|
}
|
|
6507
8245
|
function registerResolve(tokensCmd) {
|
|
6508
|
-
tokensCmd.command("resolve <path>").description(
|
|
8246
|
+
tokensCmd.command("resolve <path>").description(
|
|
8247
|
+
"Print the full reference chain from a token path down to its raw value.\nUseful for debugging circular references or understanding token inheritance.\n\nExamples:\n scope tokens resolve color.primary.500\n scope tokens resolve button.background --format json"
|
|
8248
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
6509
8249
|
try {
|
|
6510
8250
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6511
8251
|
const absFilePath = filePath;
|
|
@@ -6541,17 +8281,29 @@ function registerResolve(tokensCmd) {
|
|
|
6541
8281
|
}
|
|
6542
8282
|
function registerValidate(tokensCmd) {
|
|
6543
8283
|
tokensCmd.command("validate").description(
|
|
6544
|
-
|
|
8284
|
+
`Validate the token file and report errors.
|
|
8285
|
+
|
|
8286
|
+
CHECKS:
|
|
8287
|
+
- Circular reference chains (A \u2192 B \u2192 A)
|
|
8288
|
+
- Broken references ({path.that.does.not.exist})
|
|
8289
|
+
- Type mismatches (token declared as "color" but value is a number)
|
|
8290
|
+
- Duplicate paths
|
|
8291
|
+
|
|
8292
|
+
Exits 1 if any errors are found (suitable for CI).
|
|
8293
|
+
|
|
8294
|
+
Examples:
|
|
8295
|
+
scope tokens validate
|
|
8296
|
+
scope tokens validate --format json | jq '.errors'`
|
|
6545
8297
|
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
|
|
6546
8298
|
try {
|
|
6547
8299
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6548
|
-
if (!
|
|
8300
|
+
if (!existsSync17(filePath)) {
|
|
6549
8301
|
throw new Error(
|
|
6550
8302
|
`Token file not found at ${filePath}.
|
|
6551
8303
|
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
6552
8304
|
);
|
|
6553
8305
|
}
|
|
6554
|
-
const raw =
|
|
8306
|
+
const raw = readFileSync16(filePath, "utf-8");
|
|
6555
8307
|
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY2();
|
|
6556
8308
|
const errors = [];
|
|
6557
8309
|
let parsed;
|
|
@@ -6619,9 +8371,10 @@ function outputValidationResult(filePath, errors, useJson) {
|
|
|
6619
8371
|
}
|
|
6620
8372
|
}
|
|
6621
8373
|
function createTokensCommand() {
|
|
6622
|
-
const tokensCmd = new
|
|
6623
|
-
|
|
8374
|
+
const tokensCmd = new Command11("tokens").description(
|
|
8375
|
+
'Query, validate, and export design tokens from reactscope.tokens.json.\n\nTOKEN FILE RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nTOKEN FILE FORMAT (reactscope.tokens.json):\n Nested JSON. Each leaf is a token with { value, type } or just a raw value.\n Paths use dot notation: color.primary.500, spacing.4, font.size.base\n References use {path.to.other.token} syntax.\n Themes: top-level "themes" key with named override maps.\n\nTOKEN TYPES: color | spacing | typography | shadow | radius | opacity | other\n\nExamples:\n scope tokens validate\n scope tokens list color\n scope tokens get color.primary.500\n scope tokens compliance\n scope tokens export --format css --out tokens.css'
|
|
6624
8376
|
);
|
|
8377
|
+
registerTokensInit(tokensCmd);
|
|
6625
8378
|
registerGet2(tokensCmd);
|
|
6626
8379
|
registerList2(tokensCmd);
|
|
6627
8380
|
registerSearch(tokensCmd);
|
|
@@ -6636,8 +8389,12 @@ function createTokensCommand() {
|
|
|
6636
8389
|
|
|
6637
8390
|
// src/program.ts
|
|
6638
8391
|
function createProgram(options = {}) {
|
|
6639
|
-
const program2 = new
|
|
6640
|
-
|
|
8392
|
+
const program2 = new Command12("scope").version(options.version ?? "0.1.0").description(
|
|
8393
|
+
'Scope \u2014 static analysis + visual rendering toolkit for React component libraries.\n\nScope answers questions about React codebases \u2014 structure, props, visual output,\ndesign token compliance \u2014 without running the full application.\n\nAGENT QUICKSTART:\n scope get-skill > /tmp/scope-skill.md\n scope init --yes\n scope doctor --json\n scope manifest list --format json\n scope render all --format json --output-dir .reactscope/renders\n scope site build --output .reactscope/site\n scope instrument profile http://localhost:5173\n\nCI QUICKSTART:\n scope ci --json --output .reactscope/ci-result.json\n\nCONFIG FILE: reactscope.config.json (created by `scope init`)\n components.include glob patterns for component files (e.g. "src/**/*.tsx")\n components.wrappers providers and globalCSS to wrap every render\n render.viewport default viewport width\xD7height in px\n tokens.file path to reactscope.tokens.json (default)\n output.dir output root (default: .reactscope/)\n ci.complianceThreshold fail threshold for `scope ci` (default: 0.90)\n\nOUTPUT DIRECTORY: .reactscope/\n manifest.json component registry \u2014 updated by `scope manifest generate`\n renders/<Name>/ PNGs + render.json per component\n compliance-styles.json computed-style map for token matching\n site/ static HTML gallery (built by `scope site build`)\n\nRun `scope <command> --help` for detailed flags and examples.'
|
|
8394
|
+
);
|
|
8395
|
+
program2.command("capture <url>").description(
|
|
8396
|
+
"Capture the live React component tree from a running app and emit it as JSON.\nRequires a running dev server at the given URL (e.g. http://localhost:5173).\n\nExamples:\n scope capture http://localhost:5173\n scope capture http://localhost:5173 -o report.json --pretty\n scope capture http://localhost:5173 --timeout 15000 --wait 500"
|
|
8397
|
+
).option("-o, --output <path>", "Write JSON to file instead of stdout").option("--pretty", "Pretty-print JSON output (default: minified)", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6641
8398
|
async (url, opts) => {
|
|
6642
8399
|
try {
|
|
6643
8400
|
const { report } = await browserCapture({
|
|
@@ -6661,7 +8418,9 @@ function createProgram(options = {}) {
|
|
|
6661
8418
|
}
|
|
6662
8419
|
}
|
|
6663
8420
|
);
|
|
6664
|
-
program2.command("tree <url>").description(
|
|
8421
|
+
program2.command("tree <url>").description(
|
|
8422
|
+
"Print a formatted React component tree from a running app.\nUseful for quickly understanding component hierarchy without full capture.\n\nExamples:\n scope tree http://localhost:5173\n scope tree http://localhost:5173 --show-props --show-hooks\n scope tree http://localhost:5173 --depth 4"
|
|
8423
|
+
).option("--depth <n>", "Max depth to display (default: unlimited)").option("--show-props", "Include prop names next to components", false).option("--show-hooks", "Show hook counts per component", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6665
8424
|
async (url, opts) => {
|
|
6666
8425
|
try {
|
|
6667
8426
|
const { report } = await browserCapture({
|
|
@@ -6684,7 +8443,9 @@ function createProgram(options = {}) {
|
|
|
6684
8443
|
}
|
|
6685
8444
|
}
|
|
6686
8445
|
);
|
|
6687
|
-
program2.command("report <url>").description(
|
|
8446
|
+
program2.command("report <url>").description(
|
|
8447
|
+
"Capture a React app and print a human-readable analysis summary.\nIncludes component count, hook usage, side-effect summary, and more.\n\nExamples:\n scope report http://localhost:5173\n scope report http://localhost:5173 --json\n scope report http://localhost:5173 --json -o report.json"
|
|
8448
|
+
).option("--json", "Output as structured JSON instead of human-readable text", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6688
8449
|
async (url, opts) => {
|
|
6689
8450
|
try {
|
|
6690
8451
|
const { report } = await browserCapture({
|
|
@@ -6708,8 +8469,10 @@ function createProgram(options = {}) {
|
|
|
6708
8469
|
}
|
|
6709
8470
|
}
|
|
6710
8471
|
);
|
|
6711
|
-
program2.command("generate").description(
|
|
6712
|
-
|
|
8472
|
+
program2.command("generate").description(
|
|
8473
|
+
'Generate a Playwright test file from a Scope trace (.json).\nTraces are produced by scope instrument renders or scope capture.\n\nExamples:\n scope generate trace.json\n scope generate trace.json -o tests/scope.spec.ts -d "User login flow"'
|
|
8474
|
+
).argument("<trace>", "Path to a serialized Scope trace (.json)").option("-o, --output <path>", "Output file path", "scope.spec.ts").option("-d, --description <text>", "Test description").action((tracePath, opts) => {
|
|
8475
|
+
const raw = readFileSync17(tracePath, "utf-8");
|
|
6713
8476
|
const trace = loadTrace(raw);
|
|
6714
8477
|
const source = generateTest(trace, {
|
|
6715
8478
|
description: opts.description,
|
|
@@ -6725,6 +8488,7 @@ function createProgram(options = {}) {
|
|
|
6725
8488
|
program2.addCommand(createInitCommand());
|
|
6726
8489
|
program2.addCommand(createCiCommand());
|
|
6727
8490
|
program2.addCommand(createDoctorCommand());
|
|
8491
|
+
program2.addCommand(createGetSkillCommand());
|
|
6728
8492
|
const existingReportCmd = program2.commands.find((c) => c.name() === "report");
|
|
6729
8493
|
if (existingReportCmd !== void 0) {
|
|
6730
8494
|
registerBaselineSubCommand(existingReportCmd);
|